-
Notifications
You must be signed in to change notification settings - Fork 0
Code Walkthrough
Current — this is the living version; regenerated at 2026-07-29T00:00:00Z. Historical snapshots are linked from Index-Releases.
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).
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
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.
Every read command (list, show, fold, roadmap, status, sync scope, IA plan
lifecycle) calls fold([todo, done]). Four stages, each load-bearing:
Parse tolerantly — read_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 watermark — apply_watermark() drops everything the compactor
already folded, except snapshot events, which carry the state those events
produced.
Replay — fold(). 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).
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.
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."
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.
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).
_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).
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).
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).
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.
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.
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:
-
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. -
Try the live wiki page first. Build the URL from
wiki.root_urlin.work/config.ymlplus/Integration-<Name>;WebFetchit. -
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 workflowheading before treating it as a hit; anything else (network error, 404, wrong-page redirect) is handled identically to a fetch failure. -
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"). -
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. -
Compose, don't reinvent — for Jira/Confluence, check for the global
jira/confluenceskill 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.
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.
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_STATUSESThe 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.
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.
| # | 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 |
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.
Five things to internalize:
- State is derived, never stored. If
worklog listlooks wrong, the question is "what events exist?" (worklog fold, or read the JSONL), never "where is the state file?" -
evorder is the only order. File position andtsare noise. - 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. - Generated vs frozen:
docs/roadmap.mdanddocs/.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). - The dispatcher enforces; adapters translate; skills orchestrate; the IA plane navigates.
- (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).
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 inworklog link(--forceoverrides), a pre-loop skip plus exit 1 inworklog sync, and the newworklog unlinkretraction.is_dirty()now trackslast_pushed_keyso a retraction is actually visible to sync, andrecord_link()can no longer abort a run mid-create. See §2.14. -
GitHub create is one call (github#235):
create_issue()posts torepos/{repo}/issuesand returns number + url + revision together, closing the window in which a retried push filed duplicate issues. See §2.15. -
worklog listis null-safe onexternal— 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, matchingVERSIONinbin/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):
-
Duplicate ownership is not caught at merge time (#237). The guards run at
linkand atsync; 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. -
--keyscan 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. - Sync does not name the ticket fields it is about to overwrite (#238) — the reporter's suggestion 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.
-
conflict/resolveaccept an arbitrary field name (#240). - Adapter exit 3 does not clear the external link (#241), so a ticket that no longer exists remotely retries forever.
- Compaction is silently undone by a branch that spans it (#243) — the fold stays correct, the size win is lost.
- 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, andupdate's taxonomy / closed-item guards run against real state. Ambiguous prefixes are refused by name rather than resolved tomatch[0]. Newtests/test_resolve.py. See §2.13. -
core.hooksPathcheck accepts an absolute path to this repo'shooks/inhooks/session-doctor.sh,plugin/hooks/scripts/session-doctor.sh, andplugin/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-captureinvariant-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}.mdglob, then from that glob (a suffix match, not a field-boundary match — false-refused an unrelated longer slug) to aYYYY-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.mdadded: 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.pyclosed-item sync path fixed: forcing a never-pushed closed item into scope via--keysraisedKeyError: '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-guideskill + 11docs/integrations/fallback-*.mdpages + index. Zero newbin/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-commitbranch guard (refuses authored commits onmain/master) and newhooks/commit-msg(requires a ULID or ticket reference), both hard-fail immediately with no warn period. Wired intoinit.sh/uninstall.sh/doctor.sh/tests/test_plugin.pyCANON 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-ticketpreview. -
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):
-
close/updatedon't resolve item-id prefixes (#123).reopendoes prefix matching on the item id;closeandupdatedo 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. -
banner()mislabels frozen "current"-titled docs as status reports (#137), regardless of actualdoc_type. Verified live on 12 of 14 published plan pages. Confirmed as filed drift, not fixed in this release. -
README.md repo-layout table says "v0.13.0" on the
plugin/row — one release stale (bin/worklog VERSIONis"0.14.0", locked toplugin/.claude-plugin/plugin.jsonbytests/test_plugin.py, but README prose isn't part of that lockstep test). Cosmetic; not filed as a ticket, noted here as doc drift. -
docs/user_guide/cli-reference.md's new "Information architecture (IA) commands" section documentswiki-key,ia-normalize,ia-inventory,ia-render/ia-manifest,ia-index,ia-graph,link-pr,ticket-body,trace-check— but not the newia-ticketsubcommand. Confirmed by grep; the CLI reference has not caught up withbin/worklogfor this one command. -
worklog sync --pullcannot bootstrap a cursor-less pull (item 01KYAGZ8, filed but not yet worked): the adapter'spullverb 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:
-
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. -
.work/config.ymlcomments still say "no adapter binary" under ticketing/wiki blocks whileadapters/and the dispatcher ship. Harmless (skill path still works) but a 1.4-era story for config-only readers. - 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.
-
estimateand related optional fields (spec §5.4 / #108) have no CLI surface yet — configurable field model is open work. -
Labels don't pull — marked future work in
pull(). - Remote-origin tickets are reported, never created locally — deliberate read-safety.
-
Duplicated mini-validator (dispatcher,
adr.py, contract tests) and duplicated IA schema constants (ia.pyvsschema/*.json) — deliberate "bin-only install"; pinned by tests; fourth/diverge copies should extract or fail CI. -
IA gates warn-only until Phase 5 (#98);
trace-checkstays warn at commit forever (strict at release). Residual risk: ignored warnings allow metadata drift to merge. -
Phase 5 / platform render adapters /
/worklog:find+ glossary not shipped. -
UI work was moved to
wiki_ticket_sdd_uiand cancelled here — do not look for UI code in this repo. -
Live PR metadata (files changed, review/CI status) deliberately
deferred to a filed follow-up,
worklog pr-sync(#138) — nogh pr viewcall 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.
- Roadmap
- Design-Doc · Code-Walkthrough
- Plan: Plan-ia-content-model
- Plan: Plan-ticket-sync-and-init-detection
- ADR-0001-event-log-fold-union-merge
- ADR-0002-skill-based-edges-typed-contract
- ADR-0003-green-gates-merge
- Index-Releases
- Latest snapshot: Roadmap-2026-07-29_v0.18.0-release
- Index-Status
- Index-Decisions