Skip to content

Code Walkthrough

Rick Hightower edited this page Jul 29, 2026 · 12 revisions

Current — this is the living version; regenerated at 2026-07-29T00:00:00Z. Historical snapshots are linked from Index-Releases.

Worklog — Code Walkthrough

The guided tour a new team member reads on day one. Every claim is anchored in the code at the commit above. The companion design document is docs/designs/current_design_doc.md; where this walkthrough and that document disagree with the code, the code wins (drift is reported in §6).

1. Orientation

Three sentences: Worklog tracks work as an append-only JSONL event log inside the git repo; item state is a fold over the events, and git's union merge makes concurrent writes compose instead of conflict. Everything human-readable — the roadmap, status reports, Mermaid diagrams, and (since v0.13.0) the IA reader plane under docs/.index/ — is generated from the log and committed docs, and everything remote — tickets, wiki pages — is a mirror driven through a typed dispatcher or a skill. The rules that matter are enforced by hooks and CI, not by memory.

Directory map:

Path What lives there and why
bin/worklog The CLI and the only writer of .work/*.jsonl (1274 lines).
bin/fold.py The only code allowed to decide what the log means (307 lines). Also holds external_owners(), the one-owner-per-remote-ticket index shared by the CLI and the dispatcher (v0.18.0).
bin/ulid.py IDs. Includes the deterministic form that makes ingest idempotent across clones.
bin/canonical.py THE canonical hash (sync change detection). 34 lines, high blast radius.
bin/sync_dispatch.py Every ticket-sync invariant, in one place.
bin/compact.py The only file rewriter. CI-only, verification-gated.
bin/render_roadmap.py, bin/viz_mermaid.py Byte-deterministic roadmap + diagrams.
bin/plan_capture.py, bin/adr.py Pure helpers: plan parsing, ADR tooling.
bin/ia.py wiki_key, truth_state, inventory, normalize, sidecars (IA foundation).
bin/ia_render.py Reader plane: Home, Sidebar, indexes, publish-manifest, aliases, and (v0.14.0) ticket/release/PR artifact pages (670 lines).
bin/ia_graph.py Traceability graph, link-pr, ticket-body, trace-check, and (v0.14.0) build_adjacency()/item_links() (302 lines).
.work/ The log (todo.jsonl, done.jsonl), config.yml, ledgers.
docs/.index/ Generated IA plane (committed, regenerate-and-diff).
adapters/ github (worked example), fake (CI double), authoring rules.
hooks/ git pre-commit/pre-merge-commit/commit-msg (v0.15.0) + four Claude Code hooks.
plugin/ Claude Code packaging; plugin/scripts/ mirrors bin/ + hooks/; plugin/skills/integration-guide/ new in v0.16.0.
schema/ capabilities, adapter-io, adr, doc, entity JSON schemas.
tests/ 21 stdlib-unittest suites; the executable spec (includes test_ia.py and, since v0.18.0, test_github_adapter.py).
docs/ Generated roadmap, frozen plans/status/ADRs/designs, the spec (v1.9).
docs/integrations/ Eleven per-system setup guides + index (v0.16.0), prose only, no code.

The one diagram (derived from actual imports and subprocess calls):

flowchart TD
    U["dev / agent / skill"] --> WL["bin/worklog"]
    WL -->|"append() — sole writer"| LOG[(".work/todo.jsonl<br/>.work/done.jsonl")]
    LOG --> FOLD["fold.py"]
    FOLD --> WL
    FOLD --> RR["render_roadmap.py"] --> VIZ["viz_mermaid.py"]
    RR --> MD["docs/roadmap.md"]
    WL -.->|"lazy import"| SD["sync_dispatch.py"]
    SD -->|subprocess| AD["adapters/*/adapter"] --> TR["tracker (gh CLI)"]
    SD -->|"subprocess: link / ingest / conflict"| WL
    WL -.->|"lazy import"| IA["ia.py"]
    IA --> IDX["docs/.index/_inventory.json<br/>sidecars"]
    WL -.-> IAR["ia_render.py"] --> REND["docs/.index/rendered/*"]
    WL -.-> IAG["ia_graph.py"] --> G["docs/.index/_graph.json"]
    CP["compact.py (CI nightly)"] -->|"verified rewrite"| LOG
    HK["hooks/pre-commit + CI"] -->|"gate, never write"| LOG
    HK -->|"WARN ia-normalize / inventory / render / trace"| IDX
Loading

2. Execution-order tour

2.1 The write path: worklog add → one line in the log

Entry: argparse dispatches to cmd_add (bin/worklog, build_parser tail + cmd_add body at lines 81–105). Validation happens before any write — --unplanned requires --discovered-during, and taxonomy rules are hard here even though the fold is lenient:

def check_taxonomy(level, kind, milestone):
    """Write-time rules, taxonomy spec §2. The fold is lenient; this is not."""
    if level == "epic":
        if kind in ("bug", "triage"):
            sys.exit(f"worklog: an epic cannot be kind:{kind} — epics are "
                     "feature or ops (taxonomy §2.2)")
        if milestone is not None:
            sys.exit("worklog: milestone lives on leaves; epic milestones are "
                     "derived (taxonomy §2.5)")

bin/worklog — check_taxonomy(), lines 70–79

Note the cmd_add comment: kind is only written when given — an omitted kind folds to triage (§2.3), never silently to feature. Unclassified must look unclassified.

Every event then funnels through the single writer:

def append(event):
    """The only writer. Single O_APPEND write, always newline-terminated. ..."""
    if len(event.get("set", {}).get("body", "")) > MAX_BODY:
        sys.exit(f"worklog: body exceeds {MAX_BODY}B; put prose in the plan doc")
    line = json.dumps(event, separators=(",", ":"), sort_keys=True) + "\n"
    fd = os.open(LOG, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
    try:
        if os.fstat(fd).st_size:
            rfd = os.open(LOG, os.O_RDONLY)
            try:
                os.lseek(rfd, -1, os.SEEK_END)
                if os.read(rfd, 1) != b"\n":
                    line = "\n" + line
            finally:
                os.close(rfd)
        os.write(fd, line.encode())   # atomic under PIPE_BUF
    finally:
        os.close(fd)
    return event

bin/worklog — append(), lines 35–58

What it receives: a finished event dict. What it returns: the event. What can fail: an oversized body exits before the write; everything else is one atomic write(). Why it is written this way: the self-heal (lseek -1; read 1) repairs a hand-edited file missing its trailing newline — without it, O_APPEND would fuse two events into one unparseable line and lose both (spec §8.2). MAX_BODY = 2048 (line 31) is "derived from PIPE_BUF … Not a setting." VERSION = "0.15.0" (line 32) is lockstepped with the plugin by tests/test_plugin.py.

2.2 The read path: fold() decides what the log means

Every read command (list, show, fold, roadmap, status, sync scope, IA plan lifecycle) calls fold([todo, done]). Four stages, each load-bearing:

Parse tolerantlyread_lines() (bin/fold.py): a bad line is reported into result.errors and skipped. The docstring explains why this is not politeness: union merge plus a missing newline "can fuse two valid lines into one invalid one, and that must cost two events, not the entire history."

Dedupe and sort deterministically:

    return sorted(
        seen.values(),
        key=lambda e: (
            e["ev"],
            e.get("actor", ""),
            hashlib.sha256(e["_line"].encode()).hexdigest(),
        ),
    )

bin/fold.py — dedupe_and_sort()

ULIDs sort lexicographically by time, so ordering is a string sort; the (actor, line-hash) tiebreak makes two machines fold the same bag of lines identically — the property union merge depends on.

Apply the watermarkapply_watermark() drops everything the compactor already folded, except snapshot events, which carry the state those events produced.

Replayfold(). The subtleties that bite naive implementations, each with a guarding test (§4): snapshot replaces state entirely (never merges); a duplicate create degrades to an update; close takes its status from set (only defaulting to done when nothing closed was set); conflict records without changing state; and a later write to a conflicted field clears the conflict while an earlier one does not, because events apply in ev order (_apply_mutations()).

Orphans: an event for an item with no create/snapshot creates {"id": iid, "_orphan": True} — "Report it; never crash, never silently invent an item." Legitimate mid-rebase. Compaction counts orphans as open so it never drops them (bin/compact.py partition comment).

2.3 Plan capture: one command, one epic, N tasks, one frozen doc

cmd_plan_capture() (bin/worklog, lines 339+). It parses the draft with plan_capture.parse_tasks() — checkboxes under a ## Tasks heading only, with the regex TASK_RE (bin/plan_capture.py) where a leading indent means "subtask of the task above". It refuses to recapture a slug that already has a plan (invariant 15.8), appends the epic create, then each task/subtask create, and finally writes the plan doc with front matter linking every item ID. Captured items get explicit kind:feature.

The guard is slug-scoped, not filename-scoped (v0.17.0, PR #198 + a same-day follow-up; bin/worklog — cmd_plan_capture(), lines ~344–367). The original code checked only os.path.exists(f"docs/plans/{date}-{slug}.md") with date from time.gmtime() (UTC) — so it enforced "plans are never rewritten" only when the UTC date happened to match the plan's actual filename. A plan captured in the evening from a timezone behind UTC looks for tomorrow's UTC-dated path, finds nothing, and silently writes a duplicate: observed in a downstream repo on 2026-07-26 (a byte-identical duplicate plan plus 23 duplicate work items, caught by hand and rolled back — nothing had warned). The fix globbed docs/plans/*-{slug}.md across every date instead of one fixed path. That fix's own bare glob then matched by raw string suffix, not field boundary — a search for slug migration also matched an existing 2026-07-01-database-migration.md (real slug database-migration), a false-positive refusal of a legitimately new, unrelated slug, caught in review before PR #198 merged. The shipped guard anchors on the fixed date shape:

slug_re = re.compile(r"^\d{4}-\d{2}-\d{2}-%s\.md$" % re.escape(a.slug))
existing = sorted(p for p in glob.glob("docs/plans/*.md")
                  if slug_re.match(os.path.basename(p)))

Two regression tests pin both failure modes (test_plan_capture_refuses_a_slug_already_captured_on_another_date — a plan dated 2020-01-01 must still be caught; test_plan_capture_does_not_false_positive_on_a_suffix_match — a longer slug sharing a suffix must not block a new, shorter one), tests/test_integration.py.

The whole flow is forced by hooks/exit-plan-capture.sh, which fires on PostToolUse: ExitPlanMode and injects a non-optional instruction that now also requires worklog ia-index after capture so the reader plane stays current.

2.4 The roadmap: a pure function, gated by diff

render_roadmap.render() folds the log and emits markdown. Two non-obvious choices: generated-at comes from the newest event's ULID timestamp, not the wall clock — "wall clock here would fail every commit" — because hooks/pre-commit regenerates and diffs. The default --viz deps,hierarchy in cmd_roadmap_render must match render()'s default because the hook runs the bare script and diffs against the file the CLI wrote. viz_mermaid.py caps nodes at MAX_NODES = 40 and strips Mermaid-breaking characters.

hooks/pre-merge-commit is a one-line exec of the same script, because "git runs THIS hook (not pre-commit) when a merge auto-commits."

2.5 Ticket sync: the dispatcher owns everything

worklog sync runs sync_dispatch.main() in-process (cmd_sync()). Order inside Dispatcher.sync(): capabilities gate, push, pull, save state, report.

The gate runs first, every run: adapter capabilities output is parsed, validated against the embedded schema mirror (CAPABILITIES_SCHEMA) by a 28-line mini JSON Schema validator, plus one check the schema subset cannot express — "{ulid}" must appear in caps["marker"]["template"] (sync_dispatch.py — capabilities()).

Push scope (push_items()): open ∪ hash-dirty ∪ key-dirty (v0.18.0) ∪ --keys. The canonical hash is computed over the outbound shape — after type degradation — so "the degraded echo coming back on pull still suppresses." A closing item whose hash is dirty pushes an update with the final item shape before the close verb (v0.12.1; TestCloseSyncsFields). On a successful create, external identity enters the log the only way it can — worklog link as a subprocess (invariant 15.4). Since v0.18.0 a collection-level collision gate runs before the loop and a contested key is skipped entirely — see §2.14.

Pull: NDJSON lines; echo suppression by comparing canonical_hash(line) to last_pushed_hash; remote-only becomes worklog ingest with deterministic ev = ulid.deterministic(system, key, rev, rev_ts); both-sides-changed records a conflict per field and never overwrites. Field-diff runs over INGEST_FIELDS (includes level/kind/milestone since v0.12.0). Labels on pull remain future work.

Failure handling is an exit-code table (handle_exit()): 2 aborts; 3 pops last_pushed_hash; 4 was already retried; 5 files per-field conflicts; anything else is drift. No adapter at all is a mode: LOCAL_ONLY, exit 0.

Adapters are dumb on purpose. adapters/github/adapter maps verbs to gh calls and embeds the marker; a test bans invariant tokens from every adapter source.

Rich bodies (v0.13.0): worklog ticket-body <ulid> prints a projection with summary, epic/plan/milestone context, and graph edges (ia_graph.ticket_body(), lines 166+) for the issue-description skill to push.

2.6 Compaction: the one rewrite, quadruple-checked

compact.compact() (bin/compact.py), nightly on main via .github/workflows/compact.yml. Sequence: refuse on uncommitted log changes; watermark = max raw ev; short-circuit if todo is already all snapshots; partition open vs closed where orphans count as open — "never drop data"; write temp files; then gate on fold(new) == fold(old) plus trailing newline plus every line parses. Only after that do two os.replace calls swap the files.

v0.13.0: snapshots write folded state verbatim so a closed orphan no longer fails verify by diverging from fold (item 01KY5HW7KS / #101).

2.7 Status: deterministic facts, model prose, frozen file

_status_facts() is the deterministic half: a fold plus a raw-event pass; an event is in-window when its ev ULID timestamp is. Daily windows open at the last daily report's date; weekly is a fixed 7 days; timecards bucket per UTC day and attach best-effort git commit subjects. The prose is the skill's job. cmd_status --write stamps front matter with the window and the through watermark and refuses to overwrite without --force (invariant 15.9).

2.8 The automation ring

Claude Code hooks (wired by plugin/hooks/hooks.json): prompt-reminder.sh injects a one-line policy on every prompt; stop-worklog-check.sh blocks ending a session where the tree changed but .work/todo.jsonl did not, with a settle-and-recheck sleep; session-doctor.sh reports missing policy blocks, unarmed hooks, or plugin version skew, read-only.

CI (worklog.yml) re-runs the pre-commit script verbatim (as WORKLOG_SKIP_BRANCH_GUARD=1 hooks/pre-commit since v0.15.0 — the checkout runs with no commit in flight, and the branch guard only makes sense for an actual git commit) — "A dev can --no-verify past the local hook; not this" — then unit, integration, and a subprocess-aware coverage gate (--fail-under=80). A new PR-scoped step (v0.15.0) walks git rev-list --no-merges base..HEAD through hooks/commit-msg for every non-merge commit on the PR, requiring fetch-depth: 0 on the checkout since commit-msg's own MERGE_HEAD check has nothing to read post-hoc in a CI clone. merge-when-green.sh polls gh pr checks and merges only on all-green; empty check output counts as pending, and 24 failed polls exit 4 (ADR-0003).

Pre-commit also runs IA gates at WARN level (plan ia-content-model, migration 0002) — soon hard fail:

if [ -f bin/ia.py ] && [ -x bin/worklog ]; then
  python3 bin/worklog ia-normalize --check >/dev/null 2>&1 || \
    echo "worklog: WARNING (soon a hard gate) — doc metadata drift; run: worklog ia-normalize" >&2
  python3 bin/worklog ia-inventory --check >/dev/null 2>&1 || \
    echo "worklog: WARNING (soon a hard gate) — inventory stale/invalid; run: worklog ia-inventory" >&2
  [ ! -f bin/ia_render.py ] || python3 bin/worklog ia-render --check >/dev/null 2>&1 || \
    echo "worklog: WARNING (soon a hard gate) — rendered pages/manifest stale; run: worklog ia-render" >&2
  # trace-check stays warn-level here forever; --strict runs at release time
  [ ! -f bin/ia_graph.py ] || python3 bin/worklog trace-check >/dev/null 2>&1 || \
    echo "worklog: WARNING — unlinked evidence; run: worklog trace-check" >&2
fi

hooks/pre-commit (IA block)

PYTHONDONTWRITEBYTECODE is set in the hook so it never dirties the worktree with __pycache__ (item 01KY5P9V0C).

2.9 IA plane: normalize → inventory → render → graph

Identity. Every doc gets a stable wiki_key. Legacy keys are seeded verbatim from .work/published.json so no URL or page name changes; new docs derive keys by rule (ia.derive_canonical_key(), ia.resolve_key()). CLI: worklog wiki-key <path>.

Normalize (ia.normalize(), cmd_ia_normalize lines 510–520):

def cmd_ia_normalize(a):
    import ia
    changes = ia.normalize(check=a.check)
    for c in changes:
        print(("needs: " if a.check else "wrote: ") + c)
    if a.check and changes:
        sys.exit(1)
    ...

bin/worklog — cmd_ia_normalize(), lines 510–520

Frozen docs get additive sidecars under docs/.index/<wiki_key>.yml; sanctioned-live docs get in-place identity fields only. truth_state is recomputed every run (DYNAMIC_FIELDS), never pinned from a stale sidecar.

Inventory (ia.build_inventory() / write_inventory()): pure function of committed files → docs/.index/_inventory.json (one record per doc).

Render (ia_render.write_all()): Home, Sidebar, decisions/releases/status indexes, truth banners, publish-manifest.json, aliases.json. Deterministic — no wall clock — so --check can regenerate-and-diff.

Convenience wrapper:

def cmd_ia_index(a):
    import ia, ia_render
    for c in ia.normalize():
        print("normalize: " + c)
    ia.write_inventory()
    print("inventory: " + ia.INVENTORY)
    for path in ia_render.write_all():
        print("wrote: " + path)

bin/worklog — cmd_ia_index(), lines 534–541

Graph (ia_graph.build_graph() / write_graph()): typed edges from frontmatter, plan items, ADR references, and item sidecars. link-pr is an overlay only — it does not append to the event log:

def link_pr(ulid_, pr=None, commit=None):
    ...

bin/ia_graph.py — link_pr(), lines 118+

trace_check(strict=False) lists closed items missing plan/ticket/PR links; --strict exits 1 at release. ia-graph --seed proposes decides/implements edges into gitignored .work/suggestions.jsonl (propose-only, never auto-edits docs).

Schema split. Document types live in schema/doc.schema.json; graph/execution entities (item today) live in schema/entity.schema.json. Both are mirrored in ia.DOC_TYPES / ENTITY_TYPES / REQUIRED_* constants; TestSchemaSync pins equivalence and asserts the two enums are disjoint so items never pretend to be documents (#111).

2.10 Artifact pages (v0.14.0): one page per item, PR, release

docs/plans/2026-07-24-artifact-pages.md. Two graph nodes existed since the Phase-4 traceability graph shipped — pr/<num> and release/<tag> stubs with no page of their own. This release gives every work item, PR, and release a generated wiki page, reusing graph edges instead of adding stored fields.

One shared traversal. ia_graph.build_adjacency(graph) builds the forward/backward edge maps once per render pass; item_links(iid, fwd, back) projects parent/children/PRs/release for one item from those maps:

def item_links(iid, fwd, back):
    key = item_key(iid)
    parent = next((to for typ, to in fwd.get(key, []) if typ == "belongs-to"),
                  None)
    children = sorted(to for typ, to in back.get(key, []) if typ == "contains")
    prs = sorted(to for typ, to in fwd.get(key, []) if typ == "lands-in")
    release = next((to for typ, to in fwd.get(key, [])
                    if typ == "targets" and to.startswith("release/")), None)
    return {"parent": parent, "children": children, "prs": prs,
            "release": release}

bin/ia_graph.py — item_links(), build_adjacency()

Every one of the three new renderers calls this — "not four near-duplicates" was a design decision in the plan (render_item_page() branches by level instead of shipping separate story/epic/task renderers).

Ticket pages (ia_render.render_item_page()): title, level/kind/status badge, a one-line summary derived at render time from the body's first sentence (one_line_summary() — never cached, keeping the module's byte-determinism), an upward ## Hierarchy walk to the root (_upward_chain(), cycle-safe via a seen set), a downward ## Subtasks / ## Children list with a done/total progress rollup, ## Linked PRs, and ## Release. worklog ia-ticket <ULID> (cmd_ia_ticket, bin/worklog) previews one page without a full render pass — builds the graph, calls build_adjacency(), and writes render_item_page()'s output to stdout.

Release pages (render_release_page()): the Change Log is milestone-tagged closed items plus their linked PRs, walked straight off the graph — not a CHANGELOG.md parser. CHANGELOG.md stays human-authored prose; the page's Change Log is a separate, mechanical, always-accurate list (the plan is explicit that building a changelog-parsing engine would be a fragile addition this project avoids). Also renders a ## Release Tree (_release_tree() — a lighter nested list than viz_mermaid.hierarchy(), which only covers open items, a different "what's left" use case), Related PRs, Related Tickets, and Dependencies & Risks.

PR pages (render_pr_page()): linked tickets via reverse lands-in edges, related releases/epics via item_links() on each linked ticket. Changed-files and CI/review status render literally as "not tracked" — no code in this repository calls gh pr view today, and the plan defers that integration to a separate follow-up item (worklog pr-sync, filed as #138, not built this release) rather than silently shipping a page that implies data exists.

Manifest growth. build_manifest() gained a second loop keyed off the tickets/, releases/, prs/ filename prefix in the rendered-pages dict — a new entity type needs one new prefix branch, not a new loop. The published-page manifest grew from 51 entries to 258.

2.11 Branch discipline (v0.15.0): pre-commit's branch guard + hooks/commit-msg

docs/plans/2026-07-25-branch-discipline-hooks.md. Shipped after a real incident: local main drifted 13 commits ahead of origin/main for hours because every commit landed straight on main, while GitHub's nightly compaction bot pushed its own commit directly to origin/main in parallel — the divergence surfaced as a failed PR merge. Two new checks follow the existing hook philosophy ("hooks enforce invariants, not hope").

Branch guard — a new block in hooks/pre-commit, inserted right after fail() is defined, before the .work/*.jsonl checks:

if [ -z "${WORKLOG_SKIP_BRANCH_GUARD:-}" ] && \
   [ -z "${WORKLOG_MERGE_COMMIT:-}" ] && \
   [ ! -f "$(git rev-parse --git-path MERGE_HEAD)" ]; then
  branch=$(git symbolic-ref --quiet --short HEAD || true)
  case "$branch" in
    main|master)
      fail "commits go on a branch, not '$branch' (main/master is pull-only). Run: git checkout -b <branch-name>, then commit there."
      ;;
  esac
fi

hooks/pre-commit (branch-guard block)

Detached HEAD is allowed (branch=""). Two independent exemptions cover "this is a merge, not authored work": WORKLOG_MERGE_COMMIT — set by hooks/pre-merge-commit before it execs into this script, because MERGE_HEAD is empirically not yet on disk at the point git invokes pre-merge-commit (it only appears between that hook running and commit-msg firing) — and MERGE_HEAD itself, present by the time a merge a hook rejected is resumed via a later plain git commit. A third variable, WORKLOG_SKIP_BRANCH_GUARD, is set only by non-commit callers running the script standalone with no commit in flight: plugin/scripts/doctor.sh's health check, the CI invariants step, and two tests/test_integration.py "CI gate passes" assertions — all of which would otherwise false-positive on main. hooks/pre-merge-commit itself needed no changes — it's a one-line exec of pre-commit, and MERGE_HEAD/ WORKLOG_MERGE_COMMIT already cover it.

hooks/commit-msg — new hook, requires every non-merge commit message to reference a worklog item or ticket:

[ -f "$(git rev-parse --git-path MERGE_HEAD)" ] && exit 0

python3 - "$1" <<'PY' || fail "commit message must reference a worklog item (26-char ULID) or a ticket (#123) -- see: worklog show <id>. Merge commits are exempt."
import re, sys
msg = open(sys.argv[1], encoding="utf-8").read()
sys.exit(0 if (re.search(r'\b[0-9A-HJKMNP-TV-Z]{26}\b', msg) or
               re.search(r'#\d+', msg)) else 1)
PY

hooks/commit-msg

The ULID pattern matches bin/ulid.py's Crockford base32 alphabet exactly. No exemption beyond merge commits (via MERGE_HEAD, same signal as the branch guard). Orthogonal check — applies on any branch, including feature branches.

Wiring. hooks/commit-msg is mirrored byte-identical to plugin/scripts/commit-msg; plugin/scripts/init.sh's hook-copy loop and CI workflow template both gained it; uninstall.sh removes it symmetrically; doctor.sh checks its existence/exec bit and now runs its own pre-commit invocation with WORKLOG_SKIP_BRANCH_GUARD=1; tests/test_plugin.py's CANON list includes it so TestCanonSync still guards the mirror. Both hooks hard-fail immediately — no warn-only rollout period, unlike the IA gates above.

Release skill. plugin/skills/release/SKILL.md §3's "direct-commit repos: commit on the default branch" mode is removed — dead once the branch guard ships everywhere; the skill now describes branch+PR landing only.

Test fixture fallout. ~23 commit_all() calls in tests/test_integration.py and 3 raw git commit calls in tests/test_plugin.py mostly committed on main with no-reference messages — pure fixture plumbing. Pre-existing baseline commits got no_verify=True; commits meant to represent real authored work moved onto a branch (Sandbox.branch()) and picked up an item-ULID in the message, following the file's existing precedent for "not what this test is about" commits.

2.12 Integration guides (v0.16.0): a prose-only edge, no new bin/ code

docs/plans/2026-07-25-wiki-driven-integration-guides.md. Eleven systems this repo talks about but mostly doesn't ship real adapter code for — four SDD tools it composes with (Superpowers, GSD, SpecKit, OpenSpec) and seven ticket/wiki systems where only adapters/github/adapter is real (Jira, Confluence, GitHub, GitLab, Azure DevOps, AWS CodeCatalyst, Google Cloud DevOps) — each get a dedicated setup guide. The design choice worth noting: this ships zero new Python. WebFetch (already available to any Claude Code agent) covers "fetch the live wiki page at runtime"; worklog wiki-add (pre-existing) covers "get an arbitrary file into the wiki-publish pipeline." The entire feature is one new skill plus markdown content.

plugin/skills/integration-guide/SKILL.md (mirrored to .claude/skills/integration-guide/SKILL.md) is pure prose, six numbered steps:

  1. Match the name to a canonical key via a fixed alias table in the skill body — no network call needed to resolve "ADO" or "Azure Boards" to azuredevops.
  2. Try the live wiki page first. Build the URL from wiki.root_url in .work/config.yml plus /Integration-<Name>; WebFetch it.
  3. Verify before trusting. A GitHub wiki does not 404 a missing page slug — it silently redirects to Home with a normal 200. The skill checks the response for a ## Recommended workflow heading before treating it as a hit; anything else (network error, 404, wrong-page redirect) is handled identically to a fetch failure.
  4. Fall back to the local copy, docs/integrations/fallback-<key>.md, saying so explicitly ("using the bundled local copy, which may lag the published page").
  5. Soft version-staleness check — only for systems with a real, probeable CLI (gh --version, glab --version, az --version, aws --version); the four SDD tools are Claude Code skills with nothing to introspect, and the skill says so rather than fabricating a check.
  6. Compose, don't reinvent — for Jira/Confluence, check for the global jira/confluence skill or an Atlassian MCP server before any raw REST/CLI call; that skill already owns auth, pagination, and markup conversion.

Content: docs/integrations/README.md (index) plus eleven fallback-<key>.md files, one fixed ten-section template each (When to use, One-command setup, Adapter configuration, Recommended workflow, Mapping events, Pulling changes, Rendering support, Example links, Gotchas & troubleshooting, Last updated) so the skill's lookup logic is uniform across all eleven. Two placements are load-bearing and appear in exactly one page each: the Jira/Confluence skill-reuse paragraph (§Recommended workflow, those two files only), and the Confluence diagram-to-image conversion note (§Rendering support, fallback-confluence.md only — Confluence storage format doesn't render Mermaid/PlantUML fences directly).

Publishing: bin/worklog wiki-add docs/integrations/fallback-<key>.md --key integrations/<key> --title "Integration-<Name>" registers the file in .work/published.json; the ordinary wiki-publish skill's existing hash-compare skip logic carries it from there — no new publish path was built or needed (wiki-publish/SKILL.md §4). Confirmed: git diff --stat v0.15.1..v0.16.0 touches no file under bin/, hooks/, or tests/ for this feature — only docs/integrations/*, the two SKILL.md mirrors, and the version/changelog/roadmap bookkeeping every release touches.

2.13 Item-id resolution (v0.17.1): one helper, six commands

Every command that names an existing item now goes through one lookup. Before v0.17.1, only reopen, resolve, and show folded the log to find the item; close, update, and link called _require_item() (a non-empty-string check, nothing more) and then wrote the event under whatever string the caller passed. Hand any of them the 8-character prefix that worklog show and worklog list themselves print, and the event landed under that short id — which folds into a brand-new phantom item, leaving the real one untouched:

def _resolve(item):
    """Resolve a full ULID or an unambiguous prefix to the real item
    (worklog 01KYA99TVC). ..."""
    _require_item(item)
    r = fold([LOG, ".work/done.jsonl"])
    match = [i for k, i in r.items.items() if k.startswith(item)]
    if not match:
        sys.exit(f"worklog: no item matching {item}")
    if len(match) > 1:
        ids = ", ".join(sorted(i["id"] for i in match))
        sys.exit(f"worklog: {item} is ambiguous — matches {ids}")
    return match[0]

bin/worklog — _resolve(), lines 115–130

What it receives: a full ULID or any prefix of one. What it returns: the folded item dict (so callers get item["id"], item["status"], item["_conflicts"] for free). What can fail: empty string, no match, or ambiguous — and that last case is new behavior, not just refactoring. reopen/resolve/show previously took match[0] when two ULIDs shared a prefix, an arbitrary pick; _resolve() exits naming both candidates instead.

Callers, all of which now write under item["id"] rather than the raw argument: cmd_update() (line 133), cmd_close() (156), cmd_reopen() (165), cmd_link() (176), cmd_resolve() (242), cmd_show() (298). Two of those were worse than a phantom item. cmd_update() used to do its current-state lookup as fold(...).items.get(a.item, {}) — for a prefix that returns {}, so check_taxonomy(cur.get("level"), ...) ran against level=None (an epic could be reclassified kind:bug, which taxonomy §2.2 forbids) and the cur.get("status") in CLOSED_STATUSES guard never fired (so update --status silently bypassed the "use reopen" refusal). cmd_link()'s failure mode was the loudest downstream: a link event under a prefix minted an orphan item carrying a real external key, which bin/sync_dispatch.py would then push to the tracker.

This is the shape the lazy fix takes: reopen already had the prefix match, so the fix was to lift those six lines into a shared helper and delete three copies, not to add a guard to each caller. git diff --numstat v0.17.0..v0.17.1 -- bin/worklog is +30/−27 lines, one add and one delete of which is the VERSION bump.

The regression suite is tests/test_resolve.py (new in v0.17.1, 143 lines, sandbox-subprocess style copied from test_taxonomy.py): each of close/update/link by prefix asserts len(items) == 1 with the message "prefix close minted a phantom item"; the two bypassed update guards get a test each; test_unknown_id_fails_loudly_and_writes_nothing() reads the log before and after and asserts byte equality; test_ambiguous_prefix_names_the_candidates() derives the shared prefix with os.path.commonprefix() on two real ULIDs.

The same release widened one unrelated check in three copies: hooks/session-doctor.sh (lines 15–18), plugin/hooks/scripts/session-doctor.sh (same lines, mirrored), and plugin/scripts/doctor.sh (63–68) compared git config core.hooksPath against the literal string hooks and failed anything else. worklog:init writes that relative form, but a git worktree resolves a relative hooksPath against the wrong CWD, so a worktree checkout needs the absolute path — and doctor called a correctly-wired repo broken. All three now accept either form, comparing cd "$hookspath" && pwd -P against $(pwd -P)/hooks, and the failure message quotes the offending value instead of asserting a single expected string.

2.14 One owner per remote ticket (v0.18.0): one predicate, three enforcement points

This is the release's centre of gravity, and the tour is worth walking in the order the failure actually happens.

The failure. Two local items were allowed to own the same external ticket key (ado:294). worklog sync pushed both; last writer won; a cancelled duplicate marked a live P0 stakeholder-gating ticket Done. Hand-repairing the ticket did not hold — the next sync rewrote the damage, twice. And it is invisible from the log: worklog fold shows two items, each with a perfectly valid external block.

Why it converges on the wrong value rather than flapping: .work/sync-state.json is keyed by ULID only, so the two owners get two independent, both-satisfiable last_pushed_hash slots and neither can see the other. The correctly linked item is hash-clean, so it is skipped forever and never repairs the ticket, while the wrong one keeps re-pushing.

The predicate — one helper, so the CLI and the dispatcher cannot grow divergent copies of the rule:

def external_owners(items):
    """(system, key) -> sorted ids of every item claiming that remote ticket."""
    owners = {}
    for i in items:
        ext = i.get("external") or {}
        if ext.get("key"):
            owners.setdefault((ext.get("system"), str(ext["key"])), []).append(i["id"])
    return {k: sorted(v) for k, v in owners.items()}

bin/fold.py — external_owners(), lines 98–124 (docstring elided)

Three details in six lines, each of which is a bug if you get it wrong. i.get("external") or {} rather than .get("external", {}): a merge or a hand edit can leave a literal null there, and the default never fires when the key exists. str(ext["key"]) because adapters return ints — 294 must not be a different ticket from "294". (system, key) and never bare key, because ado:294 and github:294 are unrelated tickets and a mid-migration repo legitimately holds both. It returns every owner rather than only the duplicates, because link needs "who else owns this" and sync needs "which keys have more than one" — one filter each, not two traversals.

Enforcement 1 — write time (bin/worklog — cmd_link(), lines 180–212). The command folds once and hands the fold down:

    r = fold([LOG, ".work/done.jsonl"])
    cur = _resolve(a.item, r)
    ...
    if not a.force:
        others = [i for i in external_owners(r.items.values())
                  .get((a.system, str(a.key)), []) if i != cur["id"]]

_resolve() grew an optional pre-computed fold this release (bin/worklog — _resolve(item, r=None), line 115) for exactly this caller: the documented bulk-migration workflow links hundreds of items in a loop, and folding the whole log twice per link is the difference between a fast migration and a slow one. The refusal names the other item and its title, then prints the two-command move (unlink then link), because "already linked" without an id is a message you cannot act on.

Two properties of the guard that look like oversights and are not. It is status-blind: a cancelled owner is among the most dangerous, since sync pushes a full update against its key and then closes the ticket — the exact sequence that marked the reported ticket Done. Guarding only open items would wave the same bug through with the two commands reordered. And it is self-excluding: re-linking an item to the key it already owns is normal (refreshing --url/--rev, re-running a partial migration, sync's own auto-link after a create), so cur["id"] is filtered out of others.

Enforcement 2 — push time (bin/sync_dispatch.py — push_items(), lines 363–372):

        self.collisions = {k: v for k, v in external_owners(items).items()
                           if len(v) > 1}
        if self.collisions:
            self.report_collisions(items)
        blocked = {i for ids in self.collisions.values() for i in ids}
        for item in items:
            ...
            if iid in blocked:
                continue
            closed = item.get("status") in CLOSED_STATUSES

The placement is the design. Inside the loop each item looks perfectly valid on its own — that is why #226 was invisible — so the check has to be collection-level, before the loop. And the continue sits before closed is computed, because the closed branch is separate code from the create/update path; a guard at the op = "update" if ext.get("key") discriminator would have missed the dirty-update-then-close path, which is the one that did the damage. Skipping the whole contested set (not just one side) is what removes the corruption: corruption needs both claimants pushed. Everything else in the run still syncs. sync() then returns 1 (lines 583–585) so CI cannot pass over it, and report_collisions() (lines 338–361) prints its own stderr block rather than a drift: line — drift is what operators skim, and burying a live-corruption warning there would reproduce the original silent-failure mode in a new costume.

Enforcement 3 — the repair (bin/worklog — cmd_unlink(), lines 214–239). There was no worklog unlink, which is a sharp edge in a log whose whole premise is that mistakes are corrected by appending. It needs no new fold op:

    ev = base(cur["id"], "link", a.actor)
    ev["set"] = {"external": {}}

link already falls through to _apply_mutations(), which does whole-field last-writer-wins on external — so an empty external is the retraction, and a clone running an older fold.py applies it correctly too. {} and never null, for the same reason external_owners() uses or {}: cmd_list's reader was i.get("external", {}).get("key", "-"), and a null would have raised AttributeError on every worklog list in the repo. That reader was hardened in the same commit (cmd_list(), line 346). The command also warns on stderr that trackers which merge rather than overwrite (ADO tags) may still carry the worklog:<ULID> marker, so a later pull could still attribute a remote change to this item — the log is only half the state.

The part that is easy to miss. external is not in HASH_FIELDS (bin/canonical.py:17), so unlinking or re-pointing an item never made it content-dirty — worklog unlink would have been a silent no-op at sync time and the damaged ticket would have stayed wrong. Hence:

    def is_dirty(self, iid, h, ext):
        st = self.state.get("items", {}).get(iid, {})
        if h != st.get("last_pushed_hash"):
            return True
        prev = st.get("last_pushed_key")
        now = str(ext["key"]) if ext.get("key") else None
        return prev is not None and prev != now

bin/sync_dispatch.py — is_dirty(), lines 171–186

The prev is not None guard is what keeps an upgrade from re-pushing every item in every existing clone at once, since no clone has ever written last_pushed_key. record_push() (lines 188–190) writes both fields together. The same asymmetry shows up in report_collisions()'s printed repair, which ends with worklog sync --keys <key> — unlinking the impostor does not make the survivor dirty, so the damaged ticket stays wrong until it is forced back into scope.

And the trap the obvious fix would have set (record_link(), lines 213–231). Auto-link after a create used to be fatal=True. A guard there aborts sync between "remote ticket created" and "link recorded"; on the next run the item has no external.key, so the create-vs-update discriminator says create and files a second live ticket. So the dispatcher's own link passes --force (the one-owner rule cannot apply to a key the remote just handed us) and fatal=False, and a failure becomes a drift note naming the manual repair.

2.15 The GitHub adapter's create (v0.18.0): the same rule, one layer down

gh issue create prints only the new URL, so the rev the push contract requires came from a second call, gh issue view … --json updatedAt. That read happens after the issue exists. A rate limit there exits 4 — "transient, retry me" — the dispatcher retries the whole push with op still create, and each retry files another issue. One transient failure, up to four live duplicates (github#235).

    args = ["api", f"repos/{repo}/issues",
            "-f", f"title={title}", "-f", f"body={body}"]
    for lab in labels:
        args += ["-f", f"labels[]={lab}"]
    issue = json.loads(gh(args))
    return str(issue["number"]), issue["html_url"], issue["updated_at"]

adapters/github/adapter — create_issue(), lines 91–111

The REST endpoint returns number, html_url and updated_at together, so there is no window left to fail in. cmd_push() then emits rev or issue_rev(repo, key) (line 248): the update path deliberately keeps its second read, because re-editing the same issue is idempotent — a retry there costs a call, not a duplicate.

Stated as one rule, this and §2.14 are the same rule: nothing may fail or diverge between mutating shared remote state and recording what was mutated. Failing in that window duplicates the mutation; recording it in two places corrupts it.

3. Load-bearing invariants

# Invariant Enforced at Broken means
1 Every .jsonl write ends in \n append() self-heal; hooks/pre-commit; CI next append fuses two events into one corrupt line; both lost
2 Only worklog writes the log; only compact.py rewrites it policy + CLAUDE.md; sync_dispatch shells into worklog hand edits corrupt merges; invariants unauditable
3 Fold order is ev, never file position or ts dedupe_and_sort() union-merged logs fold differently per machine
4 Ingested events carry deterministic ev and the remote's ts ulid.deterministic(); cmd_ingest() duplicate ingests silently revert local edits
5 Push idempotency: marker worklog:<ulid> + canonical-hash skip push_items(); marker template gate retried pushes file duplicate tickets
6 Canonical hash = exactly HASH_FIELDS, one implementation canonical.py ("nothing else may reimplement it") echo suppression breaks for every existing clone
7 Compaction only lands if fold(new) == fold(old) _verify(); temp files + os.replace state loss — "the worst failure mode in this system"
8 close reads status from set fold() cancelled work reports as shipped
9 Generated roadmap always matches the log pre-commit + pre-merge-commit diff; deterministic timestamps roadmap silently lies; hand edits stick
10 Frozen artifacts are never rewritten plan-capture/roadmap-snapshot/status existence refusals; ADR mark_superseded(); IA sidecars for frozen docs history that people acted on gets rewritten
11 Adapters contain no invariant logic test_adapter_contract.py banned-token scan invariants fork per platform and drift
12 Epics are feature/ops only; milestone lives on leaves check_taxonomy(); pre-commit taxonomy scan; fold stays lenient taxonomy queries give wrong answers
13 Merges happen only on all-green gates merge-when-green.sh broken main, agent-speed
14 IA index artifacts are pure functions of committed files no wall clock in inventory/render/graph writers; freshness --check regenerate-and-diff gates become flaky
15 Doc types and entity types are disjoint TestSchemaSync.test_doc_and_entity_types_are_disjoint inventory/graph validation confuses items with pages
16 Artifact-page hierarchy/PR/release links are derived at render time, never stored on the item or a sidecar ia_graph.item_links() reads graph["edges"] only a cached copy would drift from the graph, the exact second-source-of-truth problem sidecars were built to avoid
17 Every commit on main/master is a merge (MERGE_HEAD/WORKLOG_MERGE_COMMIT), never authored directly (v0.15.0) hooks/pre-commit branch guard local main and origin/main diverge silently until a failed merge surfaces it — the actual incident this hook exists for
18 Every non-merge commit message references a worklog item ULID or a ticket number (v0.15.0) hooks/commit-msg work is untraceable to a plan or ticket after the fact
19 A given (external.system, external.key) belongs to at most one item (v0.18.0) fold.external_owners() behind cmd_link()'s refusal and push_items()'s pre-loop skip; docs/worklog-spec.md:272 every sync overwrites the remote ticket with whichever item changed last, forever — the correct owner is hash-clean and never repairs it (github#226)
20 Nothing may fail or diverge between mutating remote state and recording it (v0.18.0) adapters/github/adapter — create_issue() (one call returns key+url+rev); Dispatcher.record_link() (--force, fatal=False) a retryable failure in that window re-runs the mutation: duplicate live tickets, one per retry (github#235)
21 Sync scope must notice a changed link, not just changed content (v0.18.0) Dispatcher.is_dirty() comparing last_pushed_key; record_push() writing it external is not in HASH_FIELDS, so unlink and re-link are silent no-ops at sync time and a damaged ticket is never repaired

4. Tests as executable specification

tests/test_fold.py — test_cancelled_stays_cancelled(). Rule proved: close takes status from set. Regression caught: a fold that hardcodes done — abandoned work reporting as shipped.

tests/test_ulid.py — TestTheBugThisPrevents. Two devs poll the same remote change; with deterministic ev, dedupe collapses them. The companion test passes while documenting the failure mode with random evs — Rick's edit is gone, nothing errors. Exists "because this design keeps getting proposed."

tests/test_dispatch.py — test_push_twice_same_ulid_is_one_ticket(). Rule proved: canonical-hash skip + marker idempotency. Sibling test_retry_after_transient_does_not_duplicate injects exit-4 with _fail_next.

tests/test_adapter_contract.py — test_adapters_contain_no_invariant_logic(). Scans every adapters/*/adapter for banned tokens. Automatically covers new adapters the day they appear.

tests/test_integration.py — test_a_fused_line_costs_exactly_its_own_events(). Corruption is contained and detected at the merge boundary.

tests/test_compact.py — test_reopen_after_compact_restores_pre_close_fields(). Folding todo + done by ev makes reopen work across the physical file split.

tests/test_dispatch.py — test_pull_ingests_remote_taxonomy_change() (v0.12.0). Remote taxonomy edits pull instead of silently dropping.

tests/test_dispatch.py — TestCloseSyncsFields (v0.12.1). Reclassify then close; local kind survives the round-trip; pull is an echo, not a remote edit (worklog 01KY129S, GitHub #76).

tests/test_ingest.py — TestReopen (v0.12.0). reopen clears resolution; update --status on closed is refused; reopen of open is refused.

tests/test_resolve.py — ResolveTest (v0.17.1). Rule proved: an id prefix names the existing item, never a new one. Regression caught: close/update/ link writing events under the raw caller string, so the short id the CLI itself prints minted a phantom orphan — and, for update, made the taxonomy and closed-item guards run against an empty dict. Each prefix test asserts len(items) == 1, which is what a phantom breaks; test_ambiguous_prefix_names_the_candidates() pins the new refusal where the old code silently took match[0] (worklog 01KYA99TVC).

tests/test_dispatch.py — TestOneOwnerPerKey (v0.18.0). Six cases against the fake adapter, and the fixture is as instructive as the assertions. Item A files a real ticket; item B is added after the sync and pointed at A's key — "that is the reported shape: a plan-capture phantom that someone 'fixes' by linking it to the ticket it appears to duplicate." The duplicate is manufactured through link --force, not a hand-written JSONL line, and the docstring says why: the fold orders by ev, so a synthetic high ev sorts after a real later unlink and silently swallows it. --force is also what a union merge of two branches that each linked the same key looks like. The cases prove: the contested ticket is never pushed and keeps its original title; a cancelled claimant does not close it (the separate closed branch — the exact #226 damage); healthy items in the same run still push; --dry-run also exits 1, since "0 creates on a dry run" is the documented migration acceptance gate; after unlink the survivor re-pushes and the run exits 0 again; an unlinked open item re-enters scope with no field edits at all (the direct proof that last_pushed_key is load-bearing); and auto-link after a create is never blocked, even with a squatter already holding the key.

tests/test_link.py — TestOneOwnerPerKey / TestUnlink (v0.18.0). The CLI-side rules: refuses a key another item owns; refuses it even when that owner is closed; allows re-linking the same item to the same key; allows the same key on a different system; --force bypasses. Unlink clears external and worklog list still works (the null-safety regression), frees the key for another item, warns about the leftover marker, and exits non-zero with nothing to unlink.

tests/test_fold.py — TestExternalOwners (v0.18.0). Pins the predicate's edge cases directly: 294 (int) and "294" (str) are the same ticket, github:294 is a different one, and {} / missing / None externals are absent from the index entirely. test_ids_are_sorted_so_the_newest_link_is_last() exists because the collision report points at ids[-1] as "usually the mistake" — ULID order is creation order, so that has to hold.

tests/test_github_adapter.py — TestCreateIsASingleCall (v0.18.0). The first suite to exercise a real adapter end to end, via a stub gh on PATH that appends every argv to a file and replays canned responses. The point is stated in the module docstring: "worklog #235 was not a wrong output, it was a call that happened at the wrong moment." test_create_reads_no_issue_afterwards() asserts on the call log, and test_a_rate_limited_read_cannot_duplicate_the_issue() asserts the mutation happened exactly once. TestUpdateStillReadsTheRev pins the deliberate asymmetry — update keeps its read-back and a retry is idempotent — so a future "cleanup" cannot delete it as dead symmetry.

tests/test_ia.py — TestSchemaSync (v0.13.0).

    def test_doc_schema_json_matches_ia_constants(self):
        ...
        self.assertEqual(schema["required"], list(ia.REQUIRED_ALL))
        self.assertEqual(props["doc_type"]["enum"], list(ia.DOC_TYPES))
        ...
    def test_doc_and_entity_types_are_disjoint(self):
        self.assertEqual(set(ia.DOC_TYPES) & set(ia.ENTITY_TYPES), set())

Rule proved: embedded IA constants cannot silently diverge from schema/doc.schema.json / entity.schema.json before Phase 5 hard-fail.

tests/test_ia.py — TestNormalize.test_normalize_backfills_then_noop. First run writes sidecars/frontmatter; second run is a no-op. Rule proved: normalize is idempotent and additive.

tests/test_ia.py — TestGraph.test_link_pr_is_overlay_only. link-pr mutates the item sidecar, not the event log. Rule proved: PR edges do not violate invariant 15.4.

tests/test_ia.py — TestGraph.test_trace_check_warn_and_strict. Default is non-zero gaps without process failure; --strict exits 1.

tests/test_ia.py — TestGraph.test_seed_edges_propose_only_and_deduped. Seed writes suggestions only; never edits docs; dedupes re-proposals.

tests/test_ia.py — TestArtifactPages.test_ticket_page_hierarchy_and_progress (v0.14.0). Builds a real epic→task→subtask chain, closes the subtask, and asserts the epic page's ## Children section, the task page's ## Hierarchy and ## Subtasks sections, and the Progress: 1/1 done rollup — the strongest proof that item_links() + _upward_chain() compose correctly across three levels.

tests/test_ia.py — TestArtifactPages.test_release_page_change_log_is_graph_derived. Closes an item tagged with a milestone, renders, and asserts the release page's Change Log contains the item's title — proves the Change Log is graph-derived, not a CHANGELOG.md parse.

tests/test_ia.py — TestArtifactPages.test_manifest_grows_with_items_releases_prs. Closes an item, links a PR, renders, and asserts item/, release/, and pr/ wiki_keys all appear in the manifest with the right cardinality — proves build_manifest()'s new prefix-keyed loop actually fires for every entity type, not just documents.

tests/test_integration.py — TestBranchGuard (v0.15.0). Three cases: committing on main with no branch is rejected with a "pull-only" message; committing on a feature branch succeeds; merging a feature branch onto main — the exact incident scenario — is allowed even though it lands a commit directly on main. Rule proved: the guard blocks authored commits on main, not merges.

tests/test_integration.py — TestCommitMsgReference (v0.15.0). A commit message with neither a ULID nor a #123 reference is rejected; one with either passes; a merge commit's message is exempt regardless of content.

tests/test_integration.py — TestPlanCapturePR.test_plan_capture_refuses_a_slug_already_captured_on_another_date (v0.17.0). Seeds docs/plans/2020-01-01-demo.md, then captures a fresh draft with slug demo; asserts the refusal names the 2020 file and cites invariant 15.8, and that nothing gets written under today's date either. Rule proved: invariant 15.8 is slug-scoped, not filename-scoped — the exact bug that produced a real duplicate-plan incident in a downstream repo (§2.3, §6).

tests/test_integration.py — TestPlanCapturePR.test_plan_capture_does_not_false_positive_on_a_suffix_match (v0.17.0). Seeds docs/plans/2026-07-01-database-migration.md, then captures a new draft with the unrelated, shorter slug migration; asserts the capture succeeds. Rule proved: the guard's regex is anchored on the YYYY-MM-DD- date field, so a bare suffix match (-migration.md) on a longer, different slug does not false-positive-refuse a legitimately new one — the correctness bug the first fix's naive glob introduced, caught before PR #198 merged.

5. Junior engineer orientation

Five things to internalize:

  1. State is derived, never stored. If worklog list looks wrong, the question is "what events exist?" (worklog fold, or read the JSONL), never "where is the state file?"
  2. ev order is the only order. File position and ts are noise.
  3. There is exactly one writer (append()), one meaning-maker (fold()), one rewriter (compact.py), one hash (canonical.py). Adding a second of any of these is the design failure the tests hunt.
  4. Generated vs frozen: docs/roadmap.md and docs/.index/* are regenerated and diffed; plans, snapshots, status reports, and ADR bodies are written once (IA metadata for frozen docs lives in sidecars, not in the body).
  5. The dispatcher enforces; adapters translate; skills orchestrate; the IA plane navigates.
  6. (v0.18.0, the one worth adding) The log is not the only state. Anything that mutates a remote record must leave no window in which the mutation happened and the log does not know, and no way for two items to claim one remote record. Both failure modes look fine from worklog fold.

Where to start debugging: python3 bin/fold.py prints derived state with warnings for corrupt lines and orphans. worklog sync --dry-run prints decisions without side effects. worklog adapter check validates a contract. bash hooks/pre-commit runs every local gate manually. worklog ia-index and worklog trace-check diagnose reader-plane / evidence gaps.

Where common changes go: new CLI behavior → bin/worklog (subcommand + a test suite); roadmap presentation → render_roadmap.py/viz_mermaid.py (keep byte-determinism — no wall clocks); a new tracker → copy adapters/github/adapter, keep it dumb, then worklog adapter check; policy → CLAUDE.md prose backed by a hook if it must always hold; doc identity / navigation → ia.py / ia_render.py / ia_graph.py + test_ia.py; a new artifact-page entity type (v0.14.0 pattern) → add a render_<x>_page() in ia_render.py that consumes ia_graph.item_links(), wire it into render_all()'s loop, and add one prefix branch to build_manifest(); a twelfth integration system (v0.16.0 pattern) → one row in integration-guide/SKILL.md's alias table, one docs/integrations/fallback-<key>.md, one worklog wiki-add call — no bin/ code.

Risky files: bin/canonical.py (any change churns every clone's hashes — the file says "Don't." — and note that external is deliberately not in HASH_FIELDS, which is why sync_dispatch.is_dirty() has to track last_pushed_key separately); bin/fold.py (every command's notion of truth); bin/compact.py (the only code that can lose state); append() in bin/worklog (the atomicity/newline dance); sync_dispatch.CAPABILITIES_SCHEMA and ia.REQUIRED_* / DOC_TYPES (must stay identical to schema/* — tests diff them).

Never break: invariants table in §3 — especially trailing newline, ev-ordering, deterministic ingest, marker idempotency, fold-equality in compaction, and frozen-doc immutability (use sidecars).

6. Gaps and design drift

Confirmed facts unless labeled otherwise.

Shipped in v0.18.0 (were open at v0.17.1):

  • One local owner per remote ticket (github#226): fold.external_owners() behind a status-blind refusal in worklog link (--force overrides), a pre-loop skip plus exit 1 in worklog sync, and the new worklog unlink retraction. is_dirty() now tracks last_pushed_key so a retraction is actually visible to sync, and record_link() can no longer abort a run mid-create. See §2.14.
  • GitHub create is one call (github#235): create_issue() posts to repos/{repo}/issues and returns number + url + revision together, closing the window in which a retried push filed duplicate issues. See §2.15.
  • worklog list is null-safe on external — a merge or hand edit can already produce a literal null there.
  • README repo-layout drift closed: the plugin/ row now reads v0.18.0, matching VERSION in bin/worklog. This is the first release since v0.13.0 where that row is not stale.

New in v0.18.0 (found while fixing #226, filed, none started):

  1. Duplicate ownership is not caught at merge time (#237). The guards run at link and at sync; a git union merge of two branches that each linked the same key still lands both events, and nothing notices until the next sync. That sync now contains the damage rather than pushing it, so the residual exposure is a delayed loud failure, not a silent one — but the merge-time check named in the plan's "out of scope" list is genuinely not built.
  2. --keys can match an ambiguous external key (#239) — no system disambiguation, no owner restriction. Not a corruption path today because the collision gate runs first and blocks the whole set.
  3. Sync does not name the ticket fields it is about to overwrite (#238) — the reporter's suggestion 4.
  4. Plan capture does not warn when a task title references a ticket number (#242) — the reporter's suggestion 5, and the shape of the phantom that started #226.
  5. conflict/resolve accept an arbitrary field name (#240).
  6. Adapter exit 3 does not clear the external link (#241), so a ticket that no longer exists remotely retries forever.
  7. Compaction is silently undone by a branch that spans it (#243) — the fold stays correct, the size win is lost.
  8. Two sessions in one working directory corrupt each other's work (#236) — the reporter's contributing factor, a process problem before it is a code one.

Explicitly not doing (from the plan's own out-of-scope list, so a future reader does not read these as oversights): a repair or merge command, shared-ownership support, a new fold op, a sync-state schema migration.

Shipped in v0.17.1 (were open at v0.17.0):

  • ID-prefix resolution on close/update/link (#123, open since v0.14.0): the shared _resolve() helper now backs all six commands that name an existing item, so a prefix resolves to the real item instead of minting a phantom orphan, and update's taxonomy / closed-item guards run against real state. Ambiguous prefixes are refused by name rather than resolved to match[0]. New tests/test_resolve.py. See §2.13.
  • core.hooksPath check accepts an absolute path to this repo's hooks/ in hooks/session-doctor.sh, plugin/hooks/scripts/session-doctor.sh, and plugin/scripts/doctor.sh — the form a git worktree needs, previously reported as a broken install. See §2.13.

New in v0.17.1 (found while fixing prefix resolution, not yet fixed — filed): none found and filed this release.

Still open from v0.14.0: banner() mislabels frozen "current"-titled docs as status reports (#137) — untouched by this release.

Shipped in v0.17.0 (were open at v0.16.1):

  • bin/worklog plan-capture invariant-15.8 guard fixed twice in the same release cycle (PR #198 + same-day follow-up): first from filename-scoped (missed a UTC/local date-boundary duplicate) to slug-scoped via a bare *-{slug}.md glob, then from that glob (a suffix match, not a field-boundary match — false-refused an unrelated longer slug) to a YYYY-MM-DD--anchored regex. See §2.3.
  • Generated wiki Home gained [[Code-Walkthrough]] next to [[Design-Doc]] (ia_render.render_home()) — a walkthrough page was previously unreachable from Home once published.
  • docs/graph-engineering.md added: documentation only, no code path, no gap surface of its own.

New in v0.17.0 (found while fixing the plan-capture guard, not yet fixed — filed): none found this release — both defects found were caught and fixed within the same PR cycle before merge (see §2.3), not left open.

Shipped in v0.16.1 (were open at v0.16.0):

  • bin/sync_dispatch.py closed-item sync path fixed: forcing a never-pushed closed item into scope via --keys raised KeyError: 'key'; it now creates-then-links-then-closes, mirroring the open-item branch.

New in v0.16.1 (found while shipping the sync_dispatch fix, not yet fixed — filed): none found and filed.

Shipped in v0.16.0 (were open at v0.15.1):

  • Wiki-driven integration guides: integration-guide skill + 11 docs/integrations/fallback-*.md pages + index. Zero new bin/ code — see §2.12.

New in v0.16.0 (found while shipping integration guides, not yet fixed — filed): none found this release — the feature is content plus one prose-only skill with no code path to regress.

README repo-layout drift: closed (Confirmed). README.md's plugin/ row reads "v0.18.0" and matches VERSION = "0.18.0" in bin/worklog at this commit — the recurring cosmetic gap reported at v0.14.0 through v0.17.1 is gone. Note the underlying cause is still unautomated: tests/test_plugin.py locks plugin/.claude-plugin/plugin.json to VERSION but not the README prose, which is filed as #223.

Closed in prior releases and still closed at v0.15.0: dispatcher INGEST_FIELDS carries taxonomy; worklog reopen exists; conflict_policy is report only; dirty-close pushes final shape before close; TestResolve exercises the resolve CLI.

Shipped in v0.15.0 (were gaps or plans at v0.14.0):

  • Branch discipline: hooks/pre-commit branch guard (refuses authored commits on main/master) and new hooks/commit-msg (requires a ULID or ticket reference), both hard-fail immediately with no warn period. Wired into init.sh/uninstall.sh/doctor.sh/tests/test_plugin.py CANON and a new CI PR-scoped commit-message check.
  • plugin/skills/release/SKILL.md's dead "direct-commit repos" mode removed.

New in v0.15.0 (found while shipping branch discipline, not yet fixed — filed):

None found and filed this release — the plan's own verification checklist (full suite green, TestCanonSync, manual incident-scenario replay, worklog doctor still healthy on main) was walked without surfacing new drift.

Shipped in v0.14.0 (were gaps or plans at v0.13.0):

  • Artifact pages: ticket/release/PR pages generated from existing graph edges (render_item_page(), render_release_page(), render_pr_page()), ia_graph.build_adjacency()/item_links(), worklog ia-ticket preview.
  • build_manifest() grows a second, prefix-keyed loop for items/releases/PRs; published-page manifest 51 → 258 entries.

New in v0.14.0 (found while building artifact pages, not yet fixed — filed):

  1. close/update don't resolve item-id prefixes (#123). reopen does prefix matching on the item id; close and update do not — a short but valid prefix silently creates a new orphan item rather than resolving to the intended one. Confirmed as filed drift, not fixed in this release.
  2. banner() mislabels frozen "current"-titled docs as status reports (#137), regardless of actual doc_type. Verified live on 12 of 14 published plan pages. Confirmed as filed drift, not fixed in this release.
  3. README.md repo-layout table says "v0.13.0" on the plugin/ row — one release stale (bin/worklog VERSION is "0.14.0", locked to plugin/.claude-plugin/plugin.json by tests/test_plugin.py, but README prose isn't part of that lockstep test). Cosmetic; not filed as a ticket, noted here as doc drift.
  4. docs/user_guide/cli-reference.md's new "Information architecture (IA) commands" section documents wiki-key, ia-normalize, ia-inventory, ia-render/ia-manifest, ia-index, ia-graph, link-pr, ticket-body, trace-check — but not the new ia-ticket subcommand. Confirmed by grep; the CLI reference has not caught up with bin/worklog for this one command.
  5. worklog sync --pull cannot bootstrap a cursor-less pull (item 01KYAGZ8, filed but not yet worked): the adapter's pull verb requires --since, which a first-ever pull has none of. Not exercised by this walkthrough's citations; flagged from the roadmap snapshot for release readers.

Still open / drift, carried from v0.13.0 and earlier:

  1. Spec §10.5 sync surface ≠ shipped CLI. Spec documents --scope active|all, --report, --apply; CLI ships --dry-run, --keys, --push-only, --pull-only. Doc drift, not a bug.
  2. .work/config.yml comments still say "no adapter binary" under ticketing/wiki blocks while adapters/ and the dispatcher ship. Harmless (skill path still works) but a 1.4-era story for config-only readers.
  3. Spec §11's three-phase orchestration (changeset.json, results/) is not in code. Shipped dispatcher is single-process push/pull. Assumption: still aspirational for parallel-subagent sync.
  4. estimate and related optional fields (spec §5.4 / #108) have no CLI surface yet — configurable field model is open work.
  5. Labels don't pull — marked future work in pull().
  6. Remote-origin tickets are reported, never created locally — deliberate read-safety.
  7. Duplicated mini-validator (dispatcher, adr.py, contract tests) and duplicated IA schema constants (ia.py vs schema/*.json) — deliberate "bin-only install"; pinned by tests; fourth/diverge copies should extract or fail CI.
  8. IA gates warn-only until Phase 5 (#98); trace-check stays warn at commit forever (strict at release). Residual risk: ignored warnings allow metadata drift to merge.
  9. Phase 5 / platform render adapters / /worklog:find + glossary not shipped.
  10. UI work was moved to wiki_ticket_sdd_ui and cancelled here — do not look for UI code in this repo.
  11. Live PR metadata (files changed, review/CI status) deliberately deferred to a filed follow-up, worklog pr-sync (#138) — no gh pr view call exists anywhere in this repo; PR pages render "not tracked" by design, not by oversight.

Final check against the code: every flow above was walked at commit 4d0a0fe79b232644434d7c1b90d6dd9436442974 (tag v0.18.0 on main); all citations are to that tree. Dated freeze pairs for this release pin git_hash to this same commit.

Clone this wiki locally