From 73d7ab4bf95a974d6c8dd48e80e1ce12f45dba8c Mon Sep 17 00:00:00 2001 From: Rick Hightower Date: Thu, 6 Aug 2026 23:43:21 -0500 Subject: [PATCH 1/2] chore: upgrade vendored worklog tooling 0.18.0 to 0.22.2 (01KZD823EG6R5E1FXFX416RQ0G) Pulls in the plan-banner fix filed upstream as wiki_ticket_sdd#292, which shipped in worklog 0.20.0. All three completed plan pages announced themselves as "the latest status report"; they now read "completed plan", and the new upgrade plan correctly reads "plan not yet started". trace-check drops from 56 unlinked-evidence gaps to 24: 0.20.0 scoped it to released milestones and exempted kind:ops, which is what the release items were being flagged for. Three things the upgrade did not do for itself: 1. It rewrote hooks/pre-commit wholesale and silently deleted this repo's two local gate lines, added in v0.3.0 and v0.3.1. Restored, and now pinned by test_pre_commit_keeps_the_local_gates(), which CI runs directly rather than through the hook -- so it still fires in exactly the case where the hook itself has been clobbered. Verified by re-running init's overwrite against the suite: 25/26, exit 1. 2. init.sh does not copy doc_verify.py or provenance.py, yet pre-commit calls doc-verify behind a `[ ! -f bin/doc_verify.py ]` guard -- so the gate was installed, silent, and warning about nothing. Both modules vendored by hand; the doc-verify warning now appears in the gate output, which is the proof it was not running before. 3. The 0.19.0 upgrade note says to wire a SessionEnd hook at hooks/session-end.sh, a path init never creates and the plugin manifest does not wire. Vendored the script and added .claude/settings.json. doc-verify found four fabricated citations on its first run. One was in the live code walkthrough and is fixed; the other three are in frozen release documents and keep their errors, as frozen records do. Both were verified independently by resolving the symbol at each document's own stamped commit. The plugin's own surface is untouched: scripts/ has an empty diff, and the graph suite, curate suite and strict sample validation all pass unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WMk8bhkMEUWfHi7VYzMzFX --- .claude/settings.json | 14 + .gitignore | 4 + .work/config.yml | 2 +- .work/todo.jsonl | 15 + bin/adr.py | 11 +- bin/changelog.py | 158 ++++++++ bin/compact.py | 350 ++++++++++++++++- bin/doc_verify.py | 276 ++++++++++++++ bin/fold.py | 102 ++++- bin/ia.py | 7 + bin/ia_graph.py | 268 ++++++++++++- bin/ia_render.py | 180 +++++++-- bin/item_fields.py | 201 ++++++++++ bin/plan_capture.py | 34 +- bin/provenance.py | 154 ++++++++ bin/render_roadmap.py | 55 ++- bin/session.py | 130 +++++++ bin/sync_dispatch.py | 250 ++++++++++++- bin/ulid.py | 79 +++- bin/wiki_flavor.py | 109 ++++++ bin/worklog | 352 +++++++++++++++++- docs/.index/_graph.json | 146 ++++++++ docs/.index/_inventory.json | 27 +- .../2026-08-07-worklog-0-22-2-upgrade.yml | 5 + docs/.index/publish-manifest.json | 196 ++++++++-- docs/.index/rendered/_Sidebar.md | 1 + docs/.index/rendered/decisions.md | 1 + docs/.index/rendered/home.md | 2 +- docs/.index/rendered/prs/25.md | 2 +- docs/.index/rendered/prs/26.md | 2 +- docs/.index/rendered/prs/29.md | 2 +- docs/.index/rendered/prs/8.md | 2 +- docs/.index/rendered/prs/9.md | 2 +- .../tickets/01KZD823EG6R5E1FXFX416RQ0G.md | 27 ++ .../tickets/01KZD823EH6PBMN2DRD9WAX52F.md | 10 + .../tickets/01KZD823EHAFN2YF6WCG2T7XN8.md | 9 + .../tickets/01KZD823EHGFVX6NSM2ZT057SY.md | 10 + .../tickets/01KZD823EHMC16WZNM4W193VVX.md | 11 + .../tickets/01KZD823EHMRAZH978P0EYQFB1.md | 10 + .../tickets/01KZD823EHQRBSGQ76C4EK1TXT.md | 9 + .../tickets/01KZD823EHXEV8Z72ZCNZA8VZH.md | 11 + .../tickets/01KZD823EHZ7W4HG9N5CV4SRMT.md | 10 + .../tickets/01KZD84S62B5TWKQSX4XV9848M.md | 9 + docs/.index/rendered/traceability.md | 57 +++ docs/designs/current_code_walkthrough.md | 4 +- .../2026-08-07-worklog-0-22-2-upgrade.md | 87 +++++ docs/roadmap.md | 57 ++- hooks/pre-commit | 66 +++- hooks/session-end.sh | 22 ++ tests/test_okf_graph.py | 19 + 50 files changed, 3420 insertions(+), 147 deletions(-) create mode 100644 .claude/settings.json create mode 100755 bin/changelog.py create mode 100755 bin/doc_verify.py create mode 100755 bin/item_fields.py create mode 100755 bin/provenance.py create mode 100755 bin/session.py create mode 100755 bin/wiki_flavor.py create mode 100644 docs/.index/plan/2026-08-07-worklog-0-22-2-upgrade.yml create mode 100644 docs/.index/rendered/tickets/01KZD823EG6R5E1FXFX416RQ0G.md create mode 100644 docs/.index/rendered/tickets/01KZD823EH6PBMN2DRD9WAX52F.md create mode 100644 docs/.index/rendered/tickets/01KZD823EHAFN2YF6WCG2T7XN8.md create mode 100644 docs/.index/rendered/tickets/01KZD823EHGFVX6NSM2ZT057SY.md create mode 100644 docs/.index/rendered/tickets/01KZD823EHMC16WZNM4W193VVX.md create mode 100644 docs/.index/rendered/tickets/01KZD823EHMRAZH978P0EYQFB1.md create mode 100644 docs/.index/rendered/tickets/01KZD823EHQRBSGQ76C4EK1TXT.md create mode 100644 docs/.index/rendered/tickets/01KZD823EHXEV8Z72ZCNZA8VZH.md create mode 100644 docs/.index/rendered/tickets/01KZD823EHZ7W4HG9N5CV4SRMT.md create mode 100644 docs/.index/rendered/tickets/01KZD84S62B5TWKQSX4XV9848M.md create mode 100644 docs/plans/2026-08-07-worklog-0-22-2-upgrade.md create mode 100755 hooks/session-end.sh diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..83daf04 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/hooks/session-end.sh" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index 0b0edc8..f52bd22 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,8 @@ integration-okf/ # worklog: keep event logs & config; ignore local-only caches .work/wiki-checkout/ .work/suggestions.jsonl +.work/.sessions adapters/**/__pycache__/ + +# Agent worktrees (git worktree add ...); never part of a commit. +.claude/worktrees/ diff --git a/.work/config.yml b/.work/config.yml index 89ae0c9..56a1ef0 100644 --- a/.work/config.yml +++ b/.work/config.yml @@ -44,4 +44,4 @@ classifier: min_confidence: 0.7 debounce: stop -installed: 0.18.0 +installed: 0.22.2 diff --git a/.work/todo.jsonl b/.work/todo.jsonl index 6353f2c..ace2ab2 100644 --- a/.work/todo.jsonl +++ b/.work/todo.jsonl @@ -144,3 +144,18 @@ {"actor":"richardhightower","ev":"01KZ2S9XK84AG7PX2P6MKSNTV7","item":"01KZ2CYK20RWGPSEE6VGZ3NHBP","op":"close","set":{"status":"done"},"ts":"2026-08-03T03:05:01Z"} {"actor":"richardhightower","ev":"01KZ30RXDKDRM6AMQGTMX1WGDF","item":"01KZ30RXDKQG2CSA2D0TXWJQZZ","op":"create","set":{"body":"Cut v0.3.2: stamp the changelog, snapshot the roadmap, bump the version lockstep across the four plugin manifests and the README, tag, publish the GitHub release, then sync the design doc, code walkthrough, user guide and README to the tagged commit. Ships one behaviour change: validate now reports broken and out-of-bundle links in a bundle's root index.md and log.md, which were previously exempt from all link checking.","kind":"ops","level":"task","milestone":"v0.3.2","priority":"P2","status":"todo","title":"Release v0.3.2"},"ts":"2026-08-03T05:15:33Z"} {"actor":"richardhightower","ev":"01KZ3Z3VX63GMZH9B3K9NX3Z37","item":"01KZ30RXDKQG2CSA2D0TXWJQZZ","op":"close","set":{"status":"done"},"ts":"2026-08-03T14:05:49Z"} +{"actor":"richardhightower","ev":"01KZD823EGSEBJ1WN3C1H7A7YQ","item":"01KZD823EG6R5E1FXFX416RQ0G","op":"create","set":{"body":"Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. Includes installing two modules the upstream installer omits, restoring merge commits so document provenance can be resolved, and republishing the wiki so plan pages stop announcing themselves as status reports.","kind":"feature","level":"epic","plan":"docs/plans/2026-08-07-worklog-0-22-2-upgrade.md","priority":"P2","status":"todo","title":"Upgrade the vendored worklog tooling to 0.22.2"},"ts":"2026-08-07T04:35:18Z"} +{"actor":"richardhightower","ev":"01KZD823EH4YV2D00XG2JDM2BG","item":"01KZD823EHQRBSGQ76C4EK1TXT","op":"create","set":{"body":"Run the init command, which is the documented and idempotent upgrade path. It\nre-copies bin/ and hooks/ and adds four modules this repo lacks: session.py,\nitem_fields.py, wiki_flavor.py and changelog.py. Confirm the version actually\nmoved afterwards rather than assuming the copy happened.","kind":"feature","level":"task","parent":"01KZD823EG6R5E1FXFX416RQ0G","plan":"docs/plans/2026-08-07-worklog-0-22-2-upgrade.md","priority":"P1","status":"todo","title":"Upgrade the vendored tooling to 0.22.2"},"ts":"2026-08-07T04:35:18Z"} +{"actor":"richardhightower","ev":"01KZD823EHDEKYFV7T229BGAW8","item":"01KZD823EH6PBMN2DRD9WAX52F","op":"create","set":{"body":"Add .work/.sessions to .gitignore, since it is local advisory state that must\nnever be committed and is not currently ignored. Wire the SessionEnd hook,\nwhich means creating a project settings file that does not exist here yet.\nConfirm the hooks path still points at this repo's hooks directory.","kind":"feature","level":"task","parent":"01KZD823EG6R5E1FXFX416RQ0G","plan":"docs/plans/2026-08-07-worklog-0-22-2-upgrade.md","priority":"P2","status":"todo","title":"Complete the three manual steps the upgrade requires"},"ts":"2026-08-07T04:35:18Z"} +{"actor":"richardhightower","ev":"01KZD823EHMJ7X2Q7671SFXRWH","item":"01KZD823EHZ7W4HG9N5CV4SRMT","op":"create","set":{"body":"The pre-commit hook calls doc-verify behind a guard that tests whether\nbin/doc_verify.py exists, but init does not copy that module or provenance.py.\nThe result is a gate that is present in the hook, never runs, and warns about\nnothing. This is the third time this project has met a check that was\nconfigured but could not fire. Copy both modules so the gate works, and mark\nthe deviation from init's file set clearly so it can be removed later.","kind":"feature","level":"task","parent":"01KZD823EG6R5E1FXFX416RQ0G","plan":"docs/plans/2026-08-07-worklog-0-22-2-upgrade.md","priority":"P1","status":"todo","title":"Install the doc-verify modules the upgrade leaves out"},"ts":"2026-08-07T04:35:18Z"} +{"actor":"richardhightower","ev":"01KZD823EHAHWT49H6T2NXAM4E","item":"01KZD823EHXEV8Z72ZCNZA8VZH","op":"create","set":{"body":"Report that init.sh omits doc_verify.py and provenance.py while pre-commit\nreferences the first, and that the file-exists guard makes the omission\nsilent. Include the repro and note that uninstall.sh omits them too.","kind":"feature","level":"task","parent":"01KZD823EG6R5E1FXFX416RQ0G","plan":"docs/plans/2026-08-07-worklog-0-22-2-upgrade.md","priority":"P2","status":"todo","title":"File the missing-module gap upstream"},"ts":"2026-08-07T04:35:18Z"} +{"actor":"richardhightower","ev":"01KZD823EHKD6TT4DKPZV8QY3V","item":"01KZD823EHMRAZH978P0EYQFB1","op":"create","set":{"body":"This repo used merge commits through pull request 37 and was switched to\nsquash during the v0.3.2 work without the change being flagged. Upstream\nADR-0008 records that document provenance depends on merge commits, because\nunder squash the authoring commit never reaches the default branch and the\ncitation verifier loses its ground truth. Record the reason so the convention\nis not quietly reversed later.","kind":"feature","level":"task","parent":"01KZD823EG6R5E1FXFX416RQ0G","plan":"docs/plans/2026-08-07-worklog-0-22-2-upgrade.md","priority":"P2","status":"todo","title":"Restore merge commits as the pull request merge style"},"ts":"2026-08-07T04:35:18Z"} +{"actor":"richardhightower","ev":"01KZD823EHK61WW6MDH5JYC9F9","item":"01KZD823EHMC16WZNM4W193VVX","op":"create","set":{"body":"Run doc-verify once and separate fabricated citations, which were already\nwrong in the tree the author had open, from drift, which was correct when\nwritten and is expected in a frozen document. Fix the fabrications. Leave\ndrift alone in frozen documents.","kind":"feature","level":"task","parent":"01KZD823EG6R5E1FXFX416RQ0G","plan":"docs/plans/2026-08-07-worklog-0-22-2-upgrade.md","priority":"P2","status":"todo","title":"Baseline the citation verifier and fix real defects only"},"ts":"2026-08-07T04:35:18Z"} +{"actor":"richardhightower","ev":"01KZD823EHVM0JSTPY50HAJM53","item":"01KZD823EHAFN2YF6WCG2T7XN8","op":"create","set":{"body":"Add the merge commit that brought each frozen document to the default branch.","kind":"feature","level":"task","parent":"01KZD823EG6R5E1FXFX416RQ0G","plan":"docs/plans/2026-08-07-worklog-0-22-2-upgrade.md","priority":"P3","status":"todo","title":"Backfill document provenance"},"ts":"2026-08-07T04:35:18Z"} +{"actor":"richardhightower","ev":"01KZD823EHDFP0JRRPGFBXR1HC","item":"01KZD823EHGFVX6NSM2ZT057SY","op":"create","set":{"body":"Publish first and then converge the index, because publishing rewrites the\npublished ledger that the normalizer self-describes. Expect only the plan\npages to republish, not the whole site. Confirm the three plan pages name\ntheir state, and confirm the plugin's own test suite and sample bundle are\ncompletely unaffected \u2014 a tooling upgrade that changes graph engine behaviour\nwould itself be a bug.","kind":"feature","level":"task","parent":"01KZD823EG6R5E1FXFX416RQ0G","plan":"docs/plans/2026-08-07-worklog-0-22-2-upgrade.md","priority":"P1","status":"todo","title":"Republish and confirm the plan banners are fixed"},"ts":"2026-08-07T04:35:18Z"} +{"actor":"richardhightower","ev":"01KZD84S6203HB1MPKZZZH9B8Y","git":"018e71d","item":"01KZD84S62B5TWKQSX4XV9848M","op":"create","set":{"body":"The upgrade rewrites hooks/pre-commit wholesale, which deleted the two repo-local gate lines this project added in v0.3.0 and v0.3.1: the graph engine test suite and the post-edit hook shell test. Neither the installer nor the hook warns that a local edit was discarded. CI still runs both, so the checks are not lost, but the local gate that catches a failure before it reaches CI is gone. Restore them, and place them so the next upgrade cannot silently remove them again.","discovered_during":"01KZD823EHQRBSGQ76C4EK1TXT","kind":"bug","level":"task","parent":"01KZD823EG6R5E1FXFX416RQ0G","priority":"P1","status":"todo","title":"Restore the repo-local test gates the upgrade removed from pre-commit","unplanned":true},"ts":"2026-08-07T04:36:46Z"} +{"actor":"richardhightower","ev":"01KZD8FRFAH2XF46GHKGARBCNC","git":"018e71d","item":"01KZD823EHQRBSGQ76C4EK1TXT","op":"close","set":{"status":"done"},"ts":"2026-08-07T04:42:46Z"} +{"actor":"richardhightower","ev":"01KZD8FRMJJQS981GVT8ZKRAHR","git":"018e71d","item":"01KZD823EH6PBMN2DRD9WAX52F","op":"close","set":{"status":"done"},"ts":"2026-08-07T04:42:46Z"} +{"actor":"richardhightower","ev":"01KZD8FRT07ZPMGVHYZP40NVW0","git":"018e71d","item":"01KZD823EHZ7W4HG9N5CV4SRMT","op":"close","set":{"status":"done"},"ts":"2026-08-07T04:42:46Z"} +{"actor":"richardhightower","ev":"01KZD8FRZ35SQ4YBNQ9S3TMW8D","git":"018e71d","item":"01KZD84S62B5TWKQSX4XV9848M","op":"close","set":{"status":"done"},"ts":"2026-08-07T04:42:46Z"} +{"actor":"richardhightower","ev":"01KZD8FS43AJA5BF24K9ANT7AC","git":"018e71d","item":"01KZD823EHMC16WZNM4W193VVX","op":"close","set":{"status":"done"},"ts":"2026-08-07T04:42:46Z"} diff --git a/bin/adr.py b/bin/adr.py index 78e958b..e9e6026 100755 --- a/bin/adr.py +++ b/bin/adr.py @@ -169,10 +169,17 @@ def check_all(dir=ADR_DIR): def scaffold(title, adr_id, slug, date, status="proposed", - deciders=None, tags=None, supersedes=None): - """Template for a new ADR -> (path, content).""" + deciders=None, tags=None, supersedes=None, git_hash=None): + """Template for a new ADR -> (path, content). + + `git_hash` is passed in, not looked up, so this stays a pure function of + its arguments. Omitted when falsy, never written empty; quoted so an + all-digit sha survives ia._scalar's int coercion. + """ lines = ["---", "id: %d" % adr_id, "slug: %s" % slug, "title: %s" % title, "date: %s" % date, "status: %s" % status] + if git_hash: + lines.append('git_hash: "%s"' % git_hash) if deciders: lines.append("deciders: [" + ", ".join(deciders) + "]") if tags: diff --git a/bin/changelog.py b/bin/changelog.py new file mode 100755 index 0000000..3d048fa --- /dev/null +++ b/bin/changelog.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +changelog.py -- draft the unreleased CHANGELOG section from git history. + +Item #136: the release skill requires a hand-written `## X.Y.Z — unreleased` +section before a release can be cut, but nothing enforced writing it as +features landed. v0.13.0's section had to be reconstructed from `git log` +after 36 commits had piled up unlogged. + +This does not write release notes. It writes the STARTING POINT for them, so +the failure mode is "these bullets need better prose" instead of "nobody +remembers what shipped". A human or agent still edits before stamping. + +Two deliberate refusals: + - It never guesses the version. Which digit moves is a semver judgement, so + the placeholder stays literal until someone passes --version. + - It never silently drops a commit. Everything excluded is reported on + stderr, with the reason. stdout stays clean markdown so it can be piped. +""" +import re +import subprocess +import sys + +# Conventional-commit type -> the bullet label this changelog already uses. +LABELS = { + "feat": "New", + "fix": "Fix", + "perf": "Change", + "refactor": "Change", + "ops": "Change", + "chore": "Change", + "docs": "Docs", + "test": "Tests", +} + +# Types that describe process rather than the product. A plan is its own +# permanent record in docs/plans and a release commit is the stamp itself; +# neither belongs in the notes for what shipped. +PROCESS_TYPES = ("plan", "release") + +# Paths that are generated or are the log itself. A commit touching ONLY +# these changed no behaviour a reader of the changelog cares about. +HOUSEKEEPING = (".work/", "docs/.index/", "docs/roadmap.md", "docs/status/", + "docs/plans/") + +SUBJECT = re.compile(r"^(?P[a-z]+)(?:\((?P[^)]*)\))?!?:\s*(?P.+)$") +# Trailing worklog ids -- internal provenance, noise in release notes. +ULID_REF = re.compile(r"\s*\((?:01[A-HJKMNP-TV-Z0-9]{24}(?:,\s*)?)+\)\s*$") + + +def _git(*args): + return subprocess.run(["git", *args], capture_output=True, text=True) + + +def last_tag(): + p = _git("describe", "--tags", "--abbrev=0") + return p.stdout.strip() if p.returncode == 0 else None + + +def commits(since=None): + """[(subject, [files])] for each non-merge commit after `since`. + + One `git log` call, not one per commit: the file list is what decides + housekeeping, and asking git per commit turns a release-sized range into + hundreds of subprocesses. + """ + rng = f"{since}..HEAD" if since else "HEAD" + p = _git("log", rng, "--no-merges", "--reverse", "--name-only", + "--format=%x00%s") + if p.returncode != 0: + return [] + out = [] + for chunk in p.stdout.split("\x00"): + if not chunk.strip(): + continue + lines = chunk.splitlines() + out.append((lines[0].strip(), [f for f in lines[1:] if f.strip()])) + return out + + +def _housekeeping(files): + return bool(files) and all(f.startswith(HOUSEKEEPING) for f in files) + + +def classify(subject, files): + """(label, text) to render, or (None, reason) to exclude.""" + m = SUBJECT.match(subject) + ctype = m.group("type") if m else None + text = (m.group("rest") if m else subject).strip() + text = ULID_REF.sub("", text).strip() + + if ctype in PROCESS_TYPES: + return None, f"{ctype}: commit (process, not product)" + if _housekeeping(files): + return None, "touches only the log and generated files" + if not text: + return None, "empty subject" + return LABELS.get(ctype, "Change"), text + + +def draft(version=None, since=None): + """(markdown, excluded) -- excluded is [(subject, reason)].""" + since = since or last_tag() + entries, excluded = [], [] + for subject, files in commits(since): + label, payload = classify(subject, files) + if label is None: + excluded.append((subject, payload)) + else: + entries.append((label, payload)) + + # Group in a stable, meaningful order rather than raw git order: readers + # of a release want the new things first and the incidental last. + order = ["New", "Fix", "Change", "Docs", "Tests"] + entries.sort(key=lambda e: order.index(e[0]) if e[0] in order else len(order)) + + head = f"## {version or 'X.Y.Z'} — unreleased" + if not entries: + body = ["", "_No product changes since " + f"{since or 'the start of history'}._"] + else: + body = [""] + [f"- **{label}**: {text}" for label, text in entries] + return "\n".join([head] + body) + "\n", excluded + + +def main(argv): + version = since = None + args = argv[1:] + for flag, target in (("--version", "version"), ("--since", "since")): + if flag in args: + i = args.index(flag) + if i + 1 >= len(args): + print(f"changelog-draft: {flag} needs a value", file=sys.stderr) + return 2 + if target == "version": + version = args[i + 1] + else: + since = args[i + 1] + + resolved = since or last_tag() + text, excluded = draft(version, since) + sys.stdout.write(text) + + # stderr, so stdout stays pipeable into the CHANGELOG. Never silent: a + # draft that quietly dropped a commit is how "we forgot to write it" + # comes back wearing a different hat. + print(f"\nchangelog-draft: since {resolved or '(no tag)'}; " + f"{len(excluded)} commit(s) excluded", file=sys.stderr) + for subject, reason in excluded: + print(f" - {subject} [{reason}]", file=sys.stderr) + if not version: + print("changelog-draft: version left as X.Y.Z — pass --version to set it", + file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/bin/compact.py b/bin/compact.py index 82d8c1f..ac3b0cd 100755 --- a/bin/compact.py +++ b/bin/compact.py @@ -28,7 +28,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import ulid # noqa: E402 -from fold import fold, CLOSED_STATUSES # noqa: E402 +from fold import fold, CLOSED_STATUSES, external_owners # noqa: E402 from render_roadmap import max_ev # noqa: E402 @@ -42,16 +42,34 @@ def _public(item): return {k: v for k, v in item.items() if not k.startswith("_")} -def _snapshot(item): - # Fresh ULIDs sort after every past ev, so these outsort the watermark. - return {"ev": ulid.new(), "ts": _now(), "actor": "compactor", +def _snapshot(item, through=None): + """A snapshot of one item's folded state. + + `through` is the highest ev this compaction actually folded FOR THIS + ITEM, and it is what the fold drops against (#284). A global mark cannot + do that job: it is the max over the whole log, so it also covers events + from branches this compaction never saw, and dropping those loses work. + Kept top-level rather than inside `set`, so it never becomes item state + and never reaches the verify comparison. + """ + snap = {"ev": ulid.new(), "ts": _now(), "actor": "compactor", "item": item["id"], "op": "snapshot", "set": {k: v for k, v in _public(item).items() if k != "id"}} + if through: + snap["through"] = through + sha = ulid.git_commit() + if sha: + snap["git"] = sha + return snap def _compact_line(watermark): - return {"ev": ulid.new(), "ts": _now(), "actor": "compactor", + line = {"ev": ulid.new(), "ts": _now(), "actor": "compactor", "op": "compact", "through": watermark} + sha = ulid.git_commit() + if sha: + line["git"] = sha + return line def _dump(events): @@ -141,6 +159,18 @@ def compact(todo_path=".work/todo.jsonl", done_path=".work/done.jsonl"): before = fold([todo_path, done_path]) # step 1: full history + # Highest ev this run actually folded, PER ITEM (#284). Taken from the raw + # input lines, not from the fold, because it must describe what was read + # rather than what survived. + per_item = {} + for path in (todo_path, done_path): + for _line, e in _raw_lines(path): + if e is None or e.get("op") == "compact": + continue + iid, ev = e.get("item"), e.get("ev") + if iid and ev and ev > per_item.get(iid, ""): + per_item[iid] = ev + open_items, closed_items = [], [] # step 3: partition for item in sorted(before.items.values(), key=lambda i: i["id"]): # Anything not positively closed (incl. orphans) stays open: never @@ -150,7 +180,7 @@ def compact(todo_path=".work/todo.jsonl", done_path=".work/done.jsonl"): open_ids = {i["id"] for i in open_items} # step 4: new todo = open snapshots + watermark - todo_text = _dump([_snapshot(i) for i in open_items] + todo_text = _dump([_snapshot(i, per_item.get(i["id"])) for i in open_items] + [_compact_line(watermark)]) # steps 5+6: new done = old lines minus open items, plus snapshots for @@ -166,7 +196,7 @@ def compact(todo_path=".work/todo.jsonl", done_path=".work/done.jsonl"): if parsed.get("item") in open_ids: continue # stale snapshot/event for a reopened item (step 6) kept.append(line + "\n") - fresh = [_snapshot(i) for i in closed_items + fresh = [_snapshot(i, per_item.get(i["id"])) for i in closed_items if done_state.get(i["id"]) != _public(i)] done_text = "".join(kept) + _dump(fresh + [_compact_line(watermark)]) @@ -186,7 +216,313 @@ def compact(todo_path=".work/todo.jsonl", done_path=".work/done.jsonl"): return watermark +def _merge_watermark(raw_by_path): + """Highest `through` recorded by any compact line across the given raw + lines -- the same value fold()'s own apply_watermark computes when it + reads both files together. None if neither file has ever been compacted.""" + marks = [e.get("through") for raw in raw_by_path.values() for _line, e in raw + if e is not None and e.get("op") == "compact" and e.get("through")] + return max(marks) if marks else None + + +def check_resurrection(todo_path, done_path): + """Bug #243: fold.apply_watermark already drops any raw event at/below a + compact watermark, so a union merge that resurrects those lines corrupts + no state the fold reports -- only the file shrinks back to its + pre-compaction size, silently. Flag any such line instead of losing the + size win with no warning. + + Since #284 this asks the narrower, truthful question: would the fold + actually DROP this line? An event is only dropped now if its own item has + a snapshot claiming to have folded it, so a resurrected line for an item + with no snapshot is no longer flagged -- it survives, and warning about it + would be crying wolf. What remains flagged is the real hygiene loss: lines + a compaction genuinely removed, back in the file, making it grow to its + pre-compaction size again. + + Returns a list of problem strings; empty means clean.""" + raw = {p: _raw_lines(p) for p in (todo_path, done_path)} + wm = _merge_watermark(raw) + if wm is None: + return [] # never compacted -- nothing to resurrect + + covered = {} + for lines in raw.values(): + for _line, e in lines: + if e is None or e.get("op") != "snapshot": + continue + through = e.get("through") or wm + if through > covered.get(e.get("item"), ""): + covered[e.get("item")] = through + + problems = [] + for path, lines in raw.items(): + bad = [line for line, e in lines + if e is not None and e.get("op") not in ("compact", "snapshot") + and e.get("item") in covered + and e.get("ev", "") <= covered[e.get("item")]] + if bad: + problems.append( + f"{path}: {len(bad)} event(s) at/below their item's compact " + f"watermark are back (e.g. {bad[0]!r}) -- a union merge " + f"resurrected lines a compaction already folded into a " + f"snapshot. Keeping or dropping them changes no state -- the " + f"snapshot sorts above them and replaces state entirely -- so " + f"the file has merely grown back to its pre-compaction size. " + f"That same ordering is why any of these THIS BRANCH authored " + f"would be overwritten by the snapshot: merge-rescue re-emits " + f"them above it, which is the only thing that preserves them. " + f"Run: worklog merge-rescue") + return problems + + +def check_duplicate_ownership(todo_path, done_path): + """Bug #237: `worklog link` already refuses a ticket another item owns + (fold.external_owners), but a merge of two branches that each claimed the + same ticket bypasses that check -- sync catches it later, but a merge is + the earliest point, and it happens on every machine. + + Returns a list of problem strings; empty means clean.""" + r = fold([todo_path, done_path]) + problems = [] + for (system, key), owners in external_owners(r.items.values()).items(): + if len(owners) > 1: + problems.append( + f"{system}:{key} is claimed by {len(owners)} items after merge: " + f"{', '.join(owners)} -- keep one: worklog unlink ") + return problems + + +def _git(cwd, *args): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True) + + +def _merge_state(cwd): + """(base, ours, theirs) for the merge in progress, or None if there isn't one.""" + p = _git(cwd, "rev-parse", "-q", "--verify", "MERGE_HEAD") + if p.returncode != 0 or not p.stdout.strip(): + return None + theirs = p.stdout.strip() + ours = _git(cwd, "rev-parse", "-q", "--verify", "HEAD").stdout.strip() + base = _git(cwd, "merge-base", ours, theirs).stdout.strip() + return base, ours, theirs + + +def _events_at(cwd, rev, relpath): + """Parsed events of `relpath` as of `rev`. Missing file/rev -> [].""" + p = _git(cwd, "show", f"{rev}:{relpath}") + if p.returncode != 0: + return [] + out = [] + for line in p.stdout.splitlines(): + if not line.strip(): + continue + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + continue # fold ignores it too; never fatal + return out + + +def _reissue(event, ms): + """Replay `event` as a fresh event above the watermark. + + The original `ev` is below the watermark, so the fold would drop it -- the + intent has to be re-emitted under an id that sorts above. Timestamps are + handed in explicitly and strictly increasing: ulid.new() has no + intra-millisecond counter, so two reissues generated in the same + millisecond would sort by random bytes and replay the branch's events out + of order. + """ + fresh = {"ev": ulid.new(ms), "ts": _now(), "actor": event.get("actor", "rescue"), + "item": event["item"], "op": event["op"], "rescued_from": event["ev"]} + for field in ("set", "add", "del"): + if event.get(field): + fresh[field] = event[field] + return fresh + + +def merge_rescue(todo_path=".work/todo.jsonl", done_path=".work/done.jsonl"): + """Resolve a merge that check_resurrection blocked, without losing events. + + Recompacting -- what the guard used to advise -- cannot be done from this + state and would not be safe if it could: compaction verifies + fold(new) == fold(old), and the fold has ALREADY discarded this branch's + sub-watermark events, so the check would pass while making the loss + permanent. ADR-0005 records the resolution that does work, performed by + hand once: keep the compacted side's log and re-apply this branch's own + events on top. This is that procedure, mechanised. + + The merge base is what makes it precise. Compaction ran on a descendant of + the base, so every event in the base was folded into a snapshot and is safe + to drop. An event on the other side but NOT in the base was never folded -- + if it sorts below the watermark it is exactly the work that would vanish, + so it is re-emitted above the watermark instead. + + Returns (rescued, dropped). Raises SystemExit(1) if there is no merge to + rescue or verification fails. + """ + cwd = os.path.dirname(os.path.abspath(todo_path)) or "." + state = _merge_state(cwd) + if state is None: + print("merge-rescue: no merge in progress -- run this from the blocked " + "merge, before `git merge --abort`", file=sys.stderr) + raise SystemExit(1) + base, ours, theirs = state + + # realpath both sides: --show-toplevel resolves symlinks and abspath does + # not, so on macOS (/var -> /private/var) the relative path comes out as + # garbage and every `git show` silently returns nothing. + top = _git(cwd, "rev-parse", "--show-toplevel").stdout.strip() or cwd + paths = (todo_path, done_path) + rel = {p: os.path.relpath(os.path.realpath(p), os.path.realpath(top)) + for p in paths} + at = {(rev, p): _events_at(cwd, rev, rel[p]) + for rev in (base, ours, theirs) for p in paths} + + def watermark_of(rev): + marks = [e.get("through") for p in paths for e in at[(rev, p)] + if e.get("op") == "compact" and e.get("through")] + return max(marks) if marks else None + + wms = {rev: watermark_of(rev) for rev in (ours, theirs)} + if not any(wms.values()): + print("merge-rescue: neither side has been compacted; this is not the " + "situation this command repairs", file=sys.stderr) + raise SystemExit(1) + # The compacted side's log is the base to build on; the other side's unique + # events are what get replayed onto it. + keep = max((ours, theirs), key=lambda r: wms[r] or "") + other = theirs if keep == ours else ours + wm = wms[keep] + + ids_in = lambda rev: {e["ev"] for p in paths for e in at[(rev, p)] if "ev" in e} + base_ids, keep_ids = ids_in(base), ids_in(keep) + + rescued, dropped, new_text = [], 0, {} + for p in paths: + lines = [json.dumps(e, separators=(",", ":"), sort_keys=True) + for e in at[(keep, p)]] + carried = [] + for e in at[(other, p)]: + if e.get("op") == "compact" or e.get("ev") in keep_ids: + continue + if e.get("ev") in base_ids: + dropped += 1 # already folded into the keep side's snapshot + continue + carried.append(e) + # Replay in the branch's own order; one millisecond apart so the + # fresh ids sort in that same order. + # + # Re-issuing only the sub-watermark events is not enough, and the + # failure is silent. Fresh ids are stamped at NOW, so they sort above + # every retained original -- including this item's own LATER events, + # which were above the watermark and kept their old ids. An item whose + # create and update were re-issued but whose close was retained folds + # back to the pre-close state: no event lost, every guard green, wrong + # answer. Observed 2026-08-05 on the Codex branch, where a `done` item + # came back as `in_progress`. + # + # So re-issuing is contagious forward: once an item has had one event + # re-issued, every later event of that item must move too, or the + # partial re-stamp inverts their order. + start = int(time.time() * 1000) + moved = set() + for n, e in enumerate(sorted(carried, key=lambda x: x.get("ev", ""))): + above = e.get("op") == "snapshot" or e.get("ev", "") > wm + if above and e.get("item") not in moved: + lines.append(json.dumps(e, separators=(",", ":"), sort_keys=True)) + continue + fresh = _reissue(e, start + n) + moved.add(e.get("item")) + rescued.append((e["ev"], fresh["ev"], e["item"], e["op"])) + lines.append(json.dumps(fresh, separators=(",", ":"), sort_keys=True)) + new_text[p] = "".join(ln + "\n" for ln in lines) + + tmp = {p: p + ".rescue" for p in paths} + for p in paths: + with open(tmp[p], "w", encoding="utf-8") as fh: + fh.write(new_text[p]) + try: + # The merge is only unblocked if the guard now passes... + left = check_resurrection(tmp[todo_path], tmp[done_path]) + if left: + for problem in left: + print(f"merge-rescue: {problem}", file=sys.stderr) + raise SystemExit(1) + # ...and nothing may vanish: every item either side knew about must + # still fold to a state. + expected = {e["item"] for rev in (keep, other) for p in paths + for e in at[(rev, p)] if e.get("op") != "compact" and "item" in e} + after = set(fold([tmp[todo_path], tmp[done_path]]).items) + missing = expected - after + if missing: + print(f"merge-rescue: would lose {len(missing)} item(s): " + f"{sorted(missing)}; aborted, logs untouched", file=sys.stderr) + raise SystemExit(1) + # ...and no item's events may change relative order. Losing nothing is + # necessary and NOT sufficient: state is fold(events sorted by id), so + # re-stamping ids reorders as surely as deleting them, and the item + # count check above stays green either way. Sorting by the new ids must + # give each item the same sequence its original ids did. + seen = {} + for p in paths: + for _line, e in _raw_lines(tmp[p]): + if not e or "item" not in e or "ev" not in e: + continue + seen.setdefault(e["item"], []).append( + (e["ev"], e.get("rescued_from", e["ev"]))) + for item, evs in seen.items(): + was = [orig for _new, orig in sorted(evs)] + if was != sorted(was): + print(f"merge-rescue: replay would reorder {item}'s history " + f"({' '.join(was)}); aborted, logs untouched", + file=sys.stderr) + raise SystemExit(1) + except BaseException: + for p in paths: + if os.path.exists(tmp[p]): + os.unlink(tmp[p]) + raise + for p in paths: + os.replace(tmp[p], p) + return rescued, dropped + + +def report_rescue(rescued, dropped): + """Print what the rescue did and what the operator still has to run.""" + print(f"merge-rescue: dropped {dropped} already-compacted event(s); " + f"re-applied {len(rescued)} of this branch's own") + for old, new, item, op in rescued: + print(f" {item} {op}: {old} -> {new}") + print("\nnow finish the merge:\n" + " worklog roadmap-render\n" + " git add -A && git commit\n" + "(undo with: git merge --abort)") + + +def merge_check(todo_path=".work/todo.jsonl", done_path=".work/done.jsonl"): + """Run both merge-time integrity guards (#243, #237). Prints and returns + False on any problem; never raises -- the merge-commit hook decides + whether that means blocking the commit.""" + problems = (check_resurrection(todo_path, done_path) + + check_duplicate_ownership(todo_path, done_path)) + if not problems: + return True + print("compact: merge integrity check failed", file=sys.stderr) + for p in problems: + print(f" {p}", file=sys.stderr) + return False + + def main(argv): + if argv[1:2] == ["--merge-check"]: + paths = argv[2:] or [".work/todo.jsonl", ".work/done.jsonl"] + return 0 if merge_check(*paths) else 1 + if argv[1:2] == ["--merge-rescue"]: + paths = argv[2:] or [".work/todo.jsonl", ".work/done.jsonl"] + report_rescue(*merge_rescue(*paths)) + return 0 paths = argv[1:] or [".work/todo.jsonl", ".work/done.jsonl"] wm = compact(*paths) print(f"compacted through {wm}" if wm else "nothing to compact") diff --git a/bin/doc_verify.py b/bin/doc_verify.py new file mode 100755 index 0000000..7183cd4 --- /dev/null +++ b/bin/doc_verify.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""doc_verify.py -- check a generated document's code citations against the +commit it was written against (#294). + +The v0.20.0 doc sync measured a 46% error rate on design-doc line citations: +12 of 26 stale. One of the wrong claims had been INTRODUCED while hand-fixing +the previous wrong claim. Correcting prose by hand without a check relocates +the error rather than removing it. + +Checking against HEAD would be the obvious design and it is the wrong one. +HEAD moves; a frozen document is ALLOWED to age. Resolving citations at the +document's own `git_hash` instead separates two things that today look +identical: + + FABRICATED -- wrong even in the tree the author had open. A defect. + DRIFT -- right then, and the code has moved since. Not a defect for a + frozen doc; a defect for one claiming to describe HEAD. + +Today they are indistinguishable, so all 26 look equally suspect and nobody +triages them. + +NEVER falls back to HEAD when a document is unstamped or its commit cannot be +resolved. That fallback is bug #294 with extra steps: it would report drift as +fabrication and quietly re-introduce the "checked against the wrong tree" +failure this module exists to end. +""" +import ast +import os +import re +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import ia # noqa: E402 + +# The citation forms that actually occur in this repo's design docs. Ranges +# use an EN-DASH, not a hyphen -- a regex written for "-" matches nothing. +_PATH = r"(?:bin|tests|hooks|plugin|schema|docs|adapters)/[\w./-]+" +_DASH = r"[–—-]" + +CITE_RANGE = re.compile( + r"(?P%s)`?" # bin/fold.py + r"(?:\s*%s\s*(?P[\w.]+)\(\))?" # — apply_watermark() + r"[`,\s]*lines?\s+" + r"(?P\d+)\s*%s\s*(?P\d+)" % (_PATH, _DASH, _DASH)) + +CITE_ONE = re.compile(r"(?P%s):(?P\d+)" % _PATH) + +# (`ia_graph.ticket_body()`, lines 302–357) -- module, not path. Resolved +# against bin/.py, which is where every such symbol lives here. +CITE_SYMBOL = re.compile( + r"`(?P[a-z_][\w]*)\.(?P[\w]+)\(\)`,?\s*lines?\s+" + r"(?P\d+)\s*%s\s*(?P\d+)" % _DASH) + +LIVE_DOCS = ("current_design_doc.md", "current_code_walkthrough.md") + + +def _git(*args): + try: + p = subprocess.run(["git", *args], capture_output=True, text=True) + except OSError: + return None + return p.stdout if p.returncode == 0 else None + + +def is_shallow(): + return (_git("rev-parse", "--is-shallow-repository") or "").strip() == "true" + + +def resolvable(sha): + """Is this commit actually in the clone? Squash-merges and shallow + checkouts both produce a stamp naming a commit nobody has.""" + return _git("cat-file", "-e", "%s^{commit}" % sha) is not None + + +def _at(sha, path, _cache={}): + """File content at a commit, or None when the path did not exist. + + Keyed on the working directory as well as (sha, path). Symbolic refs are + not unique across repositories -- caching "HEAD" globally returns one + repo's file for another's, which is exactly the class of wrong-tree + answer this module exists to prevent. Callers should resolve HEAD to a + real sha anyway; this is the second line of defence. + """ + key = (os.getcwd(), sha, path) + if key not in _cache: + _cache[key] = _git("show", "%s:%s" % (sha, path)) + return _cache[key] + + +def citations(text): + """-> list of {path, symbol, start, end}. Deduped; order preserved.""" + out, seen = [], set() + + def add(path, symbol, start, end): + k = (path, symbol, start, end) + if k not in seen: + seen.add(k) + out.append({"path": path, "symbol": symbol, + "start": int(start), "end": int(end)}) + + for m in CITE_RANGE.finditer(text): + add(m.group("path"), m.group("symbol"), m.group("start"), m.group("end")) + for m in CITE_SYMBOL.finditer(text): + path = "bin/%s.py" % m.group("module") + add(path, m.group("symbol"), m.group("start"), m.group("end")) + for m in CITE_ONE.finditer(text): + add(m.group("path"), None, m.group("line"), m.group("line")) + return out + + +def _defined_at(src, symbol): + """First line of `symbol`'s definition in `src`, or None if unknowable. + + None means "do not judge": the file is not Python, does not parse at that + commit, or the name is defined more than once (a method on two classes, + a redefinition under a version guard). Those cases fall back to the + substring check rather than guessing, because a wrong ACCUSATION here is + worse than a missed one -- this tool's whole value is that a finding is + worth acting on. + """ + try: + tree = ast.parse(src) + except (SyntaxError, ValueError): + return None + hits = [n for n in ast.walk(tree) + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, + ast.ClassDef)) and n.name == symbol] + return hits[0].lineno if len(hits) == 1 else None + + +def _check_one(cite, sha, head): + """-> (verdict, detail). Verdicts: ok | fabricated | drift. + + `head` is a resolved sha, never the symbolic ref: a symbolic ref caches + badly and, worse, reads differently depending on where the process is + standing. + """ + body = _at(sha, cite["path"]) + if body is None: + return "fabricated", "path did not exist at %s" % sha[:9] + lines = body.split("\n") + if cite["end"] > len(lines): + return ("fabricated", + "cites line %d of a %d-line file" % (cite["end"], len(lines))) + if cite["symbol"]: + window = "\n".join(lines[cite["start"] - 1:cite["end"]]) + if cite["symbol"] not in window: + return ("fabricated", + "%s() is not in lines %d-%d at that commit" + % (cite["symbol"], cite["start"], cite["end"])) + # Appearing SOMEWHERE in the window is too weak on its own. A range + # that begins in the wrong place still passes it, so the reader is + # sent to a window that is not the definition -- `compact()` cited at + # 165-173 when it begins at 143 read as fine for three releases. The + # v0.22.1 regeneration found six citations wrong this way, every one + # reported ok. + # + # Only the START is judged. The end is where the author chose to stop + # quoting, and citing a slice of a long function is legitimate; nine + # of the ranges measured overshot by exactly one line, the blank after + # the body, which is how people write citations and not an error worth + # a finding. Checking the start caught every genuinely misleading case + # in the measured set and produced no false positives. + defined = _defined_at(body, cite["symbol"]) + if defined is not None and defined != cite["start"]: + return ("fabricated", + "%s() begins at line %d, not %d, at that commit" + % (cite["symbol"], defined, cite["start"])) + # Correct when written. Has it moved since? + head_body = _at(head, cite["path"]) + if head_body is None: + return "drift", "file no longer exists at HEAD" + hl = head_body.split("\n") + if cite["end"] > len(hl): + return "drift", "file is now only %d lines" % len(hl) + if cite["symbol"]: + if cite["symbol"] not in "\n".join(hl[cite["start"] - 1:cite["end"]]): + return "drift", "%s() has moved since" % cite["symbol"] + # Same reasoning as above, one tree later: correct when written, and + # the definition has since slid to a different line. + now = _defined_at(head_body, cite["symbol"]) + if now is not None and now != cite["start"]: + return "drift", ("%s() now begins at line %d, not %d" + % (cite["symbol"], now, cite["start"])) + return "ok", "" + + +def verify(records=None, strict=False): + """-> (findings, summary). A finding is a dict; summary counts verdicts. + + `strict` only changes the EXIT policy of the caller, never the analysis: + drift on a live doc (one claiming to describe HEAD) is a failure, drift + on a frozen doc is information. + """ + if records is None: + records = ia.build_records() + shallow = is_shallow() + # Resolve once: a symbolic ref is not a stable cache key and reads + # differently depending on where the process is standing. + head = (_git("rev-parse", "HEAD") or "HEAD").strip() + findings, summary = [], {"ok": 0, "fabricated": 0, "drift": 0, + "unstamped": 0, "unresolvable": 0, "docs": 0} + for key in sorted(records): + rec = records[key] + src = rec.get("source") + if not src or not src.endswith(".md") or not os.path.exists(src): + continue + with open(src, encoding="utf-8") as fh: + text = fh.read() + cites = citations(text) + if not cites: + continue + summary["docs"] += 1 + sha = rec.get("git_hash") + if not sha: + summary["unstamped"] += 1 + findings.append({"doc": key, "source": src, "verdict": "unstamped", + "detail": "%d citation(s) not verifiable" + % len(cites)}) + continue + if shallow or not resolvable(sha): + summary["unresolvable"] += 1 + findings.append({"doc": key, "source": src, + "verdict": "unresolvable", + "detail": "commit %s is not in this clone%s" + % (sha[:9], + " (shallow)" if shallow else "")}) + continue + live = src.endswith(LIVE_DOCS) + for c in cites: + verdict, detail = _check_one(c, sha, head) + summary[verdict] += 1 + if verdict == "ok": + continue + findings.append({ + "doc": key, "source": src, "verdict": verdict, + "live": live, "detail": detail, + "cite": "%s%s lines %d-%d" % ( + c["path"], " %s()" % c["symbol"] if c["symbol"] else "", + c["start"], c["end"])}) + return findings, summary + + +def failing(findings): + """What --strict exits 1 on: fabrication anywhere, and drift only on a + document that claims to describe HEAD. A frozen doc is allowed to age -- + failing on that would make the gate un-passable by design.""" + return [f for f in findings + if f["verdict"] == "fabricated" + or (f["verdict"] == "drift" and f.get("live"))] + + +def report(findings, summary): + by_doc = {} + for f in findings: + by_doc.setdefault(f["doc"], []).append(f) + for doc in sorted(by_doc): + print(doc) + for f in by_doc[doc]: + if f["verdict"] in ("unstamped", "unresolvable"): + print(" %-12s %s" % (f["verdict"].upper(), f["detail"])) + else: + print(" %-12s %s — %s" + % (f["verdict"].upper(), f["cite"], f["detail"])) + print("doc-verify: %d citation(s) ok, %d fabricated, %d drifted " + "across %d document(s); %d unstamped, %d unresolvable" + % (summary["ok"], summary["fabricated"], summary["drift"], + summary["docs"], summary["unstamped"], summary["unresolvable"])) + + +if __name__ == "__main__": + f, s = verify() + report(f, s) + if "--strict" in sys.argv and failing(f): + sys.exit(1) diff --git a/bin/fold.py b/bin/fold.py index b866f30..5e0dd0d 100755 --- a/bin/fold.py +++ b/bin/fold.py @@ -27,7 +27,6 @@ time (CLI) and in the hook/CI check. """ -import hashlib import json import sys from typing import Any, Dict, Iterable, List, Optional, Tuple @@ -166,8 +165,12 @@ def dedupe_and_sort(events: List[Dict[str, Any]], result: FoldResult) -> List[Di Union merge duplicates lines and scrambles their order, so both of these are load-bearing. ULIDs sort lexicographically by time, so this is a string sort. - Ties break on actor then line hash, which makes the result identical on every - machine -- two devs folding the same log must get the same answer. + + `ev` alone is a total order here, and deliberately: dedupe runs FIRST and is + keyed on `ev`, so no two events reaching the sort can share one. This used + to carry an (actor, line-hash) tiebreak for determinism across machines -- + unreachable by construction, and worse than silence, because it advertised + a guarantee that came from the dedupe above it (worklog#259). """ seen: Dict[str, Dict[str, Any]] = {} for ev in events: @@ -176,30 +179,91 @@ def dedupe_and_sort(events: List[Dict[str, Any]], result: FoldResult) -> List[Di result.deduped += 1 continue seen[key] = ev - return sorted( - seen.values(), - key=lambda e: ( - e["ev"], - e.get("actor", ""), - hashlib.sha256(e["_line"].encode()).hexdigest(), - ), - ) + return sorted(seen.values(), key=position) + + +def position(ev: Dict[str, Any]) -> Tuple[str, int]: + """Where an event applies in the fold. Identity is still `ev`; this is + only ordering. + + A snapshot applies where the events it folded were -- at its `through` -- + not at the moment the compactor happened to write it (#284). The + compactor mints the snapshot's own `ev` at compaction time, so it sorts + above everything; a branch that closed an item before that compaction ran + would then have its close applied FIRST and immediately overwritten by + the snapshot, because a snapshot replaces state entirely. The work + vanished even though the event was still in the log. + + Ordering by `through` puts the snapshot back where it belongs: after the + events it folded, before anything that happened later on any branch. + + The second element keeps a snapshot ahead of a same-positioned ordinary + event, so the later event wins rather than being replaced. Legacy + snapshots carry no `through` and fall back to their own `ev`, which is + exactly how they sorted before. + """ + if ev.get("op") == "snapshot" and ev.get("through"): + return (ev["through"], 0) + return (ev["ev"], 1) def apply_watermark(events: List[Dict[str, Any]], result: FoldResult) -> List[Dict[str, Any]]: """Step 4: drop everything the compactor already folded into a snapshot. Snapshots are exempt -- they carry the state those events produced. + + The rule is PER ITEM, and that is the whole point (#284). The global + watermark this used to apply was `max_ev` over the log the compaction + read -- a TIME marker being used as a CONTENT marker. An event created on + a branch before a compaction ran on main was never folded into any + snapshot, yet still sorted below that global mark, so merging the branch + back silently discarded it. Reproduced deterministically; the live + 2026-07-31 incident carried three such events and they survived only + because the merge guard blocked and a human re-applied them by hand. + + Two rules replace it: + + - An item with NO snapshot never has events dropped. Nothing folded + them, so nothing carries their state. This is compaction's own + "never drop data" principle (spec 7 step 3) applied on the read side. + - An item WITH a snapshot drops only its own events at/below that + snapshot's `through` -- the highest ev the compactor actually folded + for that item. + + Legacy logs whose snapshots predate `through` fall back to the global + mark, so an un-upgraded log still folds exactly as it did before. The + no-snapshot rule applies to them too, because it can only ever restore + data. """ marks = [e.get("through") for e in events if e["op"] == "compact" and e.get("through")] - if not marks: - return [e for e in events if e["op"] != "compact"] - result.watermark = max(marks) - return [ - e - for e in events - if e["op"] != "compact" and (e["op"] == "snapshot" or e["ev"] > result.watermark) - ] + result.watermark = max(marks) if marks else None + + # item -> highest ev its snapshots claim to have folded. A union merge can + # leave two snapshots for one item; the later compaction's is the truth. + covered: Dict[str, str] = {} + for e in events: + if e["op"] != "snapshot": + continue + through = e.get("through") + if through and through > covered.get(e["item"], ""): + covered[e["item"]] = through + snapshotted = {e["item"] for e in events if e["op"] == "snapshot"} + + kept = [] + for e in events: + if e["op"] == "compact": + continue + if e["op"] == "snapshot": + kept.append(e) + continue + iid = e.get("item") + if iid not in snapshotted: + kept.append(e) # nothing folded it; never drop it + continue + limit = covered.get(iid) or result.watermark + if limit is None or e["ev"] > limit: + kept.append(e) + return kept def _apply_mutations(item: Dict[str, Any], ev: Dict[str, Any]) -> None: diff --git a/bin/ia.py b/bin/ia.py index 250c86a..9cf5583 100755 --- a/bin/ia.py +++ b/bin/ia.py @@ -135,6 +135,13 @@ def dump_simple_yaml(meta): out.append("%s: {%s}" % (k, inner)) elif v is None: out.append("%s: null" % k) + elif isinstance(v, str) and re.fullmatch(r"-?\d+", v): + # Round-trip safety: _scalar() turns an all-digit string back + # into an int, so writing it bare loses the type and, with a + # leading zero, the value ("0123456" -> 123456). Quoting reaches + # _scalar's quote-strip branch first. Matters most for git shas, + # where a corrupted value still looks like a sha. + out.append('%s: "%s"' % (k, v)) else: out.append("%s: %s" % (k, v)) return "\n".join(out) + "\n" diff --git a/bin/ia_graph.py b/bin/ia_graph.py index 7e08df9..b2a8b45 100755 --- a/bin/ia_graph.py +++ b/bin/ia_graph.py @@ -4,7 +4,10 @@ Forward edges only are stored; reverse edges are DERIVED (§9.4) — the graph builder inverts `relates_to` plus the fields the log already carries (`parent`, `plan`, `milestone`, `external`, `supersedes`). Deterministic: -pure function of records + fold; no git or network calls. +pure function of records + fold; no git or network calls. `pr_sync` is the +one exception and is deliberately not on that path — it is an explicit +command that writes a sidecar, so the builder and the renderer keep reading +committed files only. Item metadata stays overlay-only: an item sidecar (docs/.index/item/.yml) holds ONLY what the event log cannot represent @@ -92,10 +95,16 @@ def edge(frm, typ, to): edge(key, "references", tkey) side = item_sidecar(iid) for c in side.get("code") or []: - if isinstance(c, dict) and c.get("pr") is not None: + if not isinstance(c, dict): + continue + if c.get("pr") is not None: pkey = "pr/%s" % c["pr"] nodes[pkey] = {"doc_type": "pr"} edge(key, "lands-in", pkey) + elif c.get("commit"): + ckey = "commit/%s" % c["commit"] + nodes[ckey] = {"doc_type": "commit"} + edge(key, "lands-in", ckey) for e in side.get("relates_to") or []: if isinstance(e, dict) and e.get("type") in REVERSE: edge(key, e["type"], str(e.get("target"))) @@ -160,10 +169,117 @@ def link_pr(ulid_, pr=None, commit=None): return entry +# ------------------------------------------------------------- PR sync + +PR_FIELDS = ("title", "url", "state", "files", "reviewDecision", + "statusCheckRollup", "mergedAt") + +# gh reports one row per check; a page shows one word. Worst state wins — +# a page that says "passing" while a gate is red is worse than no page. +_CHECK_BAD = ("FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", + "ERROR", "STARTUP_FAILURE") +_CHECK_WAIT = ("PENDING", "IN_PROGRESS", "QUEUED", "WAITING", "REQUESTED", + "EXPECTED") + + +def rollup_checks(rows): + """statusCheckRollup rows -> passing|failing|pending|mixed|none.""" + states = set() + for c in rows or []: + if not isinstance(c, dict): + continue + # A finished check run carries `conclusion`; a running one carries + # only `status`. Commit statuses use `state`. + s = (c.get("conclusion") or c.get("state") or c.get("status") or "") + if s: + states.add(s.upper()) + if not states: + return "none" + if states & set(_CHECK_BAD): + return "failing" + if states & set(_CHECK_WAIT): + return "pending" + if states <= {"SUCCESS", "NEUTRAL", "SKIPPED", "COMPLETED"}: + return "passing" + return "mixed" + + +def _gh_pr_view(num): + import subprocess + p = subprocess.run(["gh", "pr", "view", str(num), + "--json", ",".join(PR_FIELDS)], + capture_output=True, text=True) + if p.returncode != 0: + raise ValueError("gh pr view %s failed: %s" + % (num, (p.stderr or "").strip())) + return json.loads(p.stdout or "{}") + + +def pr_sync(num, fetch=None): + """Live PR metadata -> the pr/ sidecar. -> the sidecar dict. + + The network call lives HERE and never in the renderer: `ia-render + --check` regenerates and byte-diffs, so a renderer that reached the + network would flap against mutable remote state. Same split as link_pr: + a command writes a committed file, render reads it off disk. + + Files are flattened to paths — the sidecar's YAML subset inlines lists + on one line, and a comma inside an inline `{...}` would not round-trip. + """ + raw = (fetch or _gh_pr_view)(int(num)) + meta = { + "number": int(num), + "title": raw.get("title") or "", + "url": raw.get("url") or "", + "state": (raw.get("state") or "unknown").lower(), + "review": (raw.get("reviewDecision") or "none").lower(), + "checks": rollup_checks(raw.get("statusCheckRollup")), + "merged_at": raw.get("mergedAt") or None, + "files": sorted(f.get("path") for f in raw.get("files") or [] + if isinstance(f, dict) and f.get("path")), + } + ia.write_sidecar("pr/%s" % int(num), meta) + return meta + + +def pr_meta(num): + """The pr/ sidecar, or {} when pr-sync has never run for it.""" + return ia.read_sidecar("pr/%s" % num) + + +def in_trace_scope(item): + """Is this item evidence for a release? (plan 2026-08-02-trace-check-scope) + + Scope is the released-milestone set: an item with no milestone has not + shipped in anything named, so there is no release for it to be evidence + of. `kind:ops` is exempt outright -- release cuts, status reports, log + compactions and worktree cleanup have no plan, ticket or PR by design, + and the release skill states release items are deliberately never given + an external ticket. + + Until 2026-08-02 this scope was computed as a label, interpolated into + every message, and filtered on nothing, so the gate swept all 267 closed + items instead of the 39 it claimed -- 401 gaps, 323 of them out of scope. + """ + # The second clause looks redundant and is not: CLOSED_STATUSES is + # ("done", "cancelled"), so cancelled work passes the first test and is + # excluded only here. Cancelled work shipped nothing, so it is evidence + # of nothing. Carried verbatim from the pre-2026-08-02 trace_check; kept + # as-is because tests/test_trace_scope.py pins the behaviour either way. + if item.get("status") not in CLOSED_STATUSES or item.get("status") == "cancelled": + return False + return bool(item.get("milestone")) and item.get("kind") != "ops" + + def trace_check(graph=None, items=None, strict=False): """Unlinked-evidence report (§9.6): every item in a released milestone should trace to a plan, a ticket, and a PR; verified-by stays advisory - (a test link is proposed, never assumed). -> list of gaps.""" + (a test link is proposed, never assumed). -> list of gaps. + + `unplanned` items are excused the PLAN check alone: the taxonomy defines + them as arriving without one. They still owe a ticket and a PR -- being + discovered mid-flight excuses the plan, not the evidence. + """ if items is None: items = fold((".work/todo.jsonl", ".work/done.jsonl")).items graph = graph or build_graph(items=items) @@ -175,17 +291,16 @@ def trace_check(graph=None, items=None, strict=False): gaps = [] for iid in sorted(items): it = items[iid] - if it.get("status") not in CLOSED_STATUSES or it.get("status") == "cancelled": + if not in_trace_scope(it): continue - key = item_key(iid) - have = out_edges.get(key, set()) - scope = "released" if it.get("milestone") else "closed" - if "produced-by" not in have and not it.get("plan"): - gaps.append("%s (%s): no plan link" % (iid, scope)) + have = out_edges.get(item_key(iid), set()) + if ("produced-by" not in have and not it.get("plan") + and not it.get("unplanned")): + gaps.append("%s: no plan link" % iid) if "references" not in have: - gaps.append("%s (%s): no external ticket" % (iid, scope)) + gaps.append("%s: no external ticket" % iid) if strict and "lands-in" not in have: - gaps.append("%s (%s): no PR/commit link" % (iid, scope)) + gaps.append("%s: no PR/commit link" % iid) return gaps @@ -300,3 +415,134 @@ def seed_edges(records=None): fh.write(json.dumps(rec, separators=(",", ":"), sort_keys=True) + "\n") return proposed + + +# --- read-only search over the generated model (#272) --------------------- +# +# There was no way to ask the content model a question from the command line. +# Everything an answer needs is already generated and on disk -- every +# document with its type and truth state in the inventory, every typed edge +# between documents, items, tickets and PRs in the graph. So this is a reader, +# not an index: no network call, no new store, nothing written. + +def load_graph(): + """The generated graph, or a clear instruction when it isn't there yet.""" + try: + with open(GRAPH, encoding="utf-8") as fh: + return json.load(fh) + except FileNotFoundError: + raise SystemExit("worklog find: no graph yet — run: worklog ia-graph") + except json.JSONDecodeError as e: + raise SystemExit(f"worklog find: {GRAPH} is unreadable ({e}); " + "regenerate with: worklog ia-graph") + + +def search_nodes(graph, query=None, doc_type=None, truth=None): + """Nodes matching a case-insensitive substring over key, title and source. + + Substring rather than tokens or fuzzy matching: the keys are structured + (`adr/0005-...`, `item/01KY...`), so a plain substring already answers + "show me the ADRs" and "show me this item" without a query language to + learn or maintain. + """ + q = (query or "").lower() + out = [] + for key, node in graph.get("nodes", {}).items(): + if doc_type and node.get("doc_type") != doc_type: + continue + if truth and node.get("truth_state") != truth: + continue + if q and q not in " ".join( + str(node.get(f) or "") for f in ("title", "source")).lower() \ + and q not in key.lower(): + continue + out.append((key, node)) + return sorted(out) + + +def resolve_node(graph, key): + """Exact key, else unique substring match. Ambiguity is an error, never a + silent pick -- the same rule `worklog show` uses for item prefixes.""" + nodes = graph.get("nodes", {}) + if key in nodes: + return key + hits = sorted(k for k in nodes if key.lower() in k.lower()) + if not hits: + raise SystemExit(f"worklog find: no node matching {key!r}") + if len(hits) > 1: + raise SystemExit("worklog find: %r is ambiguous — matches %d nodes:\n %s" + % (key, len(hits), "\n ".join(hits[:10]))) + return hits[0] + + +def node_links(graph, key): + """(outbound, inbound) edges for one node, each [(type, other_key)]. + + Both directions, because the questions this exists to answer run each way: + "which plan decided this?" is inbound, "what did this supersede?" is + outbound. + """ + fwd, back = build_adjacency(graph) + return sorted(fwd.get(key, [])), sorted(back.get(key, [])) + + +def edges_of_type(graph, edge_type): + known = sorted(REVERSE) + if edge_type not in known: + raise SystemExit("worklog find: unknown edge type %r; known: %s" + % (edge_type, ", ".join(known))) + return sorted((e["from"], e["to"]) for e in graph.get("edges", []) + if e["type"] == edge_type) + + +def _label(graph, key): + node = graph.get("nodes", {}).get(key) or {} + title = node.get("title") + return f"{key} — {title}" if title else key + + +def find(query=None, doc_type=None, truth=None, links=None, edge=None, + as_json=False): + """The `worklog find` command. Returns an exit code.""" + graph = load_graph() + + if links: + key = resolve_node(graph, links) + out, back = node_links(graph, key) + if as_json: + print(json.dumps({"node": key, "outbound": out, "inbound": back}, + indent=2, sort_keys=True)) + return 0 + print(_label(graph, key)) + if not out and not back: + print(" (no edges)") + for typ, other in out: + print(f" -> {typ}: {_label(graph, other)}") + for typ, other in back: + print(f" <- {typ}: {_label(graph, other)}") + return 0 + + if edge: + pairs = edges_of_type(graph, edge) + if as_json: + print(json.dumps([{"from": a, "to": b} for a, b in pairs], + indent=2, sort_keys=True)) + return 0 + for a, b in pairs: + print(f"{a} --{edge}-> {b}") + print(f"\n{len(pairs)} {edge} edge(s)") + return 0 if pairs else 1 + + hits = search_nodes(graph, query, doc_type, truth) + if as_json: + print(json.dumps([dict(key=k, **n) for k, n in hits], + indent=2, sort_keys=True)) + return 0 + for key, node in hits: + bits = [node.get("doc_type") or "?"] + if node.get("truth_state"): + bits.append(node["truth_state"]) + print("%-14s %s" % ("[" + "/".join(bits) + "]", _label(graph, key))) + print(f"\n{len(hits)} node(s)") + # Non-zero on no matches so a script can branch on it, the way grep does. + return 0 if hits else 1 diff --git a/bin/ia_render.py b/bin/ia_render.py index 6aaaeea..083148b 100755 --- a/bin/ia_render.py +++ b/bin/ia_render.py @@ -8,9 +8,18 @@ The manifest closes the legacy-banner gap: a frozen page's `source_hash` never changes, so the ledger's hash-skip would keep already-published pages banner-less forever. Each manifest page therefore carries a `render_hash` -(source bytes + banner + renderer); wiki-publish republishes when the +(source BODY + banner + renderer); wiki-publish republishes when the ledger's `render_hash` differs. Frozen still means the SOURCE never changes — only the rendered overlay may. + +Body, not whole file, and that word is load-bearing. Publish strips front +matter for Gollum wikis (wiki-publish §3), so a front-matter-only edit — +the normalizer stamping `wiki_key`, `adr.mark_superseded`, a provenance +backfill — produces byte-identical published output. Hashing the file made +all three look like content edits: they moved `render_hash`, republished +pages whose text had not changed, and tripped the publisher's frozen-source +guard. Hashing the body makes that guard mean "the prose changed", which is +the invariant §15.8/§15.9 actually protects. """ import hashlib import json @@ -21,8 +30,28 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import ia import ia_graph +import render_roadmap +import wiki_flavor from fold import fold, CLOSED_STATUSES +# The platform seam (#271). Prose below writes links in the canonical +# [[Page]] notation; the flavor translates every page once, at the output +# boundary (render_all and banner), so a second wiki engine is a new class in +# wiki_flavor.FLAVORS rather than an edit to forty link sites here. +FLAVOR = wiki_flavor.get() + + +def use_flavor(system=None): + """Swap the render flavor. Tests use it; a repo picks one via + wiki.system in .work/config.yml.""" + global FLAVOR + FLAVOR = wiki_flavor.get(system) + return FLAVOR + + +def _links(text): + return wiki_flavor.render_links(text, FLAVOR) + RENDERED = os.path.join(ia.INDEX_DIR, "rendered") MANIFEST = os.path.join(ia.INDEX_DIR, "publish-manifest.json") ALIASES = os.path.join(ia.INDEX_DIR, "aliases.json") @@ -59,7 +88,7 @@ def page_name(rec): base = ("Design-Doc" if "design_doc" in stem else "Code-Walkthrough") m = re.match(r"(\d{4}-\d{2}-\d{2}_.+?)_(?:design_doc|code_walkthrough)$", stem) return base + ("-" + m.group(1) if m else "") - return rec.get("title", stem).replace(" ", "-") + return FLAVOR.sanitize(rec.get("title", stem)) def item_page_name(iid): @@ -79,14 +108,61 @@ def pr_page_name(pr_num): def banner(rec, by_key): - """Reader-visible truth banner (§6.1), one blockquote line.""" + """Reader-visible truth banner (§6.1), one blockquote line. + + Wraps _banner_text so banner links get flavor-translated too: banners + reach the reader through the MANIFEST, not through `rendered`, so the + boundary in render_all() never sees them.""" + return _links(_banner_text(rec, by_key)) + + +PLAN_STATE = {"completed": "completed plan", + "active": "plan in flight", + "planned": "plan not yet started"} + + +def _plan_state(rec): + """A plan's banner label, from the leading word of its status (#292). + + `status` on a plan is free prose, not an enum. Real values run from + "completed" to "planned — not yet scheduled; implementation tasks attach + to the epic when work starts" — a whole sentence that cannot be dropped + into a one-line banner. Only the leading word carries the state; the rest + is detail that belongs in the page. + + Unknown prose returns None and the caller falls back to saying nothing + about state. That is deliberate: inventing a label for prose we cannot + read is how the banner came to announce plans as status reports in the + first place. Silence beats a confident guess. + """ + first = (rec.get("status") or "").split()[:1] + return PLAN_STATE.get(first[0].lower()) if first else None + + +def _banner_text(rec, by_key): ts = rec["truth_state"] if ts == "current" and ia.is_frozen(rec): - # e.g. the newest status report: current truth, but frozen — it will - # be archived by its successor, never regenerated - return ("> **Current** — the latest %s report. Reports freeze once " - "published; corrections appear in later reports." - % rec.get("kind", "status")) + # is_frozen() covers plan/roadmap-snapshot/status/dated-design (#137) + # — only status is a "report"; the rest need their own wording. + if rec["doc_type"] == "status": + # e.g. the newest status report: current truth, but frozen — it + # will be archived by its successor, never regenerated. + # rec["kind"], not .get(..., "status"): kind is REQUIRED on a + # status record (ia.py schema), so a missing one is a schema + # violation and must raise. The old default silently rendered + # plausible prose over broken data (#292). + return ("> **Current** — the latest %s report. Reports freeze " + "once published; corrections appear in later reports." + % rec["kind"]) + if rec["doc_type"] == "plan": + return ("> **Current** — %s; plans are frozen " + "once written, a changed design gets a new plan." + % (_plan_state(rec) or "the current plan")) + if rec["doc_type"] == "roadmap-snapshot": + return ("> **Current** — the current roadmap snapshot; frozen " + "once published, a new snapshot supersedes it.") + return ("> **Current** — the current design record; frozen once " + "written, an updated design gets a new dated copy.") if ts == "current": gen = rec.get("generated_at") src = " regenerated at %s" % gen if gen else "" @@ -498,16 +574,35 @@ def render_release_page(tag, items, fwd, back): def render_pr_page(pr_num, items, fwd, back): """A PR page (§ artifact-pages plan): linked tickets + related release - from existing graph edges; no live GitHub data exists anywhere in this - repo today (no `gh pr view` integration), so files-changed/review/CI - status render as 'not tracked' pending a separately-scoped follow-up.""" + from existing graph edges, plus live state/files/review from the + `pr/` sidecar that `worklog pr-sync` writes. The sidecar is a + committed file, so this stays a pure function of the working tree; a PR + that has never been synced degrades to the original 'not tracked'.""" pr_key = "pr/%s" % pr_num + meta = ia.read_sidecar(pr_key) linked = sorted(frm.split("/", 1)[1] for typ, frm in back.get(pr_key, []) if typ == "delivers") - lines = ["# PR #%s" % pr_num, "", - "`%s` · status: **not tracked**" % pr_key, "", - "Changed files: not tracked. Test/Review status: not tracked " - "(see the deferred PR live-metadata sync item).", ""] + title = meta.get("title") + lines = ["# PR #%s%s" % (pr_num, " — %s" % title if title else ""), ""] + if meta: + lines += ["`%s` · status: **%s**" % (pr_key, meta.get("state", "?")), + "", + "- Review: %s" % meta.get("review", "none"), + "- Checks: %s" % meta.get("checks", "none")] + if meta.get("merged_at"): + lines.append("- Merged: %s" % meta["merged_at"]) + if meta.get("url"): + lines.append("- Source: %s" % meta["url"]) + lines += [""] + files = [f for f in meta.get("files") or [] if f] + lines += ["## Changed Files", ""] + lines += ["- `%s`" % f for f in files] if files else \ + ["_No files recorded._"] + lines += [""] + else: + lines += ["`%s` · status: **not tracked**" % pr_key, "", + "Changed files: not tracked. Test/Review status: not " + "tracked — run `worklog pr-sync %s`." % pr_num, ""] if linked: lines += ["## Linked Tickets", ""] @@ -553,6 +648,19 @@ def _file_hash(path): return _hash_bytes(fh.read()) +def _body_hash(path): + """Hash of the doc BELOW its front matter — what a reader actually gets. + + Publish strips front matter, so two files differing only there publish + identically. Hashing the body is therefore both cheaper (no needless + republish) and stricter in the way that matters: a change to this hash + means the prose changed, which is the only thing the frozen-doc rule + was ever protecting. + """ + with open(path, encoding="utf-8") as fh: + return _hash_bytes(ia.parse_front_matter(fh.read())[1].encode()) + + def build_manifest(records, rendered, items=None): """The intended publish set (§10.2): every rendered page + every doc the default set publishes, each with its banner and render_hash. Also one @@ -581,7 +689,10 @@ def build_manifest(records, rendered, items=None): elif fname.startswith("prs/"): num = fname[len("prs/"):-3] wiki_key, pname = "pr/" + num, pr_page_name(num) - title, ts = "PR #%s" % num, "not tracked" + meta = ia.read_sidecar(wiki_key) + title = "PR #%s%s" % (num, " — %s" % meta["title"] + if meta.get("title") else "") + ts = meta.get("state") or "not tracked" else: continue pages.append({"wiki_key": wiki_key, @@ -595,17 +706,38 @@ def build_manifest(records, rendered, items=None): continue # the home SOURCE is the intro; the PAGE is rendered b = banner(rec, records) frozen = ia.is_frozen(rec) + body = _body_hash(rec["source"]) pages.append({ "wiki_key": key, "source": rec["source"], "title": rec.get("title", key), "page_name": page_name(rec), "truth_state": rec["truth_state"], "banner": b, + # source_hash is the FROZEN-DOC GUARD's input: the publisher + # compares it against the ledger and stops when a frozen doc's + # prose changed. Carried here so the publisher never has to hash + # files itself — and so it cannot accidentally hash the wrong + # thing and mistake a metadata stamp for an edit. + "source_hash": body, "render": "doc+banner", "frozen": frozen, - "render_hash": _hash_bytes( - (_file_hash(rec["source"]) + b).encode())}) - return {"version": 1, "pages": pages, - "sidebar": {"source": "%s/_Sidebar.md" % RENDERED, - "render_hash": _hash_bytes( - rendered["_Sidebar.md"].encode())}} + "render_hash": _hash_bytes((body + b).encode())}) + out = {"version": 1, "pages": pages, + "sidebar": {"source": "%s/_Sidebar.md" % RENDERED, + "render_hash": _hash_bytes( + rendered["_Sidebar.md"].encode())}} + # Build provenance, recorded ONCE here rather than on every rendered + # page. Each page under docs/.index/rendered/ is a projection of the + # whole log by one build, so "the commit" is a property of the build, + # not of any page. Stamping all ~344 of them would be 344 copies of one + # fact, would move every render_hash at once, and would be invisible to + # every reader anyway — publish strips front matter (wiki-publish §3). + # + # From the newest event's `git` field, never `git rev-parse`: write_all + # regenerates and byte-compares this file, so a HEAD-derived value would + # differ from the committed one on the very next run. Omitted when the + # log carries no sha; never written empty. + top = render_roadmap.top_event(render_roadmap.PATHS) + if top and top.get("git"): + out["git_hash"] = top["git"] + return out def build_aliases(records): @@ -641,6 +773,10 @@ def render_all(): num = key.split("/", 1)[1] rendered["prs/%s.md" % num] = render_pr_page( num, fr.items, fwd, back) + # The link boundary: translate canonical [[Page]] notation into the + # configured wiki's syntax BEFORE the manifest hashes the bytes, so + # render_hash always describes what actually gets published. + rendered = {name: _links(text) for name, text in rendered.items()} manifest = build_manifest(records, rendered, fr.items) aliases = build_aliases(records) return rendered, manifest, aliases, graph diff --git a/bin/item_fields.py b/bin/item_fields.py new file mode 100755 index 0000000..881e597 --- /dev/null +++ b/bin/item_fields.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +item_fields.py -- the configurable optional-field model. Item #108. + +The item model had a fixed shape. A lightweight team carried fields it never +filled in; a heavyweight one had nowhere to put risk, owner, or acceptance +criteria without inventing conventions in the body text. The ask was one log +that both processes can share, with the difference expressed in config. + +Two populations, and the split is the whole design: + + CORE -- id, title, status, level, kind, priority, milestone, labels, parent, + body, plan, depends_on, unplanned/discovered_during, external, resolution. + Not configurable, ever. These are load-bearing: the fold keys on them, the + roadmap renderer reads them, sync maps them onto tickets, and the + traceability index walks them. A config that could switch `priority` off + would be a config that can break the roadmap, so the "small stable core" + principle from the ticket is enforced by not offering the knob. + + OPTIONAL -- everything in CATALOG below. Enabled per team. A disabled field + is not merely rejected: its flag never gets built, so it cannot appear in + the CLI, in --help, or in anything an agent reads to decide what to write. + That is what "never appear in prompts, forms, or validation" has to mean + for a CLI whose --help IS the prompt. + +Every field carries a `description`. It is not decoration: agents read +`worklog fields` to learn what a field means before writing it, and a field +whose meaning is guessable from its name alone still gets guessed differently +by two people. The descriptions ship as defaults so a team does not have to +author them to switch a field on. + +Defaults follow the ticket's recommendation. Of its default-on list -- +priority, estimate, risk, owner, milestone, acceptance_criteria, labels -- +priority, milestone and labels were already core here and stay there, so the +catalog carries the rest. Default-off is conservative on purpose: an +unfilled field is worse than a missing one, because it looks like an answer. +""" +import os +import re + +CONFIG = ".work/config.yml" + +# Never configurable. Listed so the CLI can refuse to shadow one. +CORE = ("id", "title", "status", "level", "kind", "priority", "milestone", + "labels", "parent", "body", "plan", "depends_on", "unplanned", + "discovered_during", "external", "resolution") + +# name -> (default_enabled, choices or None, description) +CATALOG = { + "estimate": ( + True, ("XS", "S", "M", "L", "XL"), + "Relative size, not time. Compare items; never sum into a schedule."), + "owner": ( + True, None, + "Who is accountable for the item moving -- one name, not a team. " + "Not the same as who does the work."), + "risk": ( + True, ("low", "medium", "high"), + "Chance this item goes badly or blocks others. Drives what gets " + "planned first, not what gets estimated bigger."), + "acceptance_criteria": ( + True, None, + "What must be observably true to close this. Written before the " + "work, checked at close; if it cannot be observed, it is not a " + "criterion."), + "value": ( + False, None, + "Expected benefit if delivered. Only meaningful when something " + "actually ranks by it -- off by default so it does not become a " + "field everyone fills with 'high'."), + "confidence": ( + False, ("low", "medium", "high"), + "How much to trust this item's own estimate and value. Pairs with " + "them; meaningless alone."), + "due_date": ( + False, None, + "External hard date (YYYY-MM-DD) -- a conference, an audit, a " + "contract. Not a wish, and not a substitute for a milestone."), + "severity": ( + False, ("sev1", "sev2", "sev3", "sev4"), + "For bugs: how bad the impact is in production, independent of " + "priority. Off unless the team runs an incident process."), +} + +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _config_block(path=CONFIG, block="work_item_fields"): + """`name: value` pairs under one top-level block of .work/config.yml. + + A targeted read, not a YAML dependency: this is one flat block of + scalars, and the installer already reads config.yml the same way with + awk. Anything malformed reads as "nothing configured", which falls back + to the defaults rather than failing every command. + """ + out = {} + try: + with open(path, encoding="utf-8") as fh: + text = fh.read() + except OSError: + return out + inside = False + for raw in text.splitlines(): + line = raw.split("#", 1)[0].rstrip() + if not line.strip(): + continue + if not line[0].isspace(): + inside = line.strip().startswith(block + ":") + continue + if inside: + m = re.match(r"\s+([A-Za-z_][\w]*)\s*:\s*(\S+)", line) + if m: + out[m.group(1)] = m.group(2).strip("\"'").lower() + return out + + +TRUE = ("true", "yes", "on", "1") +FALSE = ("false", "no", "off", "0") + + +def enabled(path=CONFIG): + """{name: (choices, description)} for every optional field switched on.""" + cfg = _config_block(path) + out = {} + for name, (default, choices, desc) in CATALOG.items(): + raw = cfg.get(name) + on = default if raw is None else (raw in TRUE) + if raw is not None and raw not in TRUE + FALSE: + on = default # unreadable value -> the documented default + if on: + out[name] = (choices, desc) + return out + + +def is_enabled(name, path=CONFIG): + return name in enabled(path) + + +def flag(name): + """--acceptance-criteria for acceptance_criteria.""" + return "--" + name.replace("_", "-") + + +def dest(name): + return name + + +def validate(name, value): + """-> error string, or None. Choices are enforced by argparse; this is + for shapes argparse cannot express.""" + if value is None: + return None + if name == "due_date" and not DATE_RE.match(value): + return "--due-date wants YYYY-MM-DD, got %r" % value + return None + + +def add_arguments(parser, path=CONFIG): + """Build a flag per ENABLED optional field. + + Disabled fields get no flag at all, so `worklog add --risk high` in a repo + with risk off is an argparse error naming an unrecognised option -- the + field is invisible rather than rejected, which is what the ticket asked + for. + """ + for name, (choices, desc) in sorted(enabled(path).items()): + kwargs = {"help": desc} + if choices: + kwargs["choices"] = list(choices) + parser.add_argument(flag(name), dest=dest(name), **kwargs) + + +def collect(args, path=CONFIG): + """{field: value} for enabled fields present on `args`. Exits on a bad + shape, so a malformed value never reaches the log.""" + out = {} + for name in enabled(path): + value = getattr(args, dest(name), None) + if value is None: + continue + err = validate(name, value) + if err: + raise SystemExit("worklog: " + err) + out[name] = value + return out + + +def describe(path=CONFIG): + """Lines for `worklog fields` -- what an agent reads before writing.""" + on = enabled(path) + lines = ["core (always on, not configurable):", + " " + ", ".join(CORE), "", "optional:"] + for name in sorted(CATALOG): + choices, desc = CATALOG[name][1], CATALOG[name][2] + mark = "on " if name in on else "off" + vals = " {%s}" % "|".join(choices) if choices else "" + lines.append(" [%s] %s%s" % (mark, flag(name), vals)) + lines.append(" " + desc) + lines += ["", "Switch a field on or off in %s:" % CONFIG, + " work_item_fields:", " risk: off"] + return lines diff --git a/bin/plan_capture.py b/bin/plan_capture.py index a9b510e..8b1b862 100755 --- a/bin/plan_capture.py +++ b/bin/plan_capture.py @@ -43,7 +43,38 @@ def parse_tasks(draft): return tasks -def front_matter(date, slug, title, epic_id, item_ids): +# `#123` not preceded by a word char or a slash, so `C#` and a URL path do +# not read as tickets. Trailing \b keeps `#12abc` out. +TICKET_REF_RE = re.compile(r"(? list of refs. + + A title written before capture cannot know its own ticket number: the + tracker mints that during the next sync. So a `#123` in a title is either + a reference to some OTHER ticket — fine, but easy to misread as this + item's own — or a guess that will be wrong. The one that actually bites is + an item created and closed inside a single session: sync never files an + issue for it, so the number cited in the commit message points at an + unrelated ticket, or at nothing. + + Advisory only. Legitimate titles do cite ticket numbers ("supersede the + workaround from #98"), so this must never refuse a write — it names what + it saw and lets the author decide. + """ + return TICKET_REF_RE.findall(text or "") + + +def front_matter(date, slug, title, epic_id, item_ids, git_hash=None): + """The plan's frozen header. + + `git_hash` is passed in rather than looked up so this stays a pure + function of its arguments — the caller owns the subprocess. Omitted when + falsy (no git, or WORKLOG_NO_GIT_PROVENANCE), never written empty: an + empty value opens a block list in ia.parse_front_matter and swallows the + closing fence. Quoted so an all-digit sha survives the round trip. + """ return "\n".join([ "---", f"date: {date}", @@ -51,6 +82,7 @@ def front_matter(date, slug, title, epic_id, item_ids): f"title: {title}", f"epic: {epic_id}", "items: [" + ", ".join(item_ids) + "]", + ] + ([f'git_hash: "{git_hash}"'] if git_hash else []) + [ "---", "", # blank line between front matter and the draft body "", diff --git a/bin/provenance.py b/bin/provenance.py new file mode 100755 index 0000000..cf62523 --- /dev/null +++ b/bin/provenance.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""provenance.py -- backfill `merged_in` onto documents once they land. + +A document is stamped with `git_hash` when it is written: the commit its +claims were read against. It cannot know the merge that will bring it to the +default branch, because that merge does not exist yet. This module fills that +in afterwards. + +Why a separate module rather than a function in ia.py: `ia.py`'s docstring +promises "no wall clock, no environment", and `ia_render.py`'s promises no git +commands at all. Both promises are load-bearing -- the freshness gates +regenerate and byte-compare whatever those modules produce. Every git call in +the provenance story lives here, off that path, and runs only when a human or +the release routine asks for it. + +WORKLOG-SPEC section 9.6 / plan 2026-08-03-doc-provenance-and-verification. +""" +import os +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import ia # noqa: E402 + + +def _git(*args): + """-> stdout stripped, or None when git fails or is absent.""" + try: + p = subprocess.run(["git", *args], capture_output=True, text=True) + except OSError: + return None + return p.stdout.strip() if p.returncode == 0 else None + + +def default_branch(): + """The ref merges land on. `origin/main` when it exists so the answer is + stable on a branch that is behind; local `main` in a fresh clone with no + remote (the scaffolded-repo case).""" + for ref in ("origin/main", "origin/master", "main", "master"): + if _git("rev-parse", "--verify", "--quiet", ref): + return ref + return None + + +def added_in(path): + """The commit that first added `path`, or None.""" + return _git("log", "--diff-filter=A", "--format=%H", "-1", "--", path) + + +def first_parent_chain(branch): + """The default branch's first-parent history, newest first. + + One call per run, not per document — the chain is the same for all of + them and this is the expensive part. + """ + return (_git("rev-list", "--first-parent", branch) or "").split() + + +def merged_in(path, branch=None, chain=None): + """The commit on the default branch that first contained `path`. + + Defined as the OLDEST commit on the branch's first-parent chain that has + the file's add-commit as an ancestor. For a PR-merge workflow that is the + "Merge pull request #N" commit, which is the answer people want. + + The two obvious one-liners are both wrong, and quietly: + + rev-list --ancestry-path --merges A..main | tail -1 + returns the earliest merge on the path — which is frequently a + merge of main INTO the feature branch, not the merge that landed + it. Verified wrong on this repo's own history. + + rev-list --ancestry-path --first-parent A..main + returns nothing at all when the branch landed as a merge's SECOND + parent, which is every PR merge. + + None when the document has not landed yet, when git cannot answer, or + when history was squashed (see docs/adr — provenance assumes merge + commits). None is a real answer and is recorded as "absent", never + guessed: a wrong merge sha is worse than none, because it looks like + evidence. + """ + branch = branch or default_branch() + add = added_in(path) + if not (branch and add): + return None + if chain is None: + chain = first_parent_chain(branch) + ancestry = set((_git("rev-list", "--ancestry-path", + "%s..%s" % (add, branch)) or "").split()) + ancestry.add(add) # committed straight to the branch: it landed as itself + for commit in reversed(chain): # oldest first + if commit in ancestry: + return commit + return None # not on the default branch yet + + +def backfill(records=None, check=False): + """Stamp `merged_in` on every landed FROZEN doc that lacks one. + + Frozen only, and that is the whole judgement in this function. A frozen + document — a plan, a roadmap snapshot, a status report, a dated design + pair — is written once, so "the commit that landed it" is exact and + stays true forever. A live document (the roadmap, a guide, an ADR whose + status flips, the current design pair) has been edited many times since + it was added, so stamping it with the merge that FIRST carried it would + be a true fact that reads as a lie: it names a version of the file that + no longer exists. + + -> list of (wiki_key, path, sha) changed, or that would change in check + mode. Idempotent for free: ensure_front_matter_fields returns [] when + the value already matches, so a second run writes nothing. + """ + if records is None: + records = ia.build_records() + branch = default_branch() + if not branch: + return [] + chain = first_parent_chain(branch) + changed = [] + for key in sorted(records): + rec = records[key] + src = rec.get("source") + if not src or not os.path.exists(src): + continue + if rec.get("merged_in") or not ia.is_frozen(rec): + continue + sha = merged_in(src, branch, chain) + if not sha: + continue # not landed yet, or history cannot answer + changed.append((key, src, sha)) + if not check: + # Quoted for the same reason every other writer quotes: an + # all-digit sha would otherwise be read back as an int. + ia.ensure_front_matter_fields(src, {"merged_in": sha}) + return changed + + +def report(changed, check=False): + verb = "would stamp" if check else "stamped" + for key, src, sha in changed: + print("%s %s %s %s" % (verb, key, sha[:12], src)) + if not changed: + print("provenance: nothing to backfill") + else: + print("provenance: %s %d document(s)" % (verb, len(changed))) + if not check: + print("run `worklog ia-index` and commit the result together " + "with these documents") + return changed + + +if __name__ == "__main__": + report(backfill(check="--check" in sys.argv), check="--check" in sys.argv) diff --git a/bin/render_roadmap.py b/bin/render_roadmap.py index c03d9ee..dd8b52f 100755 --- a/bin/render_roadmap.py +++ b/bin/render_roadmap.py @@ -17,7 +17,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import ulid -from fold import fold, CLOSED_STATUSES +from fold import fold, CLOSED_STATUSES, OPEN_STATUSES PATHS = (".work/todo.jsonl", ".work/done.jsonl") KINDS = ("feature", "bug", "ops", "triage") # fixed order: deterministic mix @@ -25,8 +25,13 @@ # someone actually changes paths: (stdlib has no yaml). -def max_ev(paths): - """Newest event ULID across the input files; None on an empty log.""" +def top_event(paths): + """Newest event across the input files; None on an empty log. + + Returns the whole event, not just its id, because the roadmap needs two + facts from it: the ULID for `generated_at` and the `git` field for + `git_hash`. Both must come from committed bytes — see render(). + """ top = None for path in paths: try: @@ -38,14 +43,21 @@ def max_ev(paths): if not line.strip(): continue try: - e = json.loads(line).get("ev") + ev = json.loads(line) + e = ev.get("ev") except (json.JSONDecodeError, AttributeError): continue - if isinstance(e, str) and (top is None or e > top): - top = e + if isinstance(e, str) and (top is None or e > top.get("ev")): + top = ev return top +def max_ev(paths): + """Newest event ULID across the input files; None on an empty log.""" + top = top_event(paths) + return top.get("ev") if top else None + + def root_epic_id(item_id, items): seen = set() cur = items.get(item_id) @@ -128,10 +140,21 @@ def render(paths=PATHS, viz="deps,hierarchy"): src = json.dumps(sorted(items.values(), key=lambda i: i["id"]), sort_keys=True, separators=(",", ":")) source_hash = hashlib.sha256(src.encode()).hexdigest()[:8] - top = max_ev(paths) + top = top_event(paths) gen = (time.strftime("%Y-%m-%dT%H:%M:%SZ", - time.gmtime(ulid.timestamp_ms(top) / 1000)) + time.gmtime(ulid.timestamp_ms(top["ev"]) / 1000)) if top else "never") + # The commit the DATA came from, read off the newest event (every event + # has carried `git` since 0.19.1). Never `git rev-parse` here: pre-commit + # regenerates this file and diffs it, so a value derived from HEAD would + # be the parent commit on the run that writes it and the current one on + # the next run -- failing every commit thereafter. On pull_request CI it + # is worse: the checkout is a synthetic refs/pull/N/merge sha that exists + # in no local clone, so no stored value could ever match. Omitted when + # absent (pre-0.19.1 logs, or no git), never written empty -- an empty + # value opens a block list in ia.parse_front_matter and swallows the + # lines after it. + commit = (top or {}).get("git") open_non_epic = [i for i in r.open_items() if i.get("level") != "epic"] blocked = [i for i in open_non_epic @@ -151,6 +174,12 @@ def render(paths=PATHS, viz="deps,hierarchy"): "truth_state: current", f"source_hash: {source_hash}", f"generated_at: {gen}", + # Quoted, and that is not cosmetic. Events carry the SHORT sha, and + # ia._scalar coerces an all-digit value to int before it considers + # quotes -- so roughly one short sha in 27 would be read back as an + # integer, and one with a leading zero would come back corrupted but + # still sha-shaped. Quoting reaches the quote-strip branch first. + ] + ([f'git_hash: "{commit}"'] if commit else []) + [ "---", "", "", @@ -228,6 +257,16 @@ def render(paths=PATHS, viz="deps,hierarchy"): f"--take local|remote`.") for iid in sorted(set(r.orphans)): attention.append(f"- Orphan events for `{iid[:8]}` — no create/snapshot yet.") + # Bug 222: an open epic whose children are all closed (or has none) never + # lands in Now/Next/Later -- those buckets are built from open children, + # not epics. Surface it here instead so it doesn't rot invisibly. + stale_epics = sorted( + (i for i in items.values() if i.get("level") == "epic" + and i.get("status") in OPEN_STATUSES + and not any(m.get("status") in OPEN_STATUSES for m in epic_members(i["id"], items))), + key=lambda i: i["id"]) + for e in stale_epics: + attention.append(f"- **{ref(e)}** {e.get('title', '')} — no open children left; close the epic.") if attention: lines += ["", "## Needs attention", ""] + attention diff --git a/bin/session.py b/bin/session.py new file mode 100755 index 0000000..8a51f81 --- /dev/null +++ b/bin/session.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +session.py -- advisory registry of assistant sessions sharing this worklog. + +Item #236: two sessions ran in one working directory, one switched branches +out from under the other mid-operation, and both independently "fixed" the +same problem in different ways. Nothing warned. + +There is no process identity to key on. `worklog` is a short-lived CLI, so +every invocation has a fresh pid -- and ppid and POSIX session id turn over +too, because each tool call gets its own shell. The HARNESS is the only thing +that knows a session is one session, and it hands that id to its hooks. So +the hook heartbeats here and the CLI reads the result. + +The CLI deliberately never learns which session it is. "Two live heartbeats +in this directory" is the whole condition worth warning about, and it needs +no self-knowledge to evaluate. + +Advisory only, in both directions: nothing here ever blocks a write, and a +registry that is missing, stale or corrupt is silently treated as empty. A +bad advisory file must never be the reason someone cannot record work. +""" +import json +import os +import subprocess +import time + +REGISTRY = ".work/.sessions" +# How long a session counts as live without a heartbeat. The UserPromptSubmit +# hook fires every turn, so a live session refreshes constantly; this only has +# to outlast one long-running turn. SessionEnd prunes the common case, so the +# window is the backstop for a session that died without one. +WINDOW = 3600 + + +def _read(path): + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} # missing or corrupt: no opinion, never an error + + +def _write(path, data): + """Atomic, and best-effort: losing an advisory heartbeat is not a failure.""" + try: + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + tmp = f"{path}.{os.getpid()}" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh, sort_keys=True) + os.replace(tmp, path) + except OSError: + pass + + +def branch(): + try: + p = subprocess.run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], + capture_output=True, text=True) + return p.stdout.strip() or None + except OSError: + return None + + +def live(path=REGISTRY, window=WINDOW, now=None): + """Sessions that have heartbeated within `window` seconds.""" + now = time.time() if now is None else now + return {sid: rec for sid, rec in _read(path).items() + if isinstance(rec, dict) and now - rec.get("ts", 0) <= window} + + +def touch(session_id, path=REGISTRY, window=WINDOW, now=None, branch_name=None): + """Record this session as alive. Prunes anything past the window so the + file cannot grow without bound.""" + if not session_id: + return + now = time.time() if now is None else now + data = live(path, window, now) + data[session_id] = {"ts": now, + "branch": branch_name if branch_name is not None else branch()} + _write(path, data) + + +def end(session_id, path=REGISTRY): + """Drop a session on its way out, so a finished session stops warning the + next one. Without this the registry cries wolf for a full window.""" + if not session_id: + return + data = _read(path) + if data.pop(session_id, None) is not None: + _write(path, data) + + +def warning(path=REGISTRY, window=WINDOW, now=None): + """One line naming the risk, or None when this directory has one session. + + Deliberately does NOT try to name which session is 'the other one' -- the + caller may be any of them, or a hook, or the nightly job. + """ + sessions = live(path, window, now) + if len(sessions) < 2: + return None + branches = sorted({rec.get("branch") or "?" for rec in sessions.values()}) + where = f" (branches: {', '.join(branches)})" if len(branches) > 1 else "" + return (f"{len(sessions)} assistant sessions are active in this working " + f"directory{where}. They share one checkout, so one can switch " + f"branches under the other mid-operation and both can 'fix' the " + f"same thing differently. Give each session its own git worktree.") + + +def main(argv): + cmd = argv[1] if len(argv) > 1 else "" + if cmd == "touch": + touch(argv[2] if len(argv) > 2 else "") + elif cmd == "end": + end(argv[2] if len(argv) > 2 else "") + elif cmd == "warn": + w = warning() + if w: + print(w) + else: + print("usage: session.py touch|end|warn [session-id]") + return 2 + return 0 + + +if __name__ == "__main__": + import sys + sys.exit(main(sys.argv)) diff --git a/bin/sync_dispatch.py b/bin/sync_dispatch.py index 71c84cd..315cc83 100755 --- a/bin/sync_dispatch.py +++ b/bin/sync_dispatch.py @@ -30,6 +30,8 @@ CLOSED_STATUSES = ("done", "cancelled") LOCAL_ONLY = ("worklog sync: no adapter configured — local-only " "(set WORKLOG_TICKET_ADAPTER or run worklog adapter check)") +LOG_PATHS = (".work/todo.jsonl", ".work/done.jsonl") +EPOCH = "1970-01-01T00:00:00Z" # ponytail: fallback --since for an empty log # Mirror of schema/capabilities.schema.json — embedded because installed repos # ship bin/ without schema/. tests/test_dispatch.py asserts the two are identical. @@ -121,6 +123,34 @@ def rev_to_ms(rev): return int(time.time() * 1000) # ponytail: unparseable rev -> now +def earliest_event_ts(paths=LOG_PATHS): + """Earliest `ts` across the local event log. + + Seeds --since on a cursor-less first pull (worklog#141): the adapter + contract requires one of --since/--keys, so a repo that has never pulled + before must not call it with neither. Bad JSON or a missing file is + skipped, same leniency as fold.py's own log reader. + """ + earliest = None + for path in paths: + try: + fh = open(path, encoding="utf-8") + except FileNotFoundError: + continue + with fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + ts = json.loads(line).get("ts") + except json.JSONDecodeError: + continue + if ts and (earliest is None or ts < earliest): + earliest = ts + return earliest + + def resolve_adapter(): path = os.environ.get("WORKLOG_TICKET_ADAPTER") if path: @@ -135,6 +165,11 @@ def resolve_adapter(): class Dispatcher: COUNT_KEYS = ("created", "updated", "closed", "skipped", "pulled", "conflicts", "deferred") + # How many not-founds, with nothing succeeding, before we stop believing + # the tickets and start suspecting the project. Three rather than one so a + # genuinely-deleted first ticket does not abort a healthy run; low enough + # that a bad project setting cannot walk the whole log (ADR-0004). + GONE_ABORT = 3 def __init__(self, adapter, retry_base_delay=0.5, dry_run=False): self.adapter = adapter @@ -143,7 +178,20 @@ def __init__(self, adapter, retry_base_delay=0.5, dry_run=False): self.counts = dict.fromkeys(self.COUNT_KEYS, 0) self.drift = [] self.collisions = {} + # #238: what this run replaced on live tickets, and what the one + # batched read to find that out cost. + self.overwrites = [] + self.remote_before = {} + self.snapshot_cost = None self.state = self._load_state() + # GONE bookkeeping (ADR-0004). Exit code 3 cannot separate a deleted + # ticket from an unreachable project — both are 404 — so the guard is + # on SCALE, not on any per-ticket judgement: a run that has proved + # nothing may condemn at most GONE_ABORT-1 items before it aborts. + # `adapter_ok` is what disarms that, and the marks are buffered so the + # abort can leave nothing behind. + self.adapter_ok = False + self.pending_gone = {} # --- state (.work/sync-state.json, per-clone) --- @@ -290,6 +338,12 @@ def handle_exit(self, item, p): """§3.6 exit-code table. True = success, carry on with the item.""" rc = p.returncode if rc == 0: + # Reachability is proven, and this key answers — so any gone mark + # from an earlier run is stale. Without this, a ticket restored + # from the tracker's trash stays skipped forever, because the mark + # is only outgrown when the key itself changes (ADR-0004). + self.adapter_ok = True + self.item_state(item["id"]).pop("gone_key", None) return True iid = item["id"] if rc == 2: @@ -297,10 +351,27 @@ def handle_exit(self, item, p): sys.exit("worklog sync: adapter auth failure — re-authenticate " "with the tracker and re-run. Nothing further was pushed.") if rc == 3: + # GONE (definite not-found), not a transient failure. The adapter + # contract says to clear `external` so the item files afresh, but + # doing that automatically here cannot tell a real deletion from + # a flaky 404 -- and auto-clearing on a transient error would + # file a duplicate. Deliberately conservative (worklog#241): stop + # retrying this item every run and hand the decision to a human, + # instead of popping last_pushed_hash and hammering the same + # dead key forever. key = (item.get("external") or {}).get("key") - self.note("key %s gone remotely; will re-push %s next run" - % (key, iid[:8])) - self.item_state(iid).pop("last_pushed_hash", None) + self.pending_gone[iid] = key + if not self.adapter_ok and len(self.pending_gone) >= self.GONE_ABORT: + self._save_state() # deliberately WITHOUT the pending marks + sys.exit( + "worklog sync: %d tickets reported gone and not one " + "adapter call has succeeded — the project itself is " + "probably unreachable, not the tickets. Nothing was " + "changed. Check WORKLOG_TICKET_PROJECT and the tracker " + "credentials, then re-run." % len(self.pending_gone)) + self.note("%s: ticket %s reported gone remotely — not retried " + "automatically; run `worklog unlink %s` to clear the " + "link and file a fresh one" % (iid[:8], key, iid)) self.counts["deferred"] += 1 elif rc == 4: self.note("rate limited on %s; deferred after 3 retries" % iid[:8]) @@ -360,7 +431,105 @@ def report_collisions(self, items): print(" worklog sync --keys %s # re-push the surviving owner " "over the damage" % key, file=sys.stderr) + def refuse_ambiguous_keys(self, items, keys): + """--keys accepts a ticket number as well as an item ULID. A number + more than one item claims must be refused outright, not drag every + claimant into scope -- that is exactly the reflex an operator reaches + for while repairing a duplicate (github#226), and precisely wrong + then (worklog#239). Unlike the collision guard below (which skips the + colliders and keeps the rest of the run going), an ambiguous forced + key stops the run before anything is pushed -- the operator asked for + one specific ticket and got a fork in the road instead. + """ + for key in keys: + owners = sorted(i["id"] for i in items + if (i.get("external") or {}).get("key") == key) + if len(owners) > 1: + sys.exit("worklog sync: --keys %r is ambiguous — claimed by " + "%d items: %s (worklog unlink the wrong one first)" + % (key, len(owners), ", ".join(owners))) + + # --- overwrite reporting (#238) --- + + # What a reader would notice being replaced on a live ticket. Deliberately + # not every ingest field: `body` is long enough to bury the line that + # matters, and this exists to make damage visible at a glance. + OVERWRITE_FIELDS = ("title", "status", "priority", "milestone", "assignee") + + def _keys_at_risk(self, items, caps, keys, blocked): + """Keys of tickets this run may overwrite. + + Approximate on purpose. It does not re-derive the push loop's exact + scope rules -- over-fetching costs nothing (the read is batched) and + under-fetching only means one ticket reports no before/after. Cloning + that logic to be exact would put the delicate part in two places. + """ + at_risk = [] + for item in items: + ext = item.get("external") or {} + key = ext.get("key") + if (not key or item["id"] in blocked or item.get("_orphan") + or not item.get("title")): + continue + h = canonical_hash(self.outbound(item, caps)) + forced = bool(keys) and (item["id"] in keys or key in keys) + if self.is_dirty(item["id"], h, ext) or forced: + at_risk.append(str(key)) + return at_risk + + def snapshot_remote(self, caps, at_risk): + """{key: remote fields} as they stand BEFORE this run pushes. + + ONE batched `pull --keys` for the whole run. The ticket that asked for + this flagged "one extra read per updated ticket" as a real cost worth + measuring -- so it is a read per RUN instead, and the run reports how + long it took and how many tickets it covered. The adapter contract + already accepts a key list, so no new verb was needed. + + Degrades to {}: no pull support, a failed read, or unparseable output + all mean "report the fields without before/after", never a failed sync. + """ + if not at_risk or "pull" not in caps["supports"]: + return {} + started = time.time() + p = self.run_adapter("pull", "--keys", ",".join(sorted(set(at_risk)))) + self.snapshot_cost = (len(set(at_risk)), time.time() - started) + if p.returncode != 0: + self.note("could not read current ticket state (exit %d); " + "overwrites reported without before/after" % p.returncode) + return {} + snap = {} + for raw in p.stdout.splitlines(): + if not raw.strip(): + continue + try: + line = json.loads(raw) + except json.JSONDecodeError: + continue + key = (line.get("external") or {}).get("key") + if key is not None: + snap[str(key)] = line + return snap + + def note_overwrite(self, iid, key, payload_item): + """Record which live fields this push replaced, before -> after.""" + before = self.remote_before.get(str(key)) + if before is None: + return + changed = [] + for f in self.OVERWRITE_FIELDS: + if f not in before: + continue + old, new = before.get(f), payload_item.get(f) + if old != new: + changed.append("%s: %r -> %r" % (f, old, new)) + if changed: + self.overwrites.append("%s (%s): %s" + % (key, iid[:8], "; ".join(changed))) + def push_items(self, items, caps, keys): + if keys: + self.refuse_ambiguous_keys(items, keys) # Collection-level gate: inside the loop every item looks perfectly # valid, which is exactly why github#226 was invisible from the log. self.collisions = {k: v for k, v in external_owners(items).items() @@ -368,6 +537,10 @@ def push_items(self, items, caps, keys): if self.collisions: self.report_collisions(items) blocked = {i for ids in self.collisions.values() for i in ids} + # Read the live tickets BEFORE anything is pushed -- after the push + # there is no "before" left to report (#238). + self.remote_before = self.snapshot_remote( + caps, self._keys_at_risk(items, caps, keys, blocked)) for item in items: iid = item["id"] ext = item.get("external") or {} @@ -375,7 +548,12 @@ def push_items(self, items, caps, keys): # titleless items are fold debris, not work — report, never push. # Pushing one files an "(untitled)" ticket remotely. if item.get("_orphan") or not item.get("title"): - self.drift.append(f"{iid[:8]}: orphan/untitled item skipped — not pushed") + # Once it is closed the debris is settled: it can never be + # pushed and there is nothing to act on, so repeating it every + # run only teaches readers to skim drift (01KYTGNS76). + if item.get("status") not in CLOSED_STATUSES: + self.drift.append( + f"{iid[:8]}: orphan/untitled item skipped — not pushed") continue # Before `closed` is computed, so the update-then-close branch is # covered too — that is the path that marked the reported ticket @@ -383,6 +561,17 @@ def push_items(self, items, caps, keys): # set removes it entirely; the rest of the run proceeds. if iid in blocked: continue + # A ticket reported gone (rc 3) on a prior run: don't retry it + # every run (worklog#241) -- surface the remedy again so it isn't + # forgotten. `worklog unlink` clears `external`, so ext["key"] + # then mismatches gone_key and the item re-enters scope normally. + gone_key = self.state.get("items", {}).get(iid, {}).get("gone_key") + if gone_key is not None and gone_key == ext.get("key"): + self.note("%s: ticket %s reported gone remotely — not " + "retried automatically; run `worklog unlink %s` " + "to clear the link and file a fresh one" + % (iid[:8], gone_key, iid)) + continue closed = item.get("status") in CLOSED_STATUSES payload_item = self.outbound(item, caps) h = canonical_hash(payload_item) @@ -438,6 +627,16 @@ def push_items(self, items, caps, keys): else: if self.dry_run: print("would close %s (%s)" % (key, item.get("status"))) + # A close is not always only a close: a dirty item + # pushes its final shape first (see below), and that + # push can rewrite fields on a ticket somebody else + # filed. Reporting overwrites only on the update path + # left this one silent -- the path where an operator + # reading "would close" is least expecting a field + # write. Same call, same condition, so the dry run now + # predicts exactly what the real run does. + if dirty: + self.note_overwrite(iid, key, payload_item) continue if dirty: # Close alone never syncs fields (adapter close is @@ -451,6 +650,9 @@ def push_items(self, items, caps, keys): "item": payload_item}) if not self.handle_exit(item, p): continue + # This is the path that marked the reported ticket + # Done -- the one most worth naming out loud. + self.note_overwrite(iid, key, payload_item) p = self.run_adapter("close", str(key), item.get("resolution") or item["status"]) if self.handle_exit(item, p): @@ -465,6 +667,10 @@ def push_items(self, items, caps, keys): if self.dry_run: print("would %s %s%s" % (op, iid[:8], " -> %s" % ext["key"] if ext.get("key") else "")) + # The most useful place for this: see what would be replaced + # while it is still hypothetical. + if op == "update": + self.note_overwrite(iid, ext["key"], payload_item) continue p = self.call_push(payload) if not self.handle_exit(item, p): @@ -484,7 +690,11 @@ def push_items(self, items, caps, keys): self.counts["created"] += 1 else: self.counts["updated"] += 1 + self.note_overwrite(iid, pushed_key, payload_item) self.record_push(iid, h, pushed_key) + # The push loop is over, so the abort can no longer fire: whatever is + # still buffered is what this run really means to record. + self.commit_gone() # --- pull side --- @@ -493,8 +703,12 @@ def pull(self, caps, items, keys): self.note("adapter does not support pull; local log may lag remote") return system = caps["system"] - cursor = self.state.get("cursors", {}).get(system) - args = ["pull"] + (["--since", cursor] if cursor else []) + # No cursor yet (first pull for this system) -> the adapter contract + # still requires --since or --keys, so seed one from the earliest + # local event instead of calling with neither (worklog#141). + cursor = (self.state.get("cursors", {}).get(system) + or earliest_event_ts() or EPOCH) + args = ["pull", "--since", cursor] p = self.run_adapter(*args) if p.returncode == 2: sys.exit("worklog sync: adapter auth failure on pull — " @@ -502,6 +716,7 @@ def pull(self, caps, items, keys): if p.returncode != 0: self.note("pull failed (exit %d); cursor not advanced" % p.returncode) return + self.adapter_ok = True # a clean pull proves the project is reachable by_id = {i["id"]: i for i in items} max_rev = cursor for raw in p.stdout.splitlines(): @@ -565,6 +780,19 @@ def pull(self, caps, items, keys): if max_rev and not self.dry_run: self.state.setdefault("cursors", {})[system] = max_rev + def commit_gone(self): + """Flush the run's buffered gone marks into state. + + Buffering is not a second safety rule — GONE_ABORT is the rule, and it + has already fired if this run was going to condemn items at scale. What + buffering buys is that the abort leaves nothing behind: marks written + item-by-item would survive the exit that was supposed to change + nothing (ADR-0004). + """ + for iid, key in self.pending_gone.items(): + self.item_state(iid)["gone_key"] = key + self.pending_gone.clear() + # --- the run --- def sync(self, keys=None, push=True, pull=True): @@ -587,6 +815,16 @@ def sync(self, keys=None, push=True, pull=True): def report(self): print("sync report: " + " ".join("%s=%d" % (k, self.counts[k]) for k in self.COUNT_KEYS)) + # Before drift: "updated 2" is not the line that catches damage -- + # naming the field that changed on a live ticket is (#238). + if self.overwrites: + print("overwrote live ticket fields:") + for line in self.overwrites: + print(" - " + line) + if self.snapshot_cost: + n, secs = self.snapshot_cost + print(" (read %d ticket%s in %.2fs to report the above)" + % (n, "" if n == 1 else "s", secs)) if self.drift: print("drift:") for line in self.drift: diff --git a/bin/ulid.py b/bin/ulid.py index ac59f10..a5cc33a 100755 --- a/bin/ulid.py +++ b/bin/ulid.py @@ -40,8 +40,85 @@ def encode(timestamp_ms: int, entropy: bytes) -> str: return _encode(timestamp_ms, 10) + _encode(int.from_bytes(entropy, "big"), 16) +_git_commit_cache = {} # {"short"|"full": sha}; many events per run + + +def _rev_parse(short: bool) -> str: + """HEAD's sha, or "" outside a git repo / before the first commit. + + Memoised per process and per length. HEAD does not move underneath one + command, and `worklog` writes an event per command -- shelling out per + event would put a subprocess in the hot path of the only writer. + + Never raises. A missing git, a bare directory, or a repo with no commits + all return "", and every caller is required to OMIT its field rather + than write an empty one. + """ + key = "short" if short else "full" + if key in _git_commit_cache: + return _git_commit_cache[key] + sha = "" + if not os.environ.get("WORKLOG_NO_GIT_PROVENANCE"): + try: + import subprocess + argv = ["git", "rev-parse"] + (["--short"] if short else []) + ["HEAD"] + p = subprocess.run(argv, capture_output=True, text=True) + if p.returncode == 0: + sha = p.stdout.strip() + except OSError: + sha = "" + _git_commit_cache[key] = sha + return sha + + +def git_commit() -> str: + """Short HEAD sha for an EVENT's `git` field. + + Provenance, NOT identity: this goes in an event's own `git` field, never + into the id. An id is issued once and must never collide, so spending its + entropy to carry origin information weakens the single guarantee it + exists to provide. + + Short is right here: the log carries one per event and the value is a + hint for a human tracing origin, not a key anything resolves. + """ + return _rev_parse(short=True) + + +def git_commit_full() -> str: + """Full 40-hex HEAD sha for a DOCUMENT's front matter. + + Full length, and that is not a style preference. `ia._scalar` coerces an + all-digit value to int before it considers quotes, so a 7-char short sha + is all digits roughly one time in 27 and one with a leading zero reads + back corrupted but still sha-shaped. Front matter writers should quote + the value too -- belt and braces -- but the cheap structural fix is to + make an all-digit sha vanishingly unlikely rather than routine. + + It also matches what design docs have carried since they were introduced, + so there is one vocabulary for "the commit this doc was written against" + rather than two. + + Caveat worth knowing at every call site: at stamping time HEAD is the + commit BEFORE the one the doc lands in -- a commit cannot know its own + sha. This means "the tree this document was written against", which is + exactly what a reader diffing stale prose wants. + """ + return _rev_parse(short=False) + + def new(timestamp_ms: int = None) -> str: - """A fresh ULID for a locally-originated event.""" + """A fresh ULID for a locally-originated event. + + Full 80 bits of entropy, always. v0.19.0 briefly overwrote five of those + characters with the short git hash to make branches distinguishable; that + was the wrong trade and is reverted. An id is issued once and never + changes, and the only thing it must guarantee is that it does not clash, + so entropy is not currency to spend on metadata. Provenance lives in the + event's `git` field instead -- which also traces better, since an item's + id is minted once and could only ever name the branch the ITEM was + created on, while a field on every event names the origin of each one. + """ ms = int(time.time() * 1000) if timestamp_ms is None else timestamp_ms return encode(ms, os.urandom(10)) diff --git a/bin/wiki_flavor.py b/bin/wiki_flavor.py new file mode 100755 index 0000000..c1603ba --- /dev/null +++ b/bin/wiki_flavor.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +wiki_flavor.py -- the renderer's one platform seam. Item #271. + +The renderer never read the wiki system from config: page naming and every +cross-page link were written for Gollum, the GitHub wiki engine. Roughly forty +wikilink sites and five naming helpers assumed its conventions, so porting to +any other platform meant editing all of them. + +The seam is deliberately two things, and only two: + + link(page, text=None) -> the platform's cross-page link syntax + sanitize(name) -> what the platform allows in a page name + +A second platform is a new class in FLAVORS, not a rewrite. + +The design point that keeps it small: `[[Page]]` is treated as the renderer's +CANONICAL link notation, not as Gollum output. Every prose string in +ia_render.py keeps writing `[[Index-Releases]]` -- readable, greppable, +unchanged -- and render_links() translates the whole page once at the output +boundary. So a second platform implements one method instead of editing forty +call sites, and Gollum stays byte-identical because for Gollum the +translation is the identity. + +The ticket that asked for this set its own limit: doing the seam without a +second consumer is only worth it if it stays small, and if it grows past a +naming/link interface, stop and wait for a real second platform. So there is +no page-layout hook, no frontmatter hook, no directory-structure hook, and no +`filename()` -- those would be guesses about a platform nobody has asked for. +Only ONE flavor ships, because only one platform has a user +(`wiki.system: github-wiki`). +""" +import os +import re + +CONFIG = ".work/config.yml" + +# Gollum's own syntax, reused as the canonical notation: [[Page]] or the +# piped [[Display|Page]] -- display FIRST, which is the order that bites +# people, and the reason translation lives in one place. +CANONICAL_LINK = re.compile(r"\[\[([^\[\]|]+?)(?:\|([^\[\]|]+?))?\]\]") + + +class Gollum: + """GitHub wiki. Flat namespace, `[[Page]]` links, spaces become dashes.""" + + name = "github-wiki" + + def link(self, page, text=None): + return "[[%s|%s]]" % (text, page) if text else "[[%s]]" % page + + def sanitize(self, name): + # Gollum maps spaces to dashes in URLs; writing the dash keeps a page + # name and its published URL identical. + return name.replace(" ", "-") + + +FLAVORS = {f.name: f for f in (Gollum,)} +DEFAULT = Gollum.name + + +def render_links(text, flavor): + """Translate canonical [[...]] notation in `text` into `flavor` syntax.""" + def one(m): + first, second = m.group(1), m.group(2) + # [[Page]] -> page only; [[Display|Page]] -> display first (Gollum). + return flavor.link(second, first) if second else flavor.link(first) + return CANONICAL_LINK.sub(one, text) + + +def configured_system(path=CONFIG): + """`wiki.system` from .work/config.yml, or None. + + A targeted read rather than a YAML dependency: this needs one value from + one known block, and the installer already reads config.yml the same way + with awk. Anything malformed reads as "not configured", which falls back + to the default rather than failing a render. + """ + try: + with open(path, encoding="utf-8") as fh: + text = fh.read() + except OSError: + return None + in_wiki = False + for raw in text.splitlines(): + line = raw.split("#", 1)[0].rstrip() + if not line.strip(): + continue + if not line[0].isspace(): + in_wiki = line.strip().startswith("wiki:") + continue + if in_wiki: + m = re.match(r"\s+system:\s*(\S+)", line) + if m: + return m.group(1).strip("\"'") + return None + + +def get(system=None, path=CONFIG): + """The flavor to render with. + + An unknown system is NOT an error: the config legitimately carries + `other` and `none`, and a repo naming a platform nobody has implemented + should still render its docs in the one format that exists rather than + refusing to build. + """ + system = (system or os.environ.get("WORKLOG_WIKI_SYSTEM") + or configured_system(path) or DEFAULT) + return FLAVORS.get(system, FLAVORS[DEFAULT])() diff --git a/bin/worklog b/bin/worklog index c73496f..d5e453a 100755 --- a/bin/worklog +++ b/bin/worklog @@ -23,13 +23,37 @@ Nothing else in the repo may write to .work/*.jsonl (invariant 15.4). import argparse, glob, json, os, re, sys, time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import ulid +import item_fields from fold import (fold, FoldResult, read_lines, dedupe_and_sort, external_owners, OPEN_STATUSES, CLOSED_STATUSES, LEGACY_TYPE_MAP) LOG = ".work/todo.jsonl" PATHS = (".work/todo.jsonl", ".work/done.jsonl") MAX_BODY = 2048 # derived from PIPE_BUF (section 8.3). Not a setting. -VERSION = "0.18.0" # kept in step with plugin/.claude-plugin/plugin.json by tests/test_plugin.py +VERSION = "0.22.2" # kept in step with plugin/.claude-plugin/plugin.json by tests/test_plugin.py + + +_warned_concurrent = False + + +def _warn_concurrent_sessions(): + """Advisory (#236). Once per process, on stderr, never blocking. + + Sits in append() rather than in each command: every write in the system + funnels through here, so one guard covers add/update/close/link/ingest + and anything added later, and no caller can forget it. + """ + global _warned_concurrent + if _warned_concurrent or os.environ.get("WORKLOG_NO_SESSION_WARN"): + return + _warned_concurrent = True + try: + import session + note = session.warning() + except Exception: + return # an advisory must never break a write + if note: + print(f"worklog: WARNING — {note}", file=sys.stderr) def append(event): @@ -41,6 +65,7 @@ def append(event): """ if len(event.get("set", {}).get("body", "")) > MAX_BODY: sys.exit(f"worklog: body exceeds {MAX_BODY}B; put prose in the plan doc") + _warn_concurrent_sessions() line = json.dumps(event, separators=(",", ":"), sort_keys=True) + "\n" fd = os.open(LOG, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644) try: @@ -59,8 +84,21 @@ def append(event): def base(item, op, actor): - return {"ev": ulid.new(), "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "actor": actor, "item": item, "op": op} + """Every event the CLI writes starts here. + + `git` is provenance: the short HEAD sha this event was authored at, so + two agents in different worktrees or branches produce traceably different + events (01KYZNG520). It is a separate field on purpose -- the id must + keep its full entropy, and an item's id is minted once, so only a + per-event field can name where each event actually came from. Omitted + entirely outside a git repo rather than written empty. + """ + ev = {"ev": ulid.new(), "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "actor": actor, "item": item, "op": op} + sha = ulid.git_commit() + if sha: + ev["git"] = sha + return ev LEVELS = ("epic", "story", "task", "subtask") @@ -78,6 +116,50 @@ def check_taxonomy(level, kind, milestone): "derived (taxonomy §2.5)") +ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$") +ESTIMATES = ("XS", "S", "M", "L", "XL") + + +def parse_depends_on(raw, self_id=None): + """Comma-separated ULIDs -> sorted list (worklog#256, spec §3.3). + + Shape is validated, existence is NOT: --parent does not check either, and + an append-only log must stay writable when the thing you depend on is + filed a minute later. Shape still matters -- five readers resolve these to + titles, and a typo would render as a silent missing blocker rather than an + error. + + Self-dependency IS rejected: an item that blocks itself can never be + scheduled, and no ordering of the log makes it true later. + """ + if not raw: + return [] + out = [] + for tok in (t.strip() for t in raw.split(",")): + if not tok: + continue + if not ULID_RE.match(tok): + sys.exit(f"worklog: --depends-on {tok!r} is not a 26-char ULID; " + "dependencies are item ids, not ticket numbers or titles") + if self_id and tok == self_id: + sys.exit(f"worklog: {tok} cannot depend on itself") + out.append(tok) + return sorted(set(out)) + + +def warn_ticket_refs(title): + """Advisory only (worklog#242): a title cannot know its own ticket number + -- the tracker mints that at the next sync -- so a `#123` in one is either + a reference to a different ticket or a guess that will be wrong. Never a + refusal: legitimate titles do cite other tickets.""" + import plan_capture + refs = plan_capture.ticket_refs(title) + if refs: + print("worklog: WARNING — title cites %s; this item's own ticket " + "number is assigned at sync, so cite the ULID instead if you " + "meant this item" % ", ".join(refs), file=sys.stderr) + + def cmd_add(a): if a.unplanned and not a.discovered_during: sys.exit("worklog: --unplanned requires --discovered-during (section 5.4)") @@ -88,6 +170,7 @@ def cmd_add(a): lvl, knd = LEGACY_TYPE_MAP[a.legacy_type] a.level, a.kind = lvl, a.kind or knd check_taxonomy(a.level, a.kind, a.milestone) + warn_ticket_refs(a.title) item = ulid.new() ev = base(item, "create", a.actor) # kind is only written when given: an omitted kind folds to triage (§2.3), @@ -100,6 +183,12 @@ def cmd_add(a): "unplanned": True if a.unplanned else None, "discovered_during": a.discovered_during, }.items() if v is not None} + # Optional fields (#108): only the ones this repo has switched on, and + # only those the caller actually passed. + ev["set"].update(item_fields.collect(a)) + deps = parse_depends_on(a.depends_on) + if deps: + ev["set"]["depends_on"] = deps if a.labels: ev["set"]["labels"] = a.labels.split(",") append(ev) @@ -107,9 +196,20 @@ def cmd_add(a): def _require_item(item): - """Reject empty/whitespace item ids before any log write (worklog 01KXXMCG41).""" + """Reject empty/whitespace item ids before any log write (worklog 01KXXMCG41). + + Whitespace inside an id is rejected too. `item` is a single positional, so + `close "$id1 $id2"` writes an event against a composite key matching no + item — a silent no-op that also renders a junk sidecar path. A real id + never contains whitespace, so the same guard catches an error message + captured into the id by mistake, which is how one of these arose. + """ if not item or not item.strip(): sys.exit("worklog: empty item id") + if len(item.split()) > 1: + sys.exit(f"worklog: item id contains whitespace: {item[:60]!r}\n" + " one item per invocation — loop instead: " + "for i in $ids; do worklog close \"$i\"; done") def _resolve(item, r=None): @@ -148,10 +248,22 @@ def cmd_update(a): "body": a.body, "kind": a.kind, "milestone": a.milestone}.items() if v is not None} - if a.add_label: - ev["add"] = {"labels": a.add_label.split(",")} - if a.del_label: - ev["del"] = {"labels": a.del_label.split(",")} + ev["set"].update(item_fields.collect(a)) + # depends_on is SET_VALUED in the fold, exactly like labels, so it takes + # the same add/del shape rather than whole-field replacement -- two + # branches each adding a blocker must both survive a union merge. + add = {k: v for k, v in { + "labels": a.add_label.split(",") if a.add_label else None, + "depends_on": parse_depends_on(a.add_depends_on, cur["id"]) or None, + }.items() if v} + dele = {k: v for k, v in { + "labels": a.del_label.split(",") if a.del_label else None, + "depends_on": parse_depends_on(a.del_depends_on, None) or None, + }.items() if v} + if add: + ev["add"] = add + if dele: + ev["del"] = dele if not (ev.get("set") or ev.get("add") or ev.get("del")): sys.exit("worklog: nothing to update") append(ev) @@ -283,10 +395,21 @@ def cmd_ingest(a): print(ev["ev"]) +def _check_conflictable_field(field): + """#240: `id` is identity, not data, and `external` is a structured link + (system/key/url/rev), not a string -- writing either through conflict/ + resolve corrupts the next sync. Reuse INGEST_FIELDS: it is already the + set of fields a remote is allowed to disagree about.""" + if field not in INGEST_FIELDS: + sys.exit(f"worklog: field {field!r} cannot be used with conflict/resolve " + f"(allowed: {', '.join(INGEST_FIELDS)})") + + def cmd_conflict(a): """Record a both-sides-changed field (section 10.6). State never changes; the fold files it under _conflicts until a later write clears it.""" _require_item(a.item) + _check_conflictable_field(a.field) ev = base(a.item, "conflict", "sync") ev["set"] = {"field": a.field, "local": a.local, "remote": a.remote, "remote_rev": a.remote_rev} @@ -296,6 +419,7 @@ def cmd_conflict(a): def cmd_resolve(a): """Resolve the last open conflict on a field with a normal update event. The update outsorts the conflict, so the fold clears it (section 10.6).""" + _check_conflictable_field(a.field) item = _resolve(a.item) open_c = [c for c in item.get("_conflicts", []) if c.get("field") == a.field] if not open_c: @@ -372,9 +496,26 @@ def cmd_roadmap_render(a): def cmd_roadmap_snapshot(a): + import re import shutil if not os.path.exists("docs/roadmap.md"): sys.exit("worklog: docs/roadmap.md not found; run roadmap-render first") + with open("docs/roadmap.md", encoding="utf-8") as fh: + on_disk = fh.read() + # #221: a snapshot freezes docs/roadmap.md forever, so a stale copy stays + # wrong forever. render() is a pure/deterministic function of the log + # (render_roadmap.py docstring), so regenerating is a no-op when the file + # is already fresh and self-healing when the log moved on since the last + # `roadmap-render`. Only do this for a file that carries our own + # source_hash marker -- anything else was never ours to regenerate. + m = re.search(r"^source_hash: (\S+)$", on_disk, re.MULTILINE) + if m: + import render_roadmap + fresh = render_roadmap.render() + fresh_hash = re.search(r"^source_hash: (\S+)$", fresh, re.MULTILINE).group(1) + if fresh_hash != m.group(1): + with open("docs/roadmap.md", "w", encoding="utf-8") as fh: + fh.write(fresh) date = time.strftime("%Y-%m-%d", time.gmtime()) name = f"{date}_{a.name}" if a.name else date path = f"docs/roadmap/{name}.md" @@ -401,6 +542,8 @@ def cmd_plan_capture(a): tasks = plan_capture.parse_tasks(draft) if not tasks: sys.exit("worklog: no '- [ ]' tasks found under a '## Tasks' heading") + for t in tasks: + warn_ticket_refs(t["title"]) date = time.strftime("%Y-%m-%d", time.gmtime()) path = f"docs/plans/{date}-{a.slug}.md" # Invariant 15.8 is about the SLUG, not the filename: "pick a new slug to @@ -453,7 +596,8 @@ def cmd_plan_capture(a): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as fh: - fh.write(plan_capture.front_matter(date, a.slug, a.title, epic_id, item_ids)) + fh.write(plan_capture.front_matter(date, a.slug, a.title, epic_id, + item_ids, ulid.git_commit_full())) fh.write(draft) print(path) print(epic_id) @@ -529,7 +673,7 @@ def cmd_adr_new(a): a.title, nid, slug, date, status=a.status, deciders=a.deciders.split(",") if a.deciders else None, tags=a.tags.split(",") if a.tags else None, - supersedes=a.supersedes) + supersedes=a.supersedes, git_hash=ulid.git_commit_full()) if os.path.exists(path): sys.exit(f"worklog: {path} exists") if a.supersedes is not None: @@ -652,11 +796,56 @@ def cmd_ia_ticket(a): def cmd_link_pr(a): import ia_graph + # Resolve first (01KYZFMZ5C). Writing the sidecar under the raw string + # silently files docs/.index/item/.yml, so the edge never reaches + # the graph and the release evidence gate still calls the item unlinked -- + # with no error, because the write itself succeeded. Same class already + # fixed for close/update, which minted phantom orphans from the short ids + # that `list` and `show` print. + item = _resolve(a.item)["id"] try: - entry = ia_graph.link_pr(a.item, pr=a.pr, commit=a.commit) + entry = ia_graph.link_pr(item, pr=a.pr, commit=a.commit) except ValueError as e: sys.exit(f"worklog: {e}") - print(f"linked {a.item} -> {entry}") + print(f"linked {item} -> {entry}") + + +def cmd_pr_sync(a): + import ia_graph + try: + meta = ia_graph.pr_sync(a.pr) + except ValueError as e: + sys.exit(f"worklog: {e}") + print(f"pr/{meta['number']}: {meta['state']} · review {meta['review']} " + f"· checks {meta['checks']} · {len(meta['files'])} file(s)") + + +def cmd_provenance_backfill(a): + """Stamp `merged_in` on frozen docs that have landed (plan + 2026-08-03-doc-provenance-and-verification). Imported lazily: this is + the one provenance path that shells out to git, and keeping it out of + module scope keeps the import graph of the render plane clean.""" + import provenance + changed = provenance.backfill(check=a.check) + provenance.report(changed, check=a.check) + if a.check and changed: + sys.exit(1) + + +def cmd_doc_verify(a): + """Resolve each document's code citations at the commit it was written + against (#294). Lazy import: doc_verify shells out to git, and the + render plane must not grow that dependency.""" + import doc_verify + findings, summary = doc_verify.verify(strict=a.strict) + doc_verify.report(findings, summary) + if a.strict: + bad = doc_verify.failing(findings) + if bad: + print("doc-verify: %d finding(s) fail --strict " + "(fabricated anywhere, or drift on a doc claiming to " + "describe HEAD)" % len(bad)) + sys.exit(1) def cmd_trace_check(a): @@ -700,6 +889,29 @@ def cmd_compact(a): print(f"compacted through {wm}" if wm else "nothing to compact") +def cmd_changelog_draft(a): + import changelog + sys.exit(changelog.main(["changelog-draft"] + + (["--version", a.version] if a.version else []) + + (["--since", a.since] if a.since else []))) + + +def cmd_fields(a): + for line in item_fields.describe(): + print(line) + + +def cmd_merge_rescue(a): + import compact + compact.report_rescue(*compact.merge_rescue()) + + +def cmd_find(a): + import ia_graph + sys.exit(ia_graph.find(query=a.query, doc_type=a.type, truth=a.truth, + links=a.links, edge=a.edge, as_json=a.json)) + + def _rfc3339(ms): return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ms / 1000)) @@ -822,16 +1034,42 @@ def _status_facts(kind, now_ms, since=None, until=None): "through": render_roadmap.max_ev(PATHS), "days": [days[k] for k in sorted(days)]} + def closed_in(evs): + """Did this item close inside the window? + + The obvious test -- a `close` event in range -- goes silently wrong + after a compaction. Compaction folds an item's history into ONE + snapshot, so the close event no longer exists and the item vanishes + from the report even though it closed inside the window. Nightly + compaction runs on the default branch, so this quietly emptied the + weekly report of everything closed more than a day earlier: the + artifact whose entire job is to span a week. + + The time is not lost. A snapshot carries `through`, the highest event + it folded for that item, and for a closed item that is the close. So + fall back to the snapshot's watermark rather than reporting nothing. + """ + if any(e["op"] == "close" and in_window(e) for e in evs): + return True + return any(e["op"] == "snapshot" and e.get("through") + and lo <= ulid.timestamp_ms(e["through"]) <= hi + for e in evs) + closed, opened, unplanned = [], [], [] for iid, evs in per.items(): it = items.get(iid, {}) - if (it.get("status") in CLOSED_STATUSES - and any(e["op"] == "close" and in_window(e) for e in evs)): + if it.get("status") in CLOSED_STATUSES and closed_in(evs): closed.append({"id": iid, "title": it.get("title", ""), "status": it.get("status"), "resolution": it.get("resolution"), "unplanned": bool(it.get("unplanned")), "epic": epic_title(iid)}) + # No equivalent rescue exists for `opened`, and the asymmetry is real + # rather than an oversight: a snapshot's watermark is the LAST event + # it folded, so it recovers a close and can never recover a create. + # After a compaction, opened-in-window and the unplanned rollup built + # from it under-report. Prefer the unplanned share of CLOSED items, + # which is now exact, whenever the two disagree. if any(e["op"] == "create" and in_window(e) for e in evs): opened.append({"id": iid, "title": it.get("title", ""), "type": it.get("level"), "epic": epic_title(iid)}) @@ -891,6 +1129,11 @@ def cmd_status(a): date = (facts["window"]["to"][:10] if a.kind == "timecard" else time.strftime("%Y-%m-%d", time.gmtime(now_ms / 1000))) path = f"docs/status/{date}-{a.kind}.md" + # Omitted when there is no git (or WORKLOG_NO_GIT_PROVENANCE), never + # written empty -- an empty value opens a block list in + # ia.parse_front_matter and swallows the closing fence. Quoted so an + # all-digit sha is not read back as an int. + sha = ulid.git_commit_full() doc = ("---\n" f"kind: {a.kind}\n" f"date: {date}\n" @@ -898,6 +1141,7 @@ def cmd_status(a): f"to: {facts['window']['to']}}}\n" f"through: {facts['through'] or 'null'}\n" f"generated_at: {_rfc3339(now_ms)}\n" + + (f'git_hash: "{sha}"\n' if sha else "") + "---\n\n" + prose.rstrip("\n") + "\n") if a.dry_run: sys.stdout.write(doc) @@ -1052,6 +1296,10 @@ a.add_argument("--body", help="description readable by a junior dev/PM: what " "and why, no ULIDs (spec §13.4)") a.add_argument("--unplanned", action="store_true") a.add_argument("--discovered-during", dest="discovered_during") +item_fields.add_arguments(a) +a.add_argument("--depends-on", dest="depends_on", metavar="ULID[,ULID...]", + help="items that block this one — scheduling, not hierarchy " + "(spec §3.3); the roadmap renders these as blockers") u = sub.add_parser("update"); u.set_defaults(fn=cmd_update) u.add_argument("item") @@ -1064,6 +1312,11 @@ u.add_argument("--body", help="description readable by a junior dev/PM: what " "and why, no ULIDs (spec §13.4)") u.add_argument("--add-label", dest="add_label") u.add_argument("--del-label", dest="del_label") +item_fields.add_arguments(u) +u.add_argument("--add-depends-on", dest="add_depends_on", + metavar="ULID[,ULID...]", help="add blocking items") +u.add_argument("--del-depends-on", dest="del_depends_on", + metavar="ULID[,ULID...]", help="remove blocking items") c = sub.add_parser("close"); c.set_defaults(fn=cmd_close) c.add_argument("item") @@ -1143,6 +1396,45 @@ rs.add_argument("--name") co = sub.add_parser("compact"); co.set_defaults(fn=cmd_compact) co.add_argument("--yes", action="store_true") +cd = sub.add_parser( + "changelog-draft", + description="Draft the unreleased CHANGELOG section from git log since " + "the last tag, grouped by commit type, with log/index-only " + "commits excluded. Markdown on stdout, exclusions on stderr. " + "A starting point for release notes, not the notes (#136).") +cd.set_defaults(fn=cmd_changelog_draft) +cd.add_argument("--version", help="version for the heading (default: X.Y.Z)") +cd.add_argument("--since", metavar="REF", help="default: the last tag") + +fd = sub.add_parser( + "find", + description="Search the generated inventory and graph: documents by " + "text/type/truth-state, a node's edges in both directions, " + "or every edge of one type. Read-only, no network (#272).") +fd.set_defaults(fn=cmd_find) +fd.add_argument("query", nargs="?", help="case-insensitive substring") +fd.add_argument("--type", help="doc_type filter, e.g. plan, adr, item, pr") +fd.add_argument("--truth", help="truth_state filter, e.g. current, frozen") +fd.add_argument("--links", metavar="KEY", + help="show edges into and out of one node") +fd.add_argument("--edge", metavar="TYPE", + help="list every edge of one type, e.g. supersedes") +fd.add_argument("--json", action="store_true") + +sub.add_parser( + "fields", + description="Show the item field model: the fixed core, and which " + "optional fields this repo has switched on, with what each " + "one means (#108).").set_defaults(fn=cmd_fields) + +sub.add_parser( + "merge-rescue", + description="Resolve a merge the resurrection guard blocked: keep the " + "compacted side's log and re-apply this branch's own events " + "above the watermark, so none are dropped on read. Run it " + "from the blocked merge (ADR-0005).").set_defaults( + fn=cmd_merge_rescue) + pm = sub.add_parser( "promote", description="Promote a classifier suggestion from .work/suggestions.jsonl " @@ -1254,6 +1546,14 @@ lp.add_argument("item") lp.add_argument("--pr", type=int) lp.add_argument("--commit") +ps = sub.add_parser( + "pr-sync", + description="Fetch live PR metadata (state, files, review, checks) into " + "the pr/ sidecar. The one network step in the IA " + "pipeline — render stays offline and reads the sidecar.") +ps.set_defaults(fn=cmd_pr_sync) +ps.add_argument("pr", type=int) + tc = sub.add_parser( "trace-check", description="Unlinked-evidence report: closed items missing plan/ticket/" @@ -1261,6 +1561,30 @@ tc = sub.add_parser( tc.set_defaults(fn=cmd_trace_check) tc.add_argument("--strict", action="store_true") +dv = sub.add_parser( + "doc-verify", + description="Check each document's code citations against the commit it " + "was written against, not against HEAD. Separates a " + "FABRICATED citation (wrong even in the tree the author had " + "open) from DRIFT (right then, moved since) — a frozen doc " + "is allowed to drift; one claiming to describe HEAD is not. " + "Never falls back to HEAD: an unstamped or unresolvable " + "document is reported and skipped.") +dv.set_defaults(fn=cmd_doc_verify) +dv.add_argument("--strict", action="store_true", + help="exit 1 on fabrication, or on drift in a live document") + +pb = sub.add_parser( + "provenance-backfill", + description="Stamp `merged_in` on frozen documents that have landed on " + "the default branch — the merge commit a document cannot " + "know when it is written. Frozen only: a live doc has been " + "edited since it landed, so the merge that first carried it " + "would name a version that no longer exists.") +pb.set_defaults(fn=cmd_provenance_backfill) +pb.add_argument("--check", action="store_true", + help="report what would be stamped and exit 1, writing nothing") + ii = sub.add_parser( "ia-inventory", description="Generate docs/.index/_inventory.json — one metadata record " diff --git a/docs/.index/_graph.json b/docs/.index/_graph.json index 9706582..93985bd 100644 --- a/docs/.index/_graph.json +++ b/docs/.index/_graph.json @@ -540,6 +540,51 @@ "to": "release/v0.3.2", "type": "targets" }, + { + "from": "item/01KZD823EH6PBMN2DRD9WAX52F", + "to": "item/01KZD823EG6R5E1FXFX416RQ0G", + "type": "belongs-to" + }, + { + "from": "item/01KZD823EHAFN2YF6WCG2T7XN8", + "to": "item/01KZD823EG6R5E1FXFX416RQ0G", + "type": "belongs-to" + }, + { + "from": "item/01KZD823EHGFVX6NSM2ZT057SY", + "to": "item/01KZD823EG6R5E1FXFX416RQ0G", + "type": "belongs-to" + }, + { + "from": "item/01KZD823EHMC16WZNM4W193VVX", + "to": "item/01KZD823EG6R5E1FXFX416RQ0G", + "type": "belongs-to" + }, + { + "from": "item/01KZD823EHMRAZH978P0EYQFB1", + "to": "item/01KZD823EG6R5E1FXFX416RQ0G", + "type": "belongs-to" + }, + { + "from": "item/01KZD823EHQRBSGQ76C4EK1TXT", + "to": "item/01KZD823EG6R5E1FXFX416RQ0G", + "type": "belongs-to" + }, + { + "from": "item/01KZD823EHXEV8Z72ZCNZA8VZH", + "to": "item/01KZD823EG6R5E1FXFX416RQ0G", + "type": "belongs-to" + }, + { + "from": "item/01KZD823EHZ7W4HG9N5CV4SRMT", + "to": "item/01KZD823EG6R5E1FXFX416RQ0G", + "type": "belongs-to" + }, + { + "from": "item/01KZD84S62B5TWKQSX4XV9848M", + "to": "item/01KZD823EG6R5E1FXFX416RQ0G", + "type": "belongs-to" + }, { "from": "plan/2026-07-29-wiki-ticket-adoption", "to": "item/01KYQZ4PAMK1ND4XKH8A1VHMFN", @@ -675,6 +720,51 @@ "to": "item/01KZ2CAAT6Z9Q09MMFFWD7XH1R", "type": "produces" }, + { + "from": "plan/2026-08-07-worklog-0-22-2-upgrade", + "to": "item/01KZD823EG6R5E1FXFX416RQ0G", + "type": "produces" + }, + { + "from": "plan/2026-08-07-worklog-0-22-2-upgrade", + "to": "item/01KZD823EH6PBMN2DRD9WAX52F", + "type": "produces" + }, + { + "from": "plan/2026-08-07-worklog-0-22-2-upgrade", + "to": "item/01KZD823EHAFN2YF6WCG2T7XN8", + "type": "produces" + }, + { + "from": "plan/2026-08-07-worklog-0-22-2-upgrade", + "to": "item/01KZD823EHGFVX6NSM2ZT057SY", + "type": "produces" + }, + { + "from": "plan/2026-08-07-worklog-0-22-2-upgrade", + "to": "item/01KZD823EHMC16WZNM4W193VVX", + "type": "produces" + }, + { + "from": "plan/2026-08-07-worklog-0-22-2-upgrade", + "to": "item/01KZD823EHMRAZH978P0EYQFB1", + "type": "produces" + }, + { + "from": "plan/2026-08-07-worklog-0-22-2-upgrade", + "to": "item/01KZD823EHQRBSGQ76C4EK1TXT", + "type": "produces" + }, + { + "from": "plan/2026-08-07-worklog-0-22-2-upgrade", + "to": "item/01KZD823EHXEV8Z72ZCNZA8VZH", + "type": "produces" + }, + { + "from": "plan/2026-08-07-worklog-0-22-2-upgrade", + "to": "item/01KZD823EHZ7W4HG9N5CV4SRMT", + "type": "produces" + }, { "from": "roadmap-snapshot/2026-08-01_v0.3.0-release", "to": "roadmap", @@ -1029,6 +1119,56 @@ "status": "done", "title": "Release v0.3.2" }, + "item/01KZD823EG6R5E1FXFX416RQ0G": { + "doc_type": "item", + "status": "todo", + "title": "Upgrade the vendored worklog tooling to 0.22.2" + }, + "item/01KZD823EH6PBMN2DRD9WAX52F": { + "doc_type": "item", + "status": "done", + "title": "Complete the three manual steps the upgrade requires" + }, + "item/01KZD823EHAFN2YF6WCG2T7XN8": { + "doc_type": "item", + "status": "todo", + "title": "Backfill document provenance" + }, + "item/01KZD823EHGFVX6NSM2ZT057SY": { + "doc_type": "item", + "status": "todo", + "title": "Republish and confirm the plan banners are fixed" + }, + "item/01KZD823EHMC16WZNM4W193VVX": { + "doc_type": "item", + "status": "done", + "title": "Baseline the citation verifier and fix real defects only" + }, + "item/01KZD823EHMRAZH978P0EYQFB1": { + "doc_type": "item", + "status": "todo", + "title": "Restore merge commits as the pull request merge style" + }, + "item/01KZD823EHQRBSGQ76C4EK1TXT": { + "doc_type": "item", + "status": "done", + "title": "Upgrade the vendored tooling to 0.22.2" + }, + "item/01KZD823EHXEV8Z72ZCNZA8VZH": { + "doc_type": "item", + "status": "todo", + "title": "File the missing-module gap upstream" + }, + "item/01KZD823EHZ7W4HG9N5CV4SRMT": { + "doc_type": "item", + "status": "done", + "title": "Install the doc-verify modules the upgrade leaves out" + }, + "item/01KZD84S62B5TWKQSX4XV9848M": { + "doc_type": "item", + "status": "done", + "title": "Restore the repo-local test gates the upgrade removed from pre-commit" + }, "plan/2026-07-29-wiki-ticket-adoption": { "doc_type": "plan", "source": "docs/plans/2026-07-29-wiki-ticket-adoption.md", @@ -1047,6 +1187,12 @@ "title": "v0.3.1 \u2014 correctness cleanup and traceability backfill", "truth_state": "current" }, + "plan/2026-08-07-worklog-0-22-2-upgrade": { + "doc_type": "plan", + "source": "docs/plans/2026-08-07-worklog-0-22-2-upgrade.md", + "title": "Upgrade the vendored worklog tooling to 0.22.2", + "truth_state": "current" + }, "pr/25": { "doc_type": "pr" }, diff --git a/docs/.index/_inventory.json b/docs/.index/_inventory.json index 7558082..49c2fd7 100644 --- a/docs/.index/_inventory.json +++ b/docs/.index/_inventory.json @@ -243,13 +243,36 @@ "wiki": "https://github.com/SpillwaveSolutions/okf-plugin/wiki/Plan-v031-correctness-and-traceability", "wiki_key": "plan/2026-08-02-v031-correctness-and-traceability" }, + { + "canonical_key": "plan/2026-08-07-worklog-0-22-2-upgrade", + "date": "2026-08-07", + "doc_type": "plan", + "epic": "01KZD823EG6R5E1FXFX416RQ0G", + "items": [ + "01KZD823EHQRBSGQ76C4EK1TXT", + "01KZD823EH6PBMN2DRD9WAX52F", + "01KZD823EHZ7W4HG9N5CV4SRMT", + "01KZD823EHXEV8Z72ZCNZA8VZH", + "01KZD823EHMRAZH978P0EYQFB1", + "01KZD823EHMC16WZNM4W193VVX", + "01KZD823EHAFN2YF6WCG2T7XN8", + "01KZD823EHGFVX6NSM2ZT057SY" + ], + "slug": "worklog-0-22-2-upgrade", + "source": "docs/plans/2026-08-07-worklog-0-22-2-upgrade.md", + "status": "active", + "title": "Upgrade the vendored worklog tooling to 0.22.2", + "truth_state": "current", + "wiki_key": "plan/2026-08-07-worklog-0-22-2-upgrade" + }, { "canonical_key": "roadmap", "doc_type": "roadmap", - "generated_at": "2026-08-03T14:05:49Z", + "generated_at": "2026-08-07T04:42:46Z", + "git_hash": "018e71d", "slug": "roadmap", "source": "docs/roadmap.md", - "source_hash": "1bb57982", + "source_hash": "28875fdf", "title": "Roadmap", "truth_state": "current", "wiki": "https://github.com/SpillwaveSolutions/okf-plugin/wiki/Roadmap", diff --git a/docs/.index/plan/2026-08-07-worklog-0-22-2-upgrade.yml b/docs/.index/plan/2026-08-07-worklog-0-22-2-upgrade.yml new file mode 100644 index 0000000..1453ab0 --- /dev/null +++ b/docs/.index/plan/2026-08-07-worklog-0-22-2-upgrade.yml @@ -0,0 +1,5 @@ +canonical_key: plan/2026-08-07-worklog-0-22-2-upgrade +doc_type: plan +status: active +truth_state: current +wiki_key: plan/2026-08-07-worklog-0-22-2-upgrade diff --git a/docs/.index/publish-manifest.json b/docs/.index/publish-manifest.json index a82582f..83ca96b 100644 --- a/docs/.index/publish-manifest.json +++ b/docs/.index/publish-manifest.json @@ -1,10 +1,11 @@ { + "git_hash": "018e71d", "pages": [ { "frozen": false, "page_name": "Home", "render": "as-is", - "render_hash": "6606cabdb615", + "render_hash": "93a4fca03b7f", "source": "docs/.index/rendered/home.md", "title": "Home", "truth_state": "current", @@ -14,7 +15,7 @@ "frozen": false, "page_name": "Index-Decisions", "render": "as-is", - "render_hash": "0625d87a0059", + "render_hash": "a3d86f241eac", "source": "docs/.index/rendered/decisions.md", "title": "Decisions Index", "truth_state": "current", @@ -44,7 +45,7 @@ "frozen": false, "page_name": "Index-Traceability", "render": "as-is", - "render_hash": "ec3355ca118c", + "render_hash": "78ad288c4872", "source": "docs/.index/rendered/traceability.md", "title": "Traceability Index", "truth_state": "current", @@ -54,7 +55,7 @@ "frozen": false, "page_name": "PR-25", "render": "as-is", - "render_hash": "911133cd6f42", + "render_hash": "34a4f676c734", "source": "docs/.index/rendered/prs/25.md", "title": "PR #25", "truth_state": "not tracked", @@ -64,7 +65,7 @@ "frozen": false, "page_name": "PR-26", "render": "as-is", - "render_hash": "9e7932b8e907", + "render_hash": "0b71d8d400fb", "source": "docs/.index/rendered/prs/26.md", "title": "PR #26", "truth_state": "not tracked", @@ -74,7 +75,7 @@ "frozen": false, "page_name": "PR-29", "render": "as-is", - "render_hash": "05fef2022b84", + "render_hash": "cbbb848d0605", "source": "docs/.index/rendered/prs/29.md", "title": "PR #29", "truth_state": "not tracked", @@ -84,7 +85,7 @@ "frozen": false, "page_name": "PR-8", "render": "as-is", - "render_hash": "eec2ec466457", + "render_hash": "d448ca1d522e", "source": "docs/.index/rendered/prs/8.md", "title": "PR #8", "truth_state": "not tracked", @@ -94,7 +95,7 @@ "frozen": false, "page_name": "PR-9", "render": "as-is", - "render_hash": "66f20aa9427f", + "render_hash": "79f2d221efe6", "source": "docs/.index/rendered/prs/9.md", "title": "PR #9", "truth_state": "not tracked", @@ -650,13 +651,114 @@ "truth_state": "done", "wiki_key": "item/01KZ30RXDKQG2CSA2D0TXWJQZZ" }, + { + "frozen": false, + "page_name": "Ticket-01KZD823EG6R5E1FXFX416RQ0G", + "render": "as-is", + "render_hash": "685b165febbd", + "source": "docs/.index/rendered/tickets/01KZD823EG6R5E1FXFX416RQ0G.md", + "title": "Upgrade the vendored worklog tooling to 0.22.2", + "truth_state": "open", + "wiki_key": "item/01KZD823EG6R5E1FXFX416RQ0G" + }, + { + "frozen": false, + "page_name": "Ticket-01KZD823EH6PBMN2DRD9WAX52F", + "render": "as-is", + "render_hash": "eafc2f825dbc", + "source": "docs/.index/rendered/tickets/01KZD823EH6PBMN2DRD9WAX52F.md", + "title": "Complete the three manual steps the upgrade requires", + "truth_state": "done", + "wiki_key": "item/01KZD823EH6PBMN2DRD9WAX52F" + }, + { + "frozen": false, + "page_name": "Ticket-01KZD823EHAFN2YF6WCG2T7XN8", + "render": "as-is", + "render_hash": "06cf46d8d8db", + "source": "docs/.index/rendered/tickets/01KZD823EHAFN2YF6WCG2T7XN8.md", + "title": "Backfill document provenance", + "truth_state": "open", + "wiki_key": "item/01KZD823EHAFN2YF6WCG2T7XN8" + }, + { + "frozen": false, + "page_name": "Ticket-01KZD823EHGFVX6NSM2ZT057SY", + "render": "as-is", + "render_hash": "9a535c4171a7", + "source": "docs/.index/rendered/tickets/01KZD823EHGFVX6NSM2ZT057SY.md", + "title": "Republish and confirm the plan banners are fixed", + "truth_state": "open", + "wiki_key": "item/01KZD823EHGFVX6NSM2ZT057SY" + }, + { + "frozen": false, + "page_name": "Ticket-01KZD823EHMC16WZNM4W193VVX", + "render": "as-is", + "render_hash": "db0532b75b8f", + "source": "docs/.index/rendered/tickets/01KZD823EHMC16WZNM4W193VVX.md", + "title": "Baseline the citation verifier and fix real defects only", + "truth_state": "done", + "wiki_key": "item/01KZD823EHMC16WZNM4W193VVX" + }, + { + "frozen": false, + "page_name": "Ticket-01KZD823EHMRAZH978P0EYQFB1", + "render": "as-is", + "render_hash": "9e6a2895ef0f", + "source": "docs/.index/rendered/tickets/01KZD823EHMRAZH978P0EYQFB1.md", + "title": "Restore merge commits as the pull request merge style", + "truth_state": "open", + "wiki_key": "item/01KZD823EHMRAZH978P0EYQFB1" + }, + { + "frozen": false, + "page_name": "Ticket-01KZD823EHQRBSGQ76C4EK1TXT", + "render": "as-is", + "render_hash": "825ec79d3701", + "source": "docs/.index/rendered/tickets/01KZD823EHQRBSGQ76C4EK1TXT.md", + "title": "Upgrade the vendored tooling to 0.22.2", + "truth_state": "done", + "wiki_key": "item/01KZD823EHQRBSGQ76C4EK1TXT" + }, + { + "frozen": false, + "page_name": "Ticket-01KZD823EHXEV8Z72ZCNZA8VZH", + "render": "as-is", + "render_hash": "79e7ab5630b6", + "source": "docs/.index/rendered/tickets/01KZD823EHXEV8Z72ZCNZA8VZH.md", + "title": "File the missing-module gap upstream", + "truth_state": "open", + "wiki_key": "item/01KZD823EHXEV8Z72ZCNZA8VZH" + }, + { + "frozen": false, + "page_name": "Ticket-01KZD823EHZ7W4HG9N5CV4SRMT", + "render": "as-is", + "render_hash": "657ae593feb9", + "source": "docs/.index/rendered/tickets/01KZD823EHZ7W4HG9N5CV4SRMT.md", + "title": "Install the doc-verify modules the upgrade leaves out", + "truth_state": "done", + "wiki_key": "item/01KZD823EHZ7W4HG9N5CV4SRMT" + }, + { + "frozen": false, + "page_name": "Ticket-01KZD84S62B5TWKQSX4XV9848M", + "render": "as-is", + "render_hash": "fdd7b0ea8660", + "source": "docs/.index/rendered/tickets/01KZD84S62B5TWKQSX4XV9848M.md", + "title": "Restore the repo-local test gates the upgrade removed from pre-commit", + "truth_state": "done", + "wiki_key": "item/01KZD84S62B5TWKQSX4XV9848M" + }, { "banner": "> **Snapshot** of release v0.3.0 (2026-08-01) \u2014 frozen record. The current version is [[Code-Walkthrough]].", "frozen": true, "page_name": "Code-Walkthrough-2026-08-01_v0.3.0-release", "render": "doc+banner", - "render_hash": "dcd5f55faa4f", + "render_hash": "177341b2c0d3", "source": "docs/designs/2026-08-01_v0.3.0-release_code_walkthrough.md", + "source_hash": "faa08e52f899", "title": "Code Walkthrough 2026-08-01 v0.3.0-release", "truth_state": "snapshot", "wiki_key": "design/2026-08-01_v0.3.0-release-code-walkthrough" @@ -666,8 +768,9 @@ "frozen": true, "page_name": "Design-Doc-2026-08-01_v0.3.0-release", "render": "doc+banner", - "render_hash": "f9219caa69d3", + "render_hash": "af483202ba96", "source": "docs/designs/2026-08-01_v0.3.0-release_design_doc.md", + "source_hash": "b04cde22d420", "title": "Design Doc 2026-08-01 v0.3.0-release", "truth_state": "snapshot", "wiki_key": "design/2026-08-01_v0.3.0-release-design-doc" @@ -677,8 +780,9 @@ "frozen": true, "page_name": "Code-Walkthrough-2026-08-02_v0.3.1-release", "render": "doc+banner", - "render_hash": "4bce36bd9df3", + "render_hash": "5be7dfb156eb", "source": "docs/designs/2026-08-02_v0.3.1-release_code_walkthrough.md", + "source_hash": "6dd25f2650aa", "title": "Code Walkthrough 2026-08-02 v0.3.1-release", "truth_state": "snapshot", "wiki_key": "design/2026-08-02_v0.3.1-release-code-walkthrough" @@ -688,8 +792,9 @@ "frozen": true, "page_name": "Design-Doc-2026-08-02_v0.3.1-release", "render": "doc+banner", - "render_hash": "15d4e68bcf9a", + "render_hash": "4b263fe95d87", "source": "docs/designs/2026-08-02_v0.3.1-release_design_doc.md", + "source_hash": "359addc43ee2", "title": "Design Doc 2026-08-02 v0.3.1-release", "truth_state": "snapshot", "wiki_key": "design/2026-08-02_v0.3.1-release-design-doc" @@ -699,8 +804,9 @@ "frozen": true, "page_name": "Code-Walkthrough-2026-08-03_v0.3.2-release", "render": "doc+banner", - "render_hash": "90a7f701a658", + "render_hash": "761a8532ea1e", "source": "docs/designs/2026-08-03_v0.3.2-release_code_walkthrough.md", + "source_hash": "33b5bf63b438", "title": "Code Walkthrough 2026-08-03 v0.3.2-release", "truth_state": "snapshot", "wiki_key": "design/2026-08-03_v0.3.2-release-code-walkthrough" @@ -710,8 +816,9 @@ "frozen": true, "page_name": "Design-Doc-2026-08-03_v0.3.2-release", "render": "doc+banner", - "render_hash": "8a9247d0608a", + "render_hash": "ac05b00d96a5", "source": "docs/designs/2026-08-03_v0.3.2-release_design_doc.md", + "source_hash": "928779e466d0", "title": "Design Doc 2026-08-03 v0.3.2-release", "truth_state": "snapshot", "wiki_key": "design/2026-08-03_v0.3.2-release-design-doc" @@ -721,8 +828,9 @@ "frozen": false, "page_name": "Code-Walkthrough", "render": "doc+banner", - "render_hash": "e2e5f2d045ad", + "render_hash": "9c37b070f38c", "source": "docs/designs/current_code_walkthrough.md", + "source_hash": "fa6cd188c3ee", "title": "Code Walkthrough", "truth_state": "current", "wiki_key": "design/current-code-walkthrough" @@ -732,8 +840,9 @@ "frozen": false, "page_name": "Design-Doc", "render": "doc+banner", - "render_hash": "a6436f355f69", + "render_hash": "cf10a0076d44", "source": "docs/designs/current_design_doc.md", + "source_hash": "671927c0e8fd", "title": "Design Doc", "truth_state": "current", "wiki_key": "design/current-design-doc" @@ -743,8 +852,9 @@ "frozen": false, "page_name": "CLI-Reference", "render": "doc+banner", - "render_hash": "f95b0b46380a", + "render_hash": "3ba9e7ebe0ed", "source": "docs/user_guide/cli-reference.md", + "source_hash": "67635d2a7565", "title": "CLI Reference", "truth_state": "current", "wiki_key": "guide/cli-reference" @@ -754,8 +864,9 @@ "frozen": false, "page_name": "Plugin-Guide", "render": "doc+banner", - "render_hash": "ce0bbd4f133d", + "render_hash": "460b1f65b656", "source": "docs/user_guide/plugin-guide.md", + "source_hash": "9b9acb68ce72", "title": "Plugin Guide", "truth_state": "current", "wiki_key": "guide/plugin-guide" @@ -765,52 +876,69 @@ "frozen": false, "page_name": "User-Guide", "render": "doc+banner", - "render_hash": "f9f1c902315d", + "render_hash": "ecd7dbdca3e4", "source": "docs/user_guide/user-guide.md", + "source_hash": "24db8f5f5da9", "title": "User Guide", "truth_state": "current", "wiki_key": "guide/user-guide" }, { - "banner": "> **Current** \u2014 the latest status report. Reports freeze once published; corrections appear in later reports.", + "banner": "> **Current** \u2014 completed plan; plans are frozen once written, a changed design gets a new plan.", "frozen": true, "page_name": "Plan-wiki-ticket-adoption", "render": "doc+banner", - "render_hash": "d5d8f5d0a566", + "render_hash": "0748f67e0360", "source": "docs/plans/2026-07-29-wiki-ticket-adoption.md", + "source_hash": "ef7a86393ce0", "title": "Adopt WikiTicket SDD for okf-plugin", "truth_state": "current", "wiki_key": "plan/2026-07-29-wiki-ticket-adoption" }, { - "banner": "> **Current** \u2014 the latest status report. Reports freeze once published; corrections appear in later reports.", + "banner": "> **Current** \u2014 completed plan; plans are frozen once written, a changed design gets a new plan.", "frozen": true, "page_name": "Plan-v030-plumbing-and-tests", "render": "doc+banner", - "render_hash": "05c7817da8fe", + "render_hash": "0cf0bcaf4b4c", "source": "docs/plans/2026-08-01-v030-plumbing-and-tests.md", + "source_hash": "35c8fc1fafea", "title": "v0.3.0 \u2014 fix the plumbing, add the net", "truth_state": "current", "wiki_key": "plan/2026-08-01-v030-plumbing-and-tests" }, { - "banner": "> **Current** \u2014 the latest status report. Reports freeze once published; corrections appear in later reports.", + "banner": "> **Current** \u2014 completed plan; plans are frozen once written, a changed design gets a new plan.", "frozen": true, "page_name": "Plan-v031-correctness-and-traceability", "render": "doc+banner", - "render_hash": "21d79c4a20b8", + "render_hash": "3b18e2b19b2d", "source": "docs/plans/2026-08-02-v031-correctness-and-traceability.md", + "source_hash": "2e40af7939af", "title": "v0.3.1 \u2014 correctness cleanup and traceability backfill", "truth_state": "current", "wiki_key": "plan/2026-08-02-v031-correctness-and-traceability" }, { - "banner": "> **Current** \u2014 this is the living version; regenerated at 2026-08-03T14:05:49Z. Historical snapshots are linked from [[Index-Releases]].", + "banner": "> **Current** \u2014 plan in flight; plans are frozen once written, a changed design gets a new plan.", + "frozen": true, + "page_name": "Plan-worklog-0-22-2-upgrade", + "render": "doc+banner", + "render_hash": "6077349815ea", + "source": "docs/plans/2026-08-07-worklog-0-22-2-upgrade.md", + "source_hash": "2c4a6a65c8ef", + "title": "Upgrade the vendored worklog tooling to 0.22.2", + "truth_state": "current", + "wiki_key": "plan/2026-08-07-worklog-0-22-2-upgrade" + }, + { + "banner": "> **Current** \u2014 this is the living version; regenerated at 2026-08-07T04:42:46Z. Historical snapshots are linked from [[Index-Releases]].", "frozen": false, "page_name": "Roadmap", "render": "doc+banner", - "render_hash": "6513c865ef8f", + "render_hash": "e61b5a51f951", "source": "docs/roadmap.md", + "source_hash": "f2b699b31eb7", "title": "Roadmap", "truth_state": "current", "wiki_key": "roadmap" @@ -820,8 +948,9 @@ "frozen": true, "page_name": "Roadmap-2026-08-01_v0.3.0-release", "render": "doc+banner", - "render_hash": "4116641b7631", + "render_hash": "7ca2ea2842f7", "source": "docs/roadmap/2026-08-01_v0.3.0-release.md", + "source_hash": "65538697716e", "title": "Roadmap", "truth_state": "snapshot", "wiki_key": "roadmap-snapshot/2026-08-01_v0.3.0-release" @@ -831,8 +960,9 @@ "frozen": true, "page_name": "Roadmap-2026-08-02_v0.3.1-release", "render": "doc+banner", - "render_hash": "69fe7ab17849", + "render_hash": "b09fa7f38934", "source": "docs/roadmap/2026-08-02_v0.3.1-release.md", + "source_hash": "98fc198a4168", "title": "Roadmap", "truth_state": "snapshot", "wiki_key": "roadmap-snapshot/2026-08-02_v0.3.1-release" @@ -842,8 +972,9 @@ "frozen": true, "page_name": "Roadmap-2026-08-03_v0.3.2-release", "render": "doc+banner", - "render_hash": "cd5515f3f9cd", + "render_hash": "22007b38b7e9", "source": "docs/roadmap/2026-08-03_v0.3.2-release.md", + "source_hash": "65538697716e", "title": "Roadmap", "truth_state": "snapshot", "wiki_key": "roadmap-snapshot/2026-08-03_v0.3.2-release" @@ -853,15 +984,16 @@ "frozen": false, "page_name": "Worklog-Spec", "render": "doc+banner", - "render_hash": "77ec8db0b161", + "render_hash": "5a9d038c92fc", "source": "docs/worklog-spec.md", + "source_hash": "78aee81955ef", "title": "Worklog Spec", "truth_state": "current", "wiki_key": "spec" } ], "sidebar": { - "render_hash": "5dfaae317aa5", + "render_hash": "e4302112c1f2", "source": "docs/.index/rendered/_Sidebar.md" }, "version": 1 diff --git a/docs/.index/rendered/_Sidebar.md b/docs/.index/rendered/_Sidebar.md index 9a9d8c9..52451a1 100644 --- a/docs/.index/rendered/_Sidebar.md +++ b/docs/.index/rendered/_Sidebar.md @@ -2,6 +2,7 @@ - [[Roadmap]] - [[Design-Doc]] · [[Code-Walkthrough]] +- Plan: [[Plan-worklog-0-22-2-upgrade]] ### History diff --git a/docs/.index/rendered/decisions.md b/docs/.index/rendered/decisions.md index 0375bf6..340c8f5 100644 --- a/docs/.index/rendered/decisions.md +++ b/docs/.index/rendered/decisions.md @@ -11,6 +11,7 @@ _Why things are the way they are: ADRs (rules adopted) and plans (designs execut | Plan | Lifecycle | Truth | Date | |---|---|---|---| +| [[Plan-worklog-0-22-2-upgrade]] Upgrade the vendored worklog tooling to 0.22.2 | active | current | 2026-08-07 | | [[Plan-v031-correctness-and-traceability]] v0.3.1 — correctness cleanup and traceability backfill | completed | current | 2026-08-02 | | [[Plan-v030-plumbing-and-tests]] v0.3.0 — fix the plumbing, add the net | completed | current | 2026-08-01 | | [[Plan-wiki-ticket-adoption]] Adopt WikiTicket SDD for okf-plugin | completed | current | 2026-07-29 | diff --git a/docs/.index/rendered/home.md b/docs/.index/rendered/home.md index c4ef737..13c3964 100644 --- a/docs/.index/rendered/home.md +++ b/docs/.index/rendered/home.md @@ -9,7 +9,7 @@ ## What are we working on now? [[Roadmap]] - +Active plans: [[Plan-worklog-0-22-2-upgrade]] ## Why was it built this way? [[Index-Decisions]] — ADRs and plans, with supersede chains diff --git a/docs/.index/rendered/prs/25.md b/docs/.index/rendered/prs/25.md index e861037..27d5232 100644 --- a/docs/.index/rendered/prs/25.md +++ b/docs/.index/rendered/prs/25.md @@ -2,7 +2,7 @@ `pr/25` · status: **not tracked** -Changed files: not tracked. Test/Review status: not tracked (see the deferred PR live-metadata sync item). +Changed files: not tracked. Test/Review status: not tracked — run `worklog pr-sync 25`. ## Linked Tickets diff --git a/docs/.index/rendered/prs/26.md b/docs/.index/rendered/prs/26.md index 0b218bb..dc48255 100644 --- a/docs/.index/rendered/prs/26.md +++ b/docs/.index/rendered/prs/26.md @@ -2,7 +2,7 @@ `pr/26` · status: **not tracked** -Changed files: not tracked. Test/Review status: not tracked (see the deferred PR live-metadata sync item). +Changed files: not tracked. Test/Review status: not tracked — run `worklog pr-sync 26`. ## Linked Tickets diff --git a/docs/.index/rendered/prs/29.md b/docs/.index/rendered/prs/29.md index 4e4fa2d..c54930f 100644 --- a/docs/.index/rendered/prs/29.md +++ b/docs/.index/rendered/prs/29.md @@ -2,7 +2,7 @@ `pr/29` · status: **not tracked** -Changed files: not tracked. Test/Review status: not tracked (see the deferred PR live-metadata sync item). +Changed files: not tracked. Test/Review status: not tracked — run `worklog pr-sync 29`. ## Linked Tickets diff --git a/docs/.index/rendered/prs/8.md b/docs/.index/rendered/prs/8.md index bcd1cea..9d93185 100644 --- a/docs/.index/rendered/prs/8.md +++ b/docs/.index/rendered/prs/8.md @@ -2,7 +2,7 @@ `pr/8` · status: **not tracked** -Changed files: not tracked. Test/Review status: not tracked (see the deferred PR live-metadata sync item). +Changed files: not tracked. Test/Review status: not tracked — run `worklog pr-sync 8`. ## Linked Tickets diff --git a/docs/.index/rendered/prs/9.md b/docs/.index/rendered/prs/9.md index e1463d9..1b8288d 100644 --- a/docs/.index/rendered/prs/9.md +++ b/docs/.index/rendered/prs/9.md @@ -2,7 +2,7 @@ `pr/9` · status: **not tracked** -Changed files: not tracked. Test/Review status: not tracked (see the deferred PR live-metadata sync item). +Changed files: not tracked. Test/Review status: not tracked — run `worklog pr-sync 9`. ## Linked Tickets diff --git a/docs/.index/rendered/tickets/01KZD823EG6R5E1FXFX416RQ0G.md b/docs/.index/rendered/tickets/01KZD823EG6R5E1FXFX416RQ0G.md new file mode 100644 index 0000000..2d84759 --- /dev/null +++ b/docs/.index/rendered/tickets/01KZD823EG6R5E1FXFX416RQ0G.md @@ -0,0 +1,27 @@ +# Upgrade the vendored worklog tooling to 0.22.2 + +`01KZD823EG6R5E1FXFX416RQ0G` · epic/feature · **open** + +Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. + +## Children + +- [[Ticket-01KZD823EH6PBMN2DRD9WAX52F]] Complete the three manual steps the upgrade requires — Add .work/.sessions to .gitignore, since it is local advisory state that must +never be committed and is not currently ignored. (done) +- [[Ticket-01KZD823EHAFN2YF6WCG2T7XN8]] Backfill document provenance — Add the merge commit that brought each frozen document to the default branch. (open) +- [[Ticket-01KZD823EHGFVX6NSM2ZT057SY]] Republish and confirm the plan banners are fixed — Publish first and then converge the index, because publishing rewrites the +published ledger that the normalizer self-describes. (open) +- [[Ticket-01KZD823EHMC16WZNM4W193VVX]] Baseline the citation verifier and fix real defects only — Run doc-verify once and separate fabricated citations, which were already +wrong in the tree the author had open, from drift, which was correct when +written and is expected in a frozen document. (done) +- [[Ticket-01KZD823EHMRAZH978P0EYQFB1]] Restore merge commits as the pull request merge style — This repo used merge commits through pull request 37 and was switched to +squash during the v0.3.2 work without the change being flagged. (open) +- [[Ticket-01KZD823EHQRBSGQ76C4EK1TXT]] Upgrade the vendored tooling to 0.22.2 — Run the init command, which is the documented and idempotent upgrade path. (done) +- [[Ticket-01KZD823EHXEV8Z72ZCNZA8VZH]] File the missing-module gap upstream — Report that init.sh omits doc_verify.py and provenance.py while pre-commit +references the first, and that the file-exists guard makes the omission +silent. (open) +- [[Ticket-01KZD823EHZ7W4HG9N5CV4SRMT]] Install the doc-verify modules the upgrade leaves out — The pre-commit hook calls doc-verify behind a guard that tests whether +bin/doc_verify.py exists, but init does not copy that module or provenance.py. (done) +- [[Ticket-01KZD84S62B5TWKQSX4XV9848M]] Restore the repo-local test gates the upgrade removed from pre-commit — The upgrade rewrites hooks/pre-commit wholesale, which deleted the two repo-local gate lines this project added in v0.3.0 and v0.3.1: the graph engine test suite and the post-edit hook shell test. (done) + +Progress: 5/9 done diff --git a/docs/.index/rendered/tickets/01KZD823EH6PBMN2DRD9WAX52F.md b/docs/.index/rendered/tickets/01KZD823EH6PBMN2DRD9WAX52F.md new file mode 100644 index 0000000..d1758ec --- /dev/null +++ b/docs/.index/rendered/tickets/01KZD823EH6PBMN2DRD9WAX52F.md @@ -0,0 +1,10 @@ +# Complete the three manual steps the upgrade requires + +`01KZD823EH6PBMN2DRD9WAX52F` · task/feature · **done** + +Add .work/.sessions to .gitignore, since it is local advisory state that must +never be committed and is not currently ignored. + +## Hierarchy + +- epic: [[Ticket-01KZD823EG6R5E1FXFX416RQ0G]] Upgrade the vendored worklog tooling to 0.22.2 — Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. diff --git a/docs/.index/rendered/tickets/01KZD823EHAFN2YF6WCG2T7XN8.md b/docs/.index/rendered/tickets/01KZD823EHAFN2YF6WCG2T7XN8.md new file mode 100644 index 0000000..d17f521 --- /dev/null +++ b/docs/.index/rendered/tickets/01KZD823EHAFN2YF6WCG2T7XN8.md @@ -0,0 +1,9 @@ +# Backfill document provenance + +`01KZD823EHAFN2YF6WCG2T7XN8` · task/feature · **open** + +Add the merge commit that brought each frozen document to the default branch. + +## Hierarchy + +- epic: [[Ticket-01KZD823EG6R5E1FXFX416RQ0G]] Upgrade the vendored worklog tooling to 0.22.2 — Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. diff --git a/docs/.index/rendered/tickets/01KZD823EHGFVX6NSM2ZT057SY.md b/docs/.index/rendered/tickets/01KZD823EHGFVX6NSM2ZT057SY.md new file mode 100644 index 0000000..6e636a3 --- /dev/null +++ b/docs/.index/rendered/tickets/01KZD823EHGFVX6NSM2ZT057SY.md @@ -0,0 +1,10 @@ +# Republish and confirm the plan banners are fixed + +`01KZD823EHGFVX6NSM2ZT057SY` · task/feature · **open** + +Publish first and then converge the index, because publishing rewrites the +published ledger that the normalizer self-describes. + +## Hierarchy + +- epic: [[Ticket-01KZD823EG6R5E1FXFX416RQ0G]] Upgrade the vendored worklog tooling to 0.22.2 — Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. diff --git a/docs/.index/rendered/tickets/01KZD823EHMC16WZNM4W193VVX.md b/docs/.index/rendered/tickets/01KZD823EHMC16WZNM4W193VVX.md new file mode 100644 index 0000000..1fa8d9b --- /dev/null +++ b/docs/.index/rendered/tickets/01KZD823EHMC16WZNM4W193VVX.md @@ -0,0 +1,11 @@ +# Baseline the citation verifier and fix real defects only + +`01KZD823EHMC16WZNM4W193VVX` · task/feature · **done** + +Run doc-verify once and separate fabricated citations, which were already +wrong in the tree the author had open, from drift, which was correct when +written and is expected in a frozen document. + +## Hierarchy + +- epic: [[Ticket-01KZD823EG6R5E1FXFX416RQ0G]] Upgrade the vendored worklog tooling to 0.22.2 — Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. diff --git a/docs/.index/rendered/tickets/01KZD823EHMRAZH978P0EYQFB1.md b/docs/.index/rendered/tickets/01KZD823EHMRAZH978P0EYQFB1.md new file mode 100644 index 0000000..1e5f3e5 --- /dev/null +++ b/docs/.index/rendered/tickets/01KZD823EHMRAZH978P0EYQFB1.md @@ -0,0 +1,10 @@ +# Restore merge commits as the pull request merge style + +`01KZD823EHMRAZH978P0EYQFB1` · task/feature · **open** + +This repo used merge commits through pull request 37 and was switched to +squash during the v0.3.2 work without the change being flagged. + +## Hierarchy + +- epic: [[Ticket-01KZD823EG6R5E1FXFX416RQ0G]] Upgrade the vendored worklog tooling to 0.22.2 — Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. diff --git a/docs/.index/rendered/tickets/01KZD823EHQRBSGQ76C4EK1TXT.md b/docs/.index/rendered/tickets/01KZD823EHQRBSGQ76C4EK1TXT.md new file mode 100644 index 0000000..4a38eec --- /dev/null +++ b/docs/.index/rendered/tickets/01KZD823EHQRBSGQ76C4EK1TXT.md @@ -0,0 +1,9 @@ +# Upgrade the vendored tooling to 0.22.2 + +`01KZD823EHQRBSGQ76C4EK1TXT` · task/feature · **done** + +Run the init command, which is the documented and idempotent upgrade path. + +## Hierarchy + +- epic: [[Ticket-01KZD823EG6R5E1FXFX416RQ0G]] Upgrade the vendored worklog tooling to 0.22.2 — Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. diff --git a/docs/.index/rendered/tickets/01KZD823EHXEV8Z72ZCNZA8VZH.md b/docs/.index/rendered/tickets/01KZD823EHXEV8Z72ZCNZA8VZH.md new file mode 100644 index 0000000..4af1890 --- /dev/null +++ b/docs/.index/rendered/tickets/01KZD823EHXEV8Z72ZCNZA8VZH.md @@ -0,0 +1,11 @@ +# File the missing-module gap upstream + +`01KZD823EHXEV8Z72ZCNZA8VZH` · task/feature · **open** + +Report that init.sh omits doc_verify.py and provenance.py while pre-commit +references the first, and that the file-exists guard makes the omission +silent. + +## Hierarchy + +- epic: [[Ticket-01KZD823EG6R5E1FXFX416RQ0G]] Upgrade the vendored worklog tooling to 0.22.2 — Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. diff --git a/docs/.index/rendered/tickets/01KZD823EHZ7W4HG9N5CV4SRMT.md b/docs/.index/rendered/tickets/01KZD823EHZ7W4HG9N5CV4SRMT.md new file mode 100644 index 0000000..d2b96c6 --- /dev/null +++ b/docs/.index/rendered/tickets/01KZD823EHZ7W4HG9N5CV4SRMT.md @@ -0,0 +1,10 @@ +# Install the doc-verify modules the upgrade leaves out + +`01KZD823EHZ7W4HG9N5CV4SRMT` · task/feature · **done** + +The pre-commit hook calls doc-verify behind a guard that tests whether +bin/doc_verify.py exists, but init does not copy that module or provenance.py. + +## Hierarchy + +- epic: [[Ticket-01KZD823EG6R5E1FXFX416RQ0G]] Upgrade the vendored worklog tooling to 0.22.2 — Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. diff --git a/docs/.index/rendered/tickets/01KZD84S62B5TWKQSX4XV9848M.md b/docs/.index/rendered/tickets/01KZD84S62B5TWKQSX4XV9848M.md new file mode 100644 index 0000000..c3f43c2 --- /dev/null +++ b/docs/.index/rendered/tickets/01KZD84S62B5TWKQSX4XV9848M.md @@ -0,0 +1,9 @@ +# Restore the repo-local test gates the upgrade removed from pre-commit + +`01KZD84S62B5TWKQSX4XV9848M` · task/bug · **done** + +The upgrade rewrites hooks/pre-commit wholesale, which deleted the two repo-local gate lines this project added in v0.3.0 and v0.3.1: the graph engine test suite and the post-edit hook shell test. + +## Hierarchy + +- epic: [[Ticket-01KZD823EG6R5E1FXFX416RQ0G]] Upgrade the vendored worklog tooling to 0.22.2 — Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. diff --git a/docs/.index/rendered/traceability.md b/docs/.index/rendered/traceability.md index 2c32264..6dae81e 100644 --- a/docs/.index/rendered/traceability.md +++ b/docs/.index/rendered/traceability.md @@ -2,6 +2,63 @@ _The evidence chain: plan → item → ticket → code → release, forward and backward. Generated from `docs/.index/_graph.json`; do not edit._ +### Restore the repo-local test gates the upgrade removed from pre-commit +`01KZD84S62B5TWKQSX4XV9848M` · status: done +- belongs-to: Upgrade the vendored worklog tooling to 0.22.2 + +### Install the doc-verify modules the upgrade leaves out +`01KZD823EHZ7W4HG9N5CV4SRMT` · status: done +- belongs-to: Upgrade the vendored worklog tooling to 0.22.2 +- produced-by: [[Plan-worklog-0-22-2-upgrade]] + +### File the missing-module gap upstream +`01KZD823EHXEV8Z72ZCNZA8VZH` · status: todo +- belongs-to: Upgrade the vendored worklog tooling to 0.22.2 +- produced-by: [[Plan-worklog-0-22-2-upgrade]] + +### Upgrade the vendored tooling to 0.22.2 +`01KZD823EHQRBSGQ76C4EK1TXT` · status: done +- belongs-to: Upgrade the vendored worklog tooling to 0.22.2 +- produced-by: [[Plan-worklog-0-22-2-upgrade]] + +### Restore merge commits as the pull request merge style +`01KZD823EHMRAZH978P0EYQFB1` · status: todo +- belongs-to: Upgrade the vendored worklog tooling to 0.22.2 +- produced-by: [[Plan-worklog-0-22-2-upgrade]] + +### Baseline the citation verifier and fix real defects only +`01KZD823EHMC16WZNM4W193VVX` · status: done +- belongs-to: Upgrade the vendored worklog tooling to 0.22.2 +- produced-by: [[Plan-worklog-0-22-2-upgrade]] + +### Republish and confirm the plan banners are fixed +`01KZD823EHGFVX6NSM2ZT057SY` · status: todo +- belongs-to: Upgrade the vendored worklog tooling to 0.22.2 +- produced-by: [[Plan-worklog-0-22-2-upgrade]] + +### Backfill document provenance +`01KZD823EHAFN2YF6WCG2T7XN8` · status: todo +- belongs-to: Upgrade the vendored worklog tooling to 0.22.2 +- produced-by: [[Plan-worklog-0-22-2-upgrade]] + +### Complete the three manual steps the upgrade requires +`01KZD823EH6PBMN2DRD9WAX52F` · status: done +- belongs-to: Upgrade the vendored worklog tooling to 0.22.2 +- produced-by: [[Plan-worklog-0-22-2-upgrade]] + +### Upgrade the vendored worklog tooling to 0.22.2 +`01KZD823EG6R5E1FXFX416RQ0G` · status: todo +- contains: Complete the three manual steps the upgrade requires +- contains: Backfill document provenance +- contains: Republish and confirm the plan banners are fixed +- contains: Baseline the citation verifier and fix real defects only +- contains: Restore merge commits as the pull request merge style +- contains: Upgrade the vendored tooling to 0.22.2 +- contains: File the missing-module gap upstream +- contains: Install the doc-verify modules the upgrade leaves out +- contains: Restore the repo-local test gates the upgrade removed from pre-commit +- produced-by: [[Plan-worklog-0-22-2-upgrade]] + ### Release v0.3.2 `01KZ30RXDKQG2CSA2D0TXWJQZZ` · status: done - targets: release/v0.3.2 diff --git a/docs/designs/current_code_walkthrough.md b/docs/designs/current_code_walkthrough.md index 4594399..fa8ef8c 100644 --- a/docs/designs/current_code_walkthrough.md +++ b/docs/designs/current_code_walkthrough.md @@ -335,7 +335,9 @@ like `2024/notes.md` gets an `n` prefix). Both are asserted directly: assert mid.replace("_", "").isalnum(), mid assert not mid[0].isdigit(), mid ``` -`tests/test_okf_graph.py — test_mermaid_ids_are_unique_per_path(), lines 153–156` +`tests/test_okf_graph.py — test_mermaid_ids_are_unique_per_path(), lines 146–158` +(the excerpt above is the safety loop at 153–156; the citation names the whole +test, which is the unit the contract resolves) `render_mermaid()` (174–190) makes two passes: declare each node once with its title as the label, then emit the edges, labelling only relations other than the diff --git a/docs/plans/2026-08-07-worklog-0-22-2-upgrade.md b/docs/plans/2026-08-07-worklog-0-22-2-upgrade.md new file mode 100644 index 0000000..91ce7c8 --- /dev/null +++ b/docs/plans/2026-08-07-worklog-0-22-2-upgrade.md @@ -0,0 +1,87 @@ +--- +date: 2026-08-07 +slug: worklog-0-22-2-upgrade +title: Upgrade the vendored worklog tooling to 0.22.2 +epic: 01KZD823EG6R5E1FXFX416RQ0G +items: [01KZD823EHQRBSGQ76C4EK1TXT, 01KZD823EH6PBMN2DRD9WAX52F, 01KZD823EHZ7W4HG9N5CV4SRMT, 01KZD823EHXEV8Z72ZCNZA8VZH, 01KZD823EHMRAZH978P0EYQFB1, 01KZD823EHMC16WZNM4W193VVX, 01KZD823EHAFN2YF6WCG2T7XN8, 01KZD823EHGFVX6NSM2ZT057SY] +--- + +# Upgrade the vendored worklog tooling 0.18.0 to 0.22.2 + +## Why + +The backlog is empty and the only outstanding thread was upstream +wiki_ticket_sdd#292 — the plan-banner defect filed rather than patched locally, +because bin/ia_render.py is vendored and init.sh overwrites it unconditionally. +That issue is now closed, fixed in worklog 0.20.0. + +Pulling the fix in means upgrading: the vendored copy here is on 0.18.0 against +upstream 0.22.2, four minor versions, with ten of eleven vendored files changed. + +The payoff is visible on the wiki. All three plan pages read "the latest status +report" while all three are status: completed. After the upgrade they name their +actual state. The same release also stops banner() defaulting kind on status +records, so broken data raises instead of rendering plausible prose over it. + +Two upstream warnings were checked against this repo and do not apply: the +mid-file __main__ block that silently drops tests, and the hook manifest missing +its wrapper. Both verified clean here rather than assumed. + +## Context + +- Upstream: https://github.com/SpillwaveSolutions/wiki_ticket_sdd +- Fix we are pulling in: wiki_ticket_sdd#292, shipped in worklog 0.20.0 +- The event log format changed additively; mixed versions are safe in both + directions, so there is no migration and no backfill. + +## Tasks + +- [ ] (P1) Upgrade the vendored tooling to 0.22.2 + Run the init command, which is the documented and idempotent upgrade path. It + re-copies bin/ and hooks/ and adds four modules this repo lacks: session.py, + item_fields.py, wiki_flavor.py and changelog.py. Confirm the version actually + moved afterwards rather than assuming the copy happened. + +- [ ] (P2) Complete the three manual steps the upgrade requires + Add .work/.sessions to .gitignore, since it is local advisory state that must + never be committed and is not currently ignored. Wire the SessionEnd hook, + which means creating a project settings file that does not exist here yet. + Confirm the hooks path still points at this repo's hooks directory. + +- [ ] (P1) Install the doc-verify modules the upgrade leaves out + The pre-commit hook calls doc-verify behind a guard that tests whether + bin/doc_verify.py exists, but init does not copy that module or provenance.py. + The result is a gate that is present in the hook, never runs, and warns about + nothing. This is the third time this project has met a check that was + configured but could not fire. Copy both modules so the gate works, and mark + the deviation from init's file set clearly so it can be removed later. + +- [ ] (P2) File the missing-module gap upstream + Report that init.sh omits doc_verify.py and provenance.py while pre-commit + references the first, and that the file-exists guard makes the omission + silent. Include the repro and note that uninstall.sh omits them too. + +- [ ] (P2) Restore merge commits as the pull request merge style + This repo used merge commits through pull request 37 and was switched to + squash during the v0.3.2 work without the change being flagged. Upstream + ADR-0008 records that document provenance depends on merge commits, because + under squash the authoring commit never reaches the default branch and the + citation verifier loses its ground truth. Record the reason so the convention + is not quietly reversed later. + +- [ ] (P2) Baseline the citation verifier and fix real defects only + Run doc-verify once and separate fabricated citations, which were already + wrong in the tree the author had open, from drift, which was correct when + written and is expected in a frozen document. Fix the fabrications. Leave + drift alone in frozen documents. + +- [ ] (P3) Backfill document provenance + Add the merge commit that brought each frozen document to the default branch. + +- [ ] (P1) Republish and confirm the plan banners are fixed + Publish first and then converge the index, because publishing rewrites the + published ledger that the normalizer self-describes. Expect only the plan + pages to republish, not the whole site. Confirm the three plan pages name + their state, and confirm the plugin's own test suite and sample bundle are + completely unaffected — a tooling upgrade that changes graph engine behaviour + would itself be a bug. diff --git a/docs/roadmap.md b/docs/roadmap.md index 12d73f7..e7aefbf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,8 +2,9 @@ wiki_key: roadmap doc_type: roadmap truth_state: current -source_hash: 1bb57982 -generated_at: 2026-08-03T14:05:49Z +source_hash: 28875fdf +generated_at: 2026-08-07T04:42:46Z +git_hash: "018e71d" --- @@ -13,7 +14,7 @@ generated_at: 2026-08-03T14:05:49Z # Roadmap -_0 epic(s) in flight, 0 open item(s), 0 blocked, 0 unclassified._ +_1 epic(s) in flight, 4 open item(s), 0 blocked, 0 unclassified._ ## Now @@ -21,8 +22,54 @@ _Nothing here._ ## Next -_Nothing here._ +### Upgrade the vendored worklog tooling to 0.22.2 · P2 · 5 of 9 done · feature 8 / bug 1 +Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. Includes installing two modules the upstream installer omits, restoring merge commits so document provenance can be resolved, and republishing the wiki so plan pages stop announcing themselves as status reports. + +| # | Item | Type | Priority | Status | Blocked by | +|---|---|---|---|---|---| +| 01KZD823 | Republish and confirm the plan banners are fixed | task | P1 | todo | — | +| 01KZD823 | Restore merge commits as the pull request merge style | task | P2 | todo | — | +| 01KZD823 | File the missing-module gap upstream | task | P2 | todo | — | ## Later -_Nothing here._ +### Upgrade the vendored worklog tooling to 0.22.2 · P2 · 5 of 9 done · feature 8 / bug 1 +Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. Includes installing two modules the upstream installer omits, restoring merge commits so document provenance can be resolved, and republishing the wiki so plan pages stop announcing themselves as status reports. + +| # | Item | Type | Priority | Status | Blocked by | +|---|---|---|---|---|---| +| 01KZD823 | Backfill document provenance | task | P3 | todo | — | + +## Visual roadmap + +### Dependency graph + +```mermaid +graph TD + 01KZD823EG6R5E1FXFX416RQ0G["📦 Upgrade the vendored worklog t"] + 01KZD823EHAFN2YF6WCG2T7XN8["📦 Backfill document provenance"] + 01KZD823EHGFVX6NSM2ZT057SY["📦 Republish and confirm the plan"] + 01KZD823EHMRAZH978P0EYQFB1["📦 Restore merge commits as the p"] + 01KZD823EHXEV8Z72ZCNZA8VZH["📦 File the missing-module gap up"] + 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHAFN2YF6WCG2T7XN8 + 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHGFVX6NSM2ZT057SY + 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHMRAZH978P0EYQFB1 + 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHXEV8Z72ZCNZA8VZH + classDef todo fill:#f4f4f4,stroke:#999999 + class 01KZD823EG6R5E1FXFX416RQ0G,01KZD823EHAFN2YF6WCG2T7XN8,01KZD823EHGFVX6NSM2ZT057SY,01KZD823EHMRAZH978P0EYQFB1,01KZD823EHXEV8Z72ZCNZA8VZH todo +``` + +### Hierarchy + +```mermaid +graph TD + 01KZD823EG6R5E1FXFX416RQ0G["📦 Upgrade the vendored worklog t"] + 01KZD823EHAFN2YF6WCG2T7XN8["📦 Backfill document provenance"] + 01KZD823EHGFVX6NSM2ZT057SY["📦 Republish and confirm the plan"] + 01KZD823EHMRAZH978P0EYQFB1["📦 Restore merge commits as the p"] + 01KZD823EHXEV8Z72ZCNZA8VZH["📦 File the missing-module gap up"] + 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHAFN2YF6WCG2T7XN8 + 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHGFVX6NSM2ZT057SY + 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHMRAZH978P0EYQFB1 + 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHXEV8Z72ZCNZA8VZH +``` diff --git a/hooks/pre-commit b/hooks/pre-commit index 0fe35a5..ab0620d 100755 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -33,6 +33,24 @@ if [ -z "${WORKLOG_SKIP_BRANCH_GUARD:-}" ] && \ esac fi +# Conflict markers, in ANY staged file, with no merge exemption (01KYZEC0C1). +# A merge is exactly when this happens: commit-msg exempts merge commits from +# its item-reference rule and nothing else parses tests/ or plugin/, so a +# resolution that missed a hunk committed cleanly and was only found by +# running the suite. Staged content only -- an unstaged conflict elsewhere in +# the tree is not this commit's problem. +conflicted=$(git diff --cached --name-only --diff-filter=ACM | + while IFS= read -r f; do + [ -f "$f" ] || continue + if git show ":$f" 2>/dev/null | + grep -qE '^(<{7}|={7}|>{7})( |$)'; then echo "$f"; fi + done) +if [ -n "$conflicted" ]; then + fail "unresolved conflict markers in staged files: +$(echo "$conflicted" | sed 's/^/ /') +Resolve them, re-stage, and commit again." +fi + for f in .work/todo.jsonl .work/done.jsonl; do [ -f "$f" ] || continue @@ -75,6 +93,17 @@ sys.exit(0 if ok else 1) PY done +# Merge integrity guard (#243 + #237): a union merge can resurrect the very +# lines a compaction just deleted, or land two branches that each claimed the +# same external ticket. Only meaningful when a merge is in flight -- same +# signal the branch guard above uses (WORKLOG_MERGE_COMMIT for a fresh `git +# merge`, MERGE_HEAD for a resumed one). +if { [ -n "${WORKLOG_MERGE_COMMIT:-}" ] || [ -f "$(git rev-parse --git-path MERGE_HEAD)" ]; } \ + && [ -f bin/compact.py ]; then + python3 bin/compact.py --merge-check .work/todo.jsonl .work/done.jsonl || \ + fail "merge integrity check failed (see above) -- resolve manually, then git merge --continue, or git merge --abort" +fi + # Invariant 15.7: the roadmap is generated. Regenerate and diff. # mktemp, not a fixed path: two checkouts running this concurrently must not # race on the same scratch file. @@ -91,26 +120,45 @@ fi # Guarded: scaffolded repos have no tests/ (the suite lives in the source repo). [ ! -f tests/test_fold.py ] || python3 tests/test_fold.py -q >/dev/null 2>&1 || fail "fold tests failing" -# Graph engine tests, same guarded pattern. +# --- okf-plugin local gates (01KZD84S62B5TWKQSX4XV9848M) ------------------- +# This file is vendored: `worklog init` rewrites it wholesale on every upgrade, +# which silently deleted these two lines going 0.18.0 -> 0.22.2. They are +# restored here because there is no extension point to put them anywhere else. +# What stops the next upgrade from eating them quietly is not this comment -- +# it is test_pre_commit_keeps_the_local_gates() in tests/test_okf_graph.py, +# which CI runs directly and which fails if either line goes missing. [ ! -f tests/test_okf_graph.py ] || python3 tests/test_okf_graph.py -q >/dev/null 2>&1 || fail "graph tests failing — run: python3 tests/test_okf_graph.py" - -# Post-edit hook tests, same guarded pattern. [ ! -f tests/test_okf_curate.sh ] || bash tests/test_okf_curate.sh >/dev/null 2>&1 || fail "curate tests failing — run: bash tests/test_okf_curate.sh" +# --- end okf-plugin local gates -------------------------------------------- # ADRs: schema + cross-file invariants. Guarded like the fold tests. [ ! -d docs/adr ] || [ ! -x bin/worklog ] || python3 bin/worklog adr check >/dev/null || fail "ADR validation failed — run: worklog adr check" # IA gates (plan ia-content-model, migration 0002): wiki_key present/unique, -# schema-valid frontmatter, fresh inventory. WARN-ONLY for one release cycle, -# then promoted to hard fail. Guarded like the fold tests. -if [ -f bin/ia.py ] && [ -x bin/worklog ]; then +# schema-valid frontmatter, fresh inventory. The warn-only release cycle is +# over — these are hard now (#98). +# +# The extra `-d docs/.index` guard is what makes promotion safe: it asks "has +# this repo opted INTO the IA?", where the bin/ia.py guard only asks "does it +# have the code?". A scaffolded repo gets bin/ from the plugin but has never +# generated an index, and must not be blocked from its first commit. A repo +# that HAS an index stays fully enforced — deleting any single generated file +# inside it still fails, because the directory is still there. +if [ -f bin/ia.py ] && [ -x bin/worklog ] && [ -d docs/.index ]; 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 + fail "doc metadata drift; run: worklog ia-normalize" 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 + fail "inventory stale/invalid; run: worklog ia-inventory" [ ! -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 + fail "rendered pages/manifest stale; run: worklog ia-render" # 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 + # doc-verify, same tier and for the same reason: it reports on documents + # this commit may not touch, so blocking here would punish the wrong + # commit. --strict runs at release time. It is also skipped outright on a + # shallow clone, where `git show :path` cannot resolve and every + # answer would be "unresolvable" (#294). + [ ! -f bin/doc_verify.py ] || python3 bin/worklog doc-verify --strict >/dev/null 2>&1 || \ + echo "worklog: WARNING — document citations do not match the commit they were written against; run: worklog doc-verify" >&2 fi diff --git a/hooks/session-end.sh b/hooks/session-end.sh new file mode 100755 index 0000000..5a13312 --- /dev/null +++ b/hooks/session-end.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Plugin hooks fire in every session; silence outside worklog repos. +[ -x bin/worklog ] || exit 0 + +# Drop this session from the concurrent-session registry (#236). Without it a +# finished session keeps looking alive for a full window and warns the next +# one that opens the directory — an advisory that cries wolf gets ignored, +# which is the same as not having it. +python3 - <<'PY' +import json, os, sys + +sys.path.insert(0, os.path.join(os.getcwd(), "bin")) +try: + payload = json.load(sys.stdin) +except (ValueError, OSError): + payload = {} +try: + import session + session.end(payload.get("session_id")) +except Exception: + pass +PY diff --git a/tests/test_okf_graph.py b/tests/test_okf_graph.py index afaa28d..d64d27a 100644 --- a/tests/test_okf_graph.py +++ b/tests/test_okf_graph.py @@ -421,6 +421,25 @@ def test_version_is_consistent_across_manifests(): assert len(set(found.values())) == 1, f"version drift: {found}" +def test_pre_commit_keeps_the_local_gates(): + """hooks/pre-commit is vendored — `worklog init` rewrites it wholesale on + every upgrade, and doing so silently deleted both of these lines going + 0.18.0 -> 0.22.2 (01KZD84S62B5TWKQSX4XV9848M). There is no extension point + to move them to, so the loss cannot be prevented; this makes it loud. + + CI runs this file directly, not through the hook, so this assert still + fires in the very case where the hook itself has been clobbered.""" + hook = REPO / "hooks" / "pre-commit" + if not hook.exists(): + return + body = hook.read_text() + for suite in ("tests/test_okf_graph.py", "tests/test_okf_curate.sh"): + assert suite in body, ( + f"{suite} is not gated in hooks/pre-commit — a worklog upgrade " + "likely overwrote the file. Restore the okf-plugin local gates block." + ) + + def main() -> int: quiet = "-q" in sys.argv tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] From fdf3f111bf491cdd0727346b49f871926db70091 Mon Sep 17 00:00:00 2001 From: Rick Hightower Date: Thu, 6 Aug 2026 23:45:31 -0500 Subject: [PATCH 2/2] chore: record the merge-commit rule and backfill document provenance (01KZD823EG6R5E1FXFX416RQ0G) Adds working rule 9 to both host guides: merge PRs with a merge commit, not a squash. Frozen documents are stamped with the commit they were written against and doc-verify resolves their citations at that commit, so a squash keeps the authoring commit off the default branch and a fresh clone cannot resolve it. The rule names PRs #38-#45 as squashed-before-this-was-understood so they are not read as precedent. provenance-backfill stamps merged_in on 12 frozen documents. Filed two upstream issues from what this upgrade surfaced: wiki_ticket_sdd#344 (init.sh omits doc_verify.py, provenance.py and session-end.sh, each silently) and #345 (doc-verify --strict cannot pass once a frozen document carries a fabricated citation, which is true of upstream's own repo at 48 fabrications). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WMk8bhkMEUWfHi7VYzMzFX --- .work/todo.jsonl | 3 ++ AGENTS.md | 4 +++ CLAUDE.md | 11 +++++++ docs/.index/_graph.json | 6 ++-- docs/.index/_inventory.json | 18 ++++++++-- docs/.index/publish-manifest.json | 24 +++++++------- .../tickets/01KZD823EG6R5E1FXFX416RQ0G.md | 8 ++--- .../tickets/01KZD823EHAFN2YF6WCG2T7XN8.md | 2 +- .../tickets/01KZD823EHMRAZH978P0EYQFB1.md | 2 +- .../tickets/01KZD823EHXEV8Z72ZCNZA8VZH.md | 2 +- docs/.index/rendered/traceability.md | 6 ++-- ...6-08-01_v0.3.0-release_code_walkthrough.md | 1 + .../2026-08-01_v0.3.0-release_design_doc.md | 1 + ...6-08-02_v0.3.1-release_code_walkthrough.md | 1 + .../2026-08-02_v0.3.1-release_design_doc.md | 1 + ...6-08-03_v0.3.2-release_code_walkthrough.md | 1 + .../2026-08-03_v0.3.2-release_design_doc.md | 1 + docs/plans/2026-07-29-wiki-ticket-adoption.md | 1 + .../2026-08-01-v030-plumbing-and-tests.md | 1 + ...08-02-v031-correctness-and-traceability.md | 1 + docs/roadmap.md | 33 ++++--------------- docs/roadmap/2026-08-01_v0.3.0-release.md | 1 + docs/roadmap/2026-08-02_v0.3.1-release.md | 1 + docs/roadmap/2026-08-03_v0.3.2-release.md | 1 + 24 files changed, 77 insertions(+), 54 deletions(-) diff --git a/.work/todo.jsonl b/.work/todo.jsonl index ace2ab2..ecabdea 100644 --- a/.work/todo.jsonl +++ b/.work/todo.jsonl @@ -159,3 +159,6 @@ {"actor":"richardhightower","ev":"01KZD8FRT07ZPMGVHYZP40NVW0","git":"018e71d","item":"01KZD823EHZ7W4HG9N5CV4SRMT","op":"close","set":{"status":"done"},"ts":"2026-08-07T04:42:46Z"} {"actor":"richardhightower","ev":"01KZD8FRZ35SQ4YBNQ9S3TMW8D","git":"018e71d","item":"01KZD84S62B5TWKQSX4XV9848M","op":"close","set":{"status":"done"},"ts":"2026-08-07T04:42:46Z"} {"actor":"richardhightower","ev":"01KZD8FS43AJA5BF24K9ANT7AC","git":"018e71d","item":"01KZD823EHMC16WZNM4W193VVX","op":"close","set":{"status":"done"},"ts":"2026-08-07T04:42:46Z"} +{"actor":"richardhightower","ev":"01KZD8M7Z3J3C13VVYNQ9SRNC2","git":"73d7ab4","item":"01KZD823EHMRAZH978P0EYQFB1","op":"close","set":{"status":"done"},"ts":"2026-08-07T04:45:13Z"} +{"actor":"richardhightower","ev":"01KZD8M84G9ABMSW882RX81ZAC","git":"73d7ab4","item":"01KZD823EHXEV8Z72ZCNZA8VZH","op":"close","set":{"status":"done"},"ts":"2026-08-07T04:45:13Z"} +{"actor":"richardhightower","ev":"01KZD8M89ZAVFV527JXNTYDAYF","git":"73d7ab4","item":"01KZD823EHAFN2YF6WCG2T7XN8","op":"close","set":{"status":"done"},"ts":"2026-08-07T04:45:13Z"} diff --git a/AGENTS.md b/AGENTS.md index c607cca..12d6c91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,10 @@ Plugin root variable in Claude/Grok plugin context: `${CLAUDE_PLUGIN_ROOT}`. repo default branch rather than the branch you were told to build on, so the commits you depend on can be missing. `git reset --hard ` while the tree is clean, then build. +9. Merge PRs with `gh pr merge --merge`, never `--squash`. Frozen documents are + stamped with the commit they were written against and `worklog doc-verify` + resolves their code citations at that commit; a squash keeps that commit off + the default branch, so a fresh clone cannot resolve it. ## Skill routing (natural language) diff --git a/CLAUDE.md b/CLAUDE.md index ecbcef0..87dc9dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,17 @@ Use `${CLAUDE_PLUGIN_ROOT}` for all intra-plugin paths in hooks and skill instru If not, `git reset --hard ` before writing code — a working tree with no commits of its own is safe to move. Skipping this check silently rebuilds work that already exists, or conflicts at merge time. +9. **Merge PRs with a merge commit, not a squash.** Use `gh pr merge --merge`. + Frozen documents (design docs, plans, snapshots) are stamped with the commit + they were written against, and `worklog doc-verify` resolves every code + citation at that commit. A squash never puts the authoring commit on the + default branch, so in a fresh clone `git show :` fails and the + verifier loses its ground truth — it must then report `unresolvable` rather + than degrade to HEAD, which is the assumption that produced the bad + citations it exists to catch. Upstream ADR-0008 records the same rule. + PRs #38–#45 were squashed before this was understood; they happen to resolve + because their stamps landed on main anyway, but do not take them as + precedent. ## Common commands diff --git a/docs/.index/_graph.json b/docs/.index/_graph.json index 93985bd..cdeafd3 100644 --- a/docs/.index/_graph.json +++ b/docs/.index/_graph.json @@ -1131,7 +1131,7 @@ }, "item/01KZD823EHAFN2YF6WCG2T7XN8": { "doc_type": "item", - "status": "todo", + "status": "done", "title": "Backfill document provenance" }, "item/01KZD823EHGFVX6NSM2ZT057SY": { @@ -1146,7 +1146,7 @@ }, "item/01KZD823EHMRAZH978P0EYQFB1": { "doc_type": "item", - "status": "todo", + "status": "done", "title": "Restore merge commits as the pull request merge style" }, "item/01KZD823EHQRBSGQ76C4EK1TXT": { @@ -1156,7 +1156,7 @@ }, "item/01KZD823EHXEV8Z72ZCNZA8VZH": { "doc_type": "item", - "status": "todo", + "status": "done", "title": "File the missing-module gap upstream" }, "item/01KZD823EHZ7W4HG9N5CV4SRMT": { diff --git a/docs/.index/_inventory.json b/docs/.index/_inventory.json index 49c2fd7..b790cd5 100644 --- a/docs/.index/_inventory.json +++ b/docs/.index/_inventory.json @@ -6,6 +6,7 @@ "date": "2026-08-01", "doc_type": "design", "git_hash": "6bf5d65b2d9c6a2280f7706a91988ff59be1e676", + "merged_in": "3326c8007e3ccb3de6cc82de381dcded3f6c3ae8", "name": "v0.3.0-release", "release": "v0.3.0", "roadmap_snapshot": "docs/roadmap/2026-08-01_v0.3.0-release.md", @@ -23,6 +24,7 @@ "date": "2026-08-01", "doc_type": "design", "git_hash": "6bf5d65b2d9c6a2280f7706a91988ff59be1e676", + "merged_in": "3326c8007e3ccb3de6cc82de381dcded3f6c3ae8", "name": "v0.3.0-release", "release": "v0.3.0", "roadmap_snapshot": "docs/roadmap/2026-08-01_v0.3.0-release.md", @@ -40,6 +42,7 @@ "date": "2026-08-02", "doc_type": "design", "git_hash": "c9c6c991493f92b86d176761f8aa1a8f7c1a73bf", + "merged_in": "5cafcc5d354d669e9cb69446bcbeaa3f1316f788", "name": "v0.3.1-release", "release": "v0.3.1", "roadmap_snapshot": "docs/roadmap/2026-08-02_v0.3.1-release.md", @@ -57,6 +60,7 @@ "date": "2026-08-02", "doc_type": "design", "git_hash": "c9c6c991493f92b86d176761f8aa1a8f7c1a73bf", + "merged_in": "5cafcc5d354d669e9cb69446bcbeaa3f1316f788", "name": "v0.3.1-release", "release": "v0.3.1", "roadmap_snapshot": "docs/roadmap/2026-08-02_v0.3.1-release.md", @@ -74,6 +78,7 @@ "date": "2026-08-03", "doc_type": "design", "git_hash": "f59655acc848d91b67b2ecc97a4150736a8220fe", + "merged_in": "484643632df37fea0962dd8eeb213d1d03b46b6c", "name": "v0.3.2-release", "release": "v0.3.2", "roadmap_snapshot": "docs/roadmap/2026-08-03_v0.3.2-release.md", @@ -91,6 +96,7 @@ "date": "2026-08-03", "doc_type": "design", "git_hash": "f59655acc848d91b67b2ecc97a4150736a8220fe", + "merged_in": "484643632df37fea0962dd8eeb213d1d03b46b6c", "name": "v0.3.2-release", "release": "v0.3.2", "roadmap_snapshot": "docs/roadmap/2026-08-03_v0.3.2-release.md", @@ -190,6 +196,7 @@ "01KYQZ4PAN71X19C6ZZVM42ZS2", "01KYQZ4PANQFYAMMEBBHYPPG6P" ], + "merged_in": "d2d900aff7a9b9e8bfdb3b7d7e36a46e27916e60", "slug": "wiki-ticket-adoption", "source": "docs/plans/2026-07-29-wiki-ticket-adoption.md", "status": "completed", @@ -211,6 +218,7 @@ "01KYZFDBAZ6G090EZ3FSS0FXS9", "01KYZFDBAZW0F3RCN8P9J1R29A" ], + "merged_in": "a05b844c4a85d74940d9cc3ae9b2ec5cc5838094", "slug": "v030-plumbing-and-tests", "source": "docs/plans/2026-08-01-v030-plumbing-and-tests.md", "status": "completed", @@ -235,6 +243,7 @@ "01KZ2CAAT61CM8G0N25MPDEHRQ", "01KZ2CAAT66ZFTBX9NQ7Z0AKKX" ], + "merged_in": "b61871e5a1d699daa56683f2309ebe6c1f003ff3", "slug": "v031-correctness-and-traceability", "source": "docs/plans/2026-08-02-v031-correctness-and-traceability.md", "status": "completed", @@ -268,11 +277,11 @@ { "canonical_key": "roadmap", "doc_type": "roadmap", - "generated_at": "2026-08-07T04:42:46Z", - "git_hash": "018e71d", + "generated_at": "2026-08-07T04:45:13Z", + "git_hash": "73d7ab4", "slug": "roadmap", "source": "docs/roadmap.md", - "source_hash": "28875fdf", + "source_hash": "2cf09650", "title": "Roadmap", "truth_state": "current", "wiki": "https://github.com/SpillwaveSolutions/okf-plugin/wiki/Roadmap", @@ -283,6 +292,7 @@ "date": "2026-08-01", "doc_type": "roadmap-snapshot", "generated_at": "2026-08-01T22:04:00Z", + "merged_in": "6bf5d65b2d9c6a2280f7706a91988ff59be1e676", "name": "v0.3.0-release", "release": "v0.3.0", "slug": "2026-08-01_v0.3.0-release", @@ -299,6 +309,7 @@ "date": "2026-08-02", "doc_type": "roadmap-snapshot", "generated_at": "2026-08-02T23:33:31Z", + "merged_in": "c9c6c991493f92b86d176761f8aa1a8f7c1a73bf", "name": "v0.3.1-release", "release": "v0.3.1", "slug": "2026-08-02_v0.3.1-release", @@ -316,6 +327,7 @@ "date": "2026-08-03", "doc_type": "roadmap-snapshot", "generated_at": "2026-08-03T03:05:01Z", + "merged_in": "f59655acc848d91b67b2ecc97a4150736a8220fe", "name": "v0.3.2-release", "release": "v0.3.2", "slug": "2026-08-03_v0.3.2-release", diff --git a/docs/.index/publish-manifest.json b/docs/.index/publish-manifest.json index 83ca96b..cfe7d1f 100644 --- a/docs/.index/publish-manifest.json +++ b/docs/.index/publish-manifest.json @@ -1,5 +1,5 @@ { - "git_hash": "018e71d", + "git_hash": "73d7ab4", "pages": [ { "frozen": false, @@ -45,7 +45,7 @@ "frozen": false, "page_name": "Index-Traceability", "render": "as-is", - "render_hash": "78ad288c4872", + "render_hash": "8edaba359b24", "source": "docs/.index/rendered/traceability.md", "title": "Traceability Index", "truth_state": "current", @@ -655,7 +655,7 @@ "frozen": false, "page_name": "Ticket-01KZD823EG6R5E1FXFX416RQ0G", "render": "as-is", - "render_hash": "685b165febbd", + "render_hash": "e8371c013889", "source": "docs/.index/rendered/tickets/01KZD823EG6R5E1FXFX416RQ0G.md", "title": "Upgrade the vendored worklog tooling to 0.22.2", "truth_state": "open", @@ -675,10 +675,10 @@ "frozen": false, "page_name": "Ticket-01KZD823EHAFN2YF6WCG2T7XN8", "render": "as-is", - "render_hash": "06cf46d8d8db", + "render_hash": "a98270b1dd7d", "source": "docs/.index/rendered/tickets/01KZD823EHAFN2YF6WCG2T7XN8.md", "title": "Backfill document provenance", - "truth_state": "open", + "truth_state": "done", "wiki_key": "item/01KZD823EHAFN2YF6WCG2T7XN8" }, { @@ -705,10 +705,10 @@ "frozen": false, "page_name": "Ticket-01KZD823EHMRAZH978P0EYQFB1", "render": "as-is", - "render_hash": "9e6a2895ef0f", + "render_hash": "83be867af3e3", "source": "docs/.index/rendered/tickets/01KZD823EHMRAZH978P0EYQFB1.md", "title": "Restore merge commits as the pull request merge style", - "truth_state": "open", + "truth_state": "done", "wiki_key": "item/01KZD823EHMRAZH978P0EYQFB1" }, { @@ -725,10 +725,10 @@ "frozen": false, "page_name": "Ticket-01KZD823EHXEV8Z72ZCNZA8VZH", "render": "as-is", - "render_hash": "79e7ab5630b6", + "render_hash": "7c6b7c26fa1b", "source": "docs/.index/rendered/tickets/01KZD823EHXEV8Z72ZCNZA8VZH.md", "title": "File the missing-module gap upstream", - "truth_state": "open", + "truth_state": "done", "wiki_key": "item/01KZD823EHXEV8Z72ZCNZA8VZH" }, { @@ -932,13 +932,13 @@ "wiki_key": "plan/2026-08-07-worklog-0-22-2-upgrade" }, { - "banner": "> **Current** \u2014 this is the living version; regenerated at 2026-08-07T04:42:46Z. Historical snapshots are linked from [[Index-Releases]].", + "banner": "> **Current** \u2014 this is the living version; regenerated at 2026-08-07T04:45:13Z. Historical snapshots are linked from [[Index-Releases]].", "frozen": false, "page_name": "Roadmap", "render": "doc+banner", - "render_hash": "e61b5a51f951", + "render_hash": "d91e26e61fc5", "source": "docs/roadmap.md", - "source_hash": "f2b699b31eb7", + "source_hash": "cc9e481dd4d5", "title": "Roadmap", "truth_state": "current", "wiki_key": "roadmap" diff --git a/docs/.index/rendered/tickets/01KZD823EG6R5E1FXFX416RQ0G.md b/docs/.index/rendered/tickets/01KZD823EG6R5E1FXFX416RQ0G.md index 2d84759..e1ba766 100644 --- a/docs/.index/rendered/tickets/01KZD823EG6R5E1FXFX416RQ0G.md +++ b/docs/.index/rendered/tickets/01KZD823EG6R5E1FXFX416RQ0G.md @@ -8,20 +8,20 @@ Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in th - [[Ticket-01KZD823EH6PBMN2DRD9WAX52F]] Complete the three manual steps the upgrade requires — Add .work/.sessions to .gitignore, since it is local advisory state that must never be committed and is not currently ignored. (done) -- [[Ticket-01KZD823EHAFN2YF6WCG2T7XN8]] Backfill document provenance — Add the merge commit that brought each frozen document to the default branch. (open) +- [[Ticket-01KZD823EHAFN2YF6WCG2T7XN8]] Backfill document provenance — Add the merge commit that brought each frozen document to the default branch. (done) - [[Ticket-01KZD823EHGFVX6NSM2ZT057SY]] Republish and confirm the plan banners are fixed — Publish first and then converge the index, because publishing rewrites the published ledger that the normalizer self-describes. (open) - [[Ticket-01KZD823EHMC16WZNM4W193VVX]] Baseline the citation verifier and fix real defects only — Run doc-verify once and separate fabricated citations, which were already wrong in the tree the author had open, from drift, which was correct when written and is expected in a frozen document. (done) - [[Ticket-01KZD823EHMRAZH978P0EYQFB1]] Restore merge commits as the pull request merge style — This repo used merge commits through pull request 37 and was switched to -squash during the v0.3.2 work without the change being flagged. (open) +squash during the v0.3.2 work without the change being flagged. (done) - [[Ticket-01KZD823EHQRBSGQ76C4EK1TXT]] Upgrade the vendored tooling to 0.22.2 — Run the init command, which is the documented and idempotent upgrade path. (done) - [[Ticket-01KZD823EHXEV8Z72ZCNZA8VZH]] File the missing-module gap upstream — Report that init.sh omits doc_verify.py and provenance.py while pre-commit references the first, and that the file-exists guard makes the omission -silent. (open) +silent. (done) - [[Ticket-01KZD823EHZ7W4HG9N5CV4SRMT]] Install the doc-verify modules the upgrade leaves out — The pre-commit hook calls doc-verify behind a guard that tests whether bin/doc_verify.py exists, but init does not copy that module or provenance.py. (done) - [[Ticket-01KZD84S62B5TWKQSX4XV9848M]] Restore the repo-local test gates the upgrade removed from pre-commit — The upgrade rewrites hooks/pre-commit wholesale, which deleted the two repo-local gate lines this project added in v0.3.0 and v0.3.1: the graph engine test suite and the post-edit hook shell test. (done) -Progress: 5/9 done +Progress: 8/9 done diff --git a/docs/.index/rendered/tickets/01KZD823EHAFN2YF6WCG2T7XN8.md b/docs/.index/rendered/tickets/01KZD823EHAFN2YF6WCG2T7XN8.md index d17f521..696e759 100644 --- a/docs/.index/rendered/tickets/01KZD823EHAFN2YF6WCG2T7XN8.md +++ b/docs/.index/rendered/tickets/01KZD823EHAFN2YF6WCG2T7XN8.md @@ -1,6 +1,6 @@ # Backfill document provenance -`01KZD823EHAFN2YF6WCG2T7XN8` · task/feature · **open** +`01KZD823EHAFN2YF6WCG2T7XN8` · task/feature · **done** Add the merge commit that brought each frozen document to the default branch. diff --git a/docs/.index/rendered/tickets/01KZD823EHMRAZH978P0EYQFB1.md b/docs/.index/rendered/tickets/01KZD823EHMRAZH978P0EYQFB1.md index 1e5f3e5..acd50da 100644 --- a/docs/.index/rendered/tickets/01KZD823EHMRAZH978P0EYQFB1.md +++ b/docs/.index/rendered/tickets/01KZD823EHMRAZH978P0EYQFB1.md @@ -1,6 +1,6 @@ # Restore merge commits as the pull request merge style -`01KZD823EHMRAZH978P0EYQFB1` · task/feature · **open** +`01KZD823EHMRAZH978P0EYQFB1` · task/feature · **done** This repo used merge commits through pull request 37 and was switched to squash during the v0.3.2 work without the change being flagged. diff --git a/docs/.index/rendered/tickets/01KZD823EHXEV8Z72ZCNZA8VZH.md b/docs/.index/rendered/tickets/01KZD823EHXEV8Z72ZCNZA8VZH.md index 4af1890..429ba82 100644 --- a/docs/.index/rendered/tickets/01KZD823EHXEV8Z72ZCNZA8VZH.md +++ b/docs/.index/rendered/tickets/01KZD823EHXEV8Z72ZCNZA8VZH.md @@ -1,6 +1,6 @@ # File the missing-module gap upstream -`01KZD823EHXEV8Z72ZCNZA8VZH` · task/feature · **open** +`01KZD823EHXEV8Z72ZCNZA8VZH` · task/feature · **done** Report that init.sh omits doc_verify.py and provenance.py while pre-commit references the first, and that the file-exists guard makes the omission diff --git a/docs/.index/rendered/traceability.md b/docs/.index/rendered/traceability.md index 6dae81e..ba122db 100644 --- a/docs/.index/rendered/traceability.md +++ b/docs/.index/rendered/traceability.md @@ -12,7 +12,7 @@ _The evidence chain: plan → item → ticket → code → release, forward and - produced-by: [[Plan-worklog-0-22-2-upgrade]] ### File the missing-module gap upstream -`01KZD823EHXEV8Z72ZCNZA8VZH` · status: todo +`01KZD823EHXEV8Z72ZCNZA8VZH` · status: done - belongs-to: Upgrade the vendored worklog tooling to 0.22.2 - produced-by: [[Plan-worklog-0-22-2-upgrade]] @@ -22,7 +22,7 @@ _The evidence chain: plan → item → ticket → code → release, forward and - produced-by: [[Plan-worklog-0-22-2-upgrade]] ### Restore merge commits as the pull request merge style -`01KZD823EHMRAZH978P0EYQFB1` · status: todo +`01KZD823EHMRAZH978P0EYQFB1` · status: done - belongs-to: Upgrade the vendored worklog tooling to 0.22.2 - produced-by: [[Plan-worklog-0-22-2-upgrade]] @@ -37,7 +37,7 @@ _The evidence chain: plan → item → ticket → code → release, forward and - produced-by: [[Plan-worklog-0-22-2-upgrade]] ### Backfill document provenance -`01KZD823EHAFN2YF6WCG2T7XN8` · status: todo +`01KZD823EHAFN2YF6WCG2T7XN8` · status: done - belongs-to: Upgrade the vendored worklog tooling to 0.22.2 - produced-by: [[Plan-worklog-0-22-2-upgrade]] diff --git a/docs/designs/2026-08-01_v0.3.0-release_code_walkthrough.md b/docs/designs/2026-08-01_v0.3.0-release_code_walkthrough.md index 28f6c77..d7b692e 100644 --- a/docs/designs/2026-08-01_v0.3.0-release_code_walkthrough.md +++ b/docs/designs/2026-08-01_v0.3.0-release_code_walkthrough.md @@ -9,6 +9,7 @@ tag: v0.3.0 git_hash: 6bf5d65b2d9c6a2280f7706a91988ff59be1e676 branch: docs/v0.3.0-design-sync roadmap_snapshot: docs/roadmap/2026-08-01_v0.3.0-release.md +merged_in: 3326c8007e3ccb3de6cc82de381dcded3f6c3ae8 --- # OKF Graph Engineering Plugin — Code Walkthrough diff --git a/docs/designs/2026-08-01_v0.3.0-release_design_doc.md b/docs/designs/2026-08-01_v0.3.0-release_design_doc.md index b7ef61c..07b6f00 100644 --- a/docs/designs/2026-08-01_v0.3.0-release_design_doc.md +++ b/docs/designs/2026-08-01_v0.3.0-release_design_doc.md @@ -9,6 +9,7 @@ tag: v0.3.0 git_hash: 6bf5d65b2d9c6a2280f7706a91988ff59be1e676 branch: docs/v0.3.0-design-sync roadmap_snapshot: docs/roadmap/2026-08-01_v0.3.0-release.md +merged_in: 3326c8007e3ccb3de6cc82de381dcded3f6c3ae8 --- # OKF Graph Engineering Plugin — Design Document diff --git a/docs/designs/2026-08-02_v0.3.1-release_code_walkthrough.md b/docs/designs/2026-08-02_v0.3.1-release_code_walkthrough.md index 05760b6..2241550 100644 --- a/docs/designs/2026-08-02_v0.3.1-release_code_walkthrough.md +++ b/docs/designs/2026-08-02_v0.3.1-release_code_walkthrough.md @@ -9,6 +9,7 @@ tag: v0.3.1 git_hash: c9c6c991493f92b86d176761f8aa1a8f7c1a73bf branch: docs/v0.3.1-design-sync roadmap_snapshot: docs/roadmap/2026-08-02_v0.3.1-release.md +merged_in: 5cafcc5d354d669e9cb69446bcbeaa3f1316f788 --- # OKF Graph Engineering Plugin — Code Walkthrough diff --git a/docs/designs/2026-08-02_v0.3.1-release_design_doc.md b/docs/designs/2026-08-02_v0.3.1-release_design_doc.md index 7bce757..906efbe 100644 --- a/docs/designs/2026-08-02_v0.3.1-release_design_doc.md +++ b/docs/designs/2026-08-02_v0.3.1-release_design_doc.md @@ -9,6 +9,7 @@ tag: v0.3.1 git_hash: c9c6c991493f92b86d176761f8aa1a8f7c1a73bf branch: docs/v0.3.1-design-sync roadmap_snapshot: docs/roadmap/2026-08-02_v0.3.1-release.md +merged_in: 5cafcc5d354d669e9cb69446bcbeaa3f1316f788 --- # OKF Graph Engineering Plugin — Design Document diff --git a/docs/designs/2026-08-03_v0.3.2-release_code_walkthrough.md b/docs/designs/2026-08-03_v0.3.2-release_code_walkthrough.md index ab8c4e6..722e6d8 100644 --- a/docs/designs/2026-08-03_v0.3.2-release_code_walkthrough.md +++ b/docs/designs/2026-08-03_v0.3.2-release_code_walkthrough.md @@ -9,6 +9,7 @@ tag: v0.3.2 git_hash: f59655acc848d91b67b2ecc97a4150736a8220fe branch: docs/v0.3.2-design-sync roadmap_snapshot: docs/roadmap/2026-08-03_v0.3.2-release.md +merged_in: 484643632df37fea0962dd8eeb213d1d03b46b6c --- # OKF Graph Engineering Plugin — Code Walkthrough diff --git a/docs/designs/2026-08-03_v0.3.2-release_design_doc.md b/docs/designs/2026-08-03_v0.3.2-release_design_doc.md index 3db87bb..237fcae 100644 --- a/docs/designs/2026-08-03_v0.3.2-release_design_doc.md +++ b/docs/designs/2026-08-03_v0.3.2-release_design_doc.md @@ -9,6 +9,7 @@ tag: v0.3.2 git_hash: f59655acc848d91b67b2ecc97a4150736a8220fe branch: docs/v0.3.2-design-sync roadmap_snapshot: docs/roadmap/2026-08-03_v0.3.2-release.md +merged_in: 484643632df37fea0962dd8eeb213d1d03b46b6c --- # OKF Graph Engineering Plugin — Design Document diff --git a/docs/plans/2026-07-29-wiki-ticket-adoption.md b/docs/plans/2026-07-29-wiki-ticket-adoption.md index 8502b5e..e65e39b 100644 --- a/docs/plans/2026-07-29-wiki-ticket-adoption.md +++ b/docs/plans/2026-07-29-wiki-ticket-adoption.md @@ -4,6 +4,7 @@ slug: wiki-ticket-adoption title: Adopt WikiTicket SDD for okf-plugin epic: 01KYQZ4PAMZCM9N56K8F3034F2 items: [01KYQZ4PAMK1ND4XKH8A1VHMFN, 01KYQZ4PAN1WHT2YGX667F3EX6, 01KYQZ4PANSHKME2QNA8FZVVGC, 01KYQZ4PAN6A8EX36M2ZRWMDAZ, 01KYQZ4PAN551JXFMH2GF82BXF, 01KYQZ4PANS9D55RG19VMXGQ1V, 01KYQZ4PANG2JE670X42GCZY6S, 01KYQZ4PAN71X19C6ZZVM42ZS2, 01KYQZ4PANQFYAMMEBBHYPPG6P] +merged_in: d2d900aff7a9b9e8bfdb3b7d7e36a46e27916e60 --- # Adopt WikiTicket SDD for okf-plugin diff --git a/docs/plans/2026-08-01-v030-plumbing-and-tests.md b/docs/plans/2026-08-01-v030-plumbing-and-tests.md index 3c04e21..6d0dd56 100644 --- a/docs/plans/2026-08-01-v030-plumbing-and-tests.md +++ b/docs/plans/2026-08-01-v030-plumbing-and-tests.md @@ -4,6 +4,7 @@ slug: v030-plumbing-and-tests title: v0.3.0 — fix the plumbing, add the net epic: 01KYZFDBAYGEG2FKWRADN46Z9W items: [01KYZFDBAY0AX03XQ6SAT6SXJW, 01KYZFDBAY2VPBHBNXW7E4ZKHF, 01KYZFDBAZ0T4W5TZYVXSBXADV, 01KYZFDBAZ62YTTMN7SRGD2QKC, 01KYZFDBAZ6G090EZ3FSS0FXS9, 01KYZFDBAZW0F3RCN8P9J1R29A] +merged_in: a05b844c4a85d74940d9cc3ae9b2ec5cc5838094 --- --- diff --git a/docs/plans/2026-08-02-v031-correctness-and-traceability.md b/docs/plans/2026-08-02-v031-correctness-and-traceability.md index 1f19ac4..7600582 100644 --- a/docs/plans/2026-08-02-v031-correctness-and-traceability.md +++ b/docs/plans/2026-08-02-v031-correctness-and-traceability.md @@ -4,6 +4,7 @@ slug: v031-correctness-and-traceability title: v0.3.1 — correctness cleanup and traceability backfill epic: 01KZ2CAAT5G2ND3RAF4TAXR5B9 items: [01KZ2CAAT56M9V49211G7NVBYV, 01KZ2CAAT5JS108XWJ4T00B3V1, 01KZ2CAAT5C7PGNP8QDXGJJB9E, 01KZ2CAAT6XQ5WCT70AS6QQNTY, 01KZ2CAAT6Z9Q09MMFFWD7XH1R, 01KZ2CAAT64CERBRZMR7AJZ5XW, 01KZ2CAAT6YNARXMA7HMNM1AZ1, 01KZ2CAAT61CM8G0N25MPDEHRQ, 01KZ2CAAT66ZFTBX9NQ7Z0AKKX] +merged_in: b61871e5a1d699daa56683f2309ebe6c1f003ff3 --- --- diff --git a/docs/roadmap.md b/docs/roadmap.md index e7aefbf..2b52108 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,9 +2,9 @@ wiki_key: roadmap doc_type: roadmap truth_state: current -source_hash: 28875fdf -generated_at: 2026-08-07T04:42:46Z -git_hash: "018e71d" +source_hash: 2cf09650 +generated_at: 2026-08-07T04:45:13Z +git_hash: "73d7ab4" --- @@ -14,7 +14,7 @@ git_hash: "018e71d" # Roadmap -_1 epic(s) in flight, 4 open item(s), 0 blocked, 0 unclassified._ +_1 epic(s) in flight, 1 open item(s), 0 blocked, 0 unclassified._ ## Now @@ -22,23 +22,16 @@ _Nothing here._ ## Next -### Upgrade the vendored worklog tooling to 0.22.2 · P2 · 5 of 9 done · feature 8 / bug 1 +### Upgrade the vendored worklog tooling to 0.22.2 · P2 · 8 of 9 done · feature 8 / bug 1 Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. Includes installing two modules the upstream installer omits, restoring merge commits so document provenance can be resolved, and republishing the wiki so plan pages stop announcing themselves as status reports. | # | Item | Type | Priority | Status | Blocked by | |---|---|---|---|---|---| | 01KZD823 | Republish and confirm the plan banners are fixed | task | P1 | todo | — | -| 01KZD823 | Restore merge commits as the pull request merge style | task | P2 | todo | — | -| 01KZD823 | File the missing-module gap upstream | task | P2 | todo | — | ## Later -### Upgrade the vendored worklog tooling to 0.22.2 · P2 · 5 of 9 done · feature 8 / bug 1 -Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in the plan-banner fix filed upstream as wiki_ticket_sdd#292, plus the trace-check scoping and merge-rescue correctness that shipped alongside it. Includes installing two modules the upstream installer omits, restoring merge commits so document provenance can be resolved, and republishing the wiki so plan pages stop announcing themselves as status reports. - -| # | Item | Type | Priority | Status | Blocked by | -|---|---|---|---|---|---| -| 01KZD823 | Backfill document provenance | task | P3 | todo | — | +_Nothing here._ ## Visual roadmap @@ -47,16 +40,10 @@ Upgrade this repo's vendored worklog tooling from 0.18.0 to 0.22.2 to pull in th ```mermaid graph TD 01KZD823EG6R5E1FXFX416RQ0G["📦 Upgrade the vendored worklog t"] - 01KZD823EHAFN2YF6WCG2T7XN8["📦 Backfill document provenance"] 01KZD823EHGFVX6NSM2ZT057SY["📦 Republish and confirm the plan"] - 01KZD823EHMRAZH978P0EYQFB1["📦 Restore merge commits as the p"] - 01KZD823EHXEV8Z72ZCNZA8VZH["📦 File the missing-module gap up"] - 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHAFN2YF6WCG2T7XN8 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHGFVX6NSM2ZT057SY - 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHMRAZH978P0EYQFB1 - 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHXEV8Z72ZCNZA8VZH classDef todo fill:#f4f4f4,stroke:#999999 - class 01KZD823EG6R5E1FXFX416RQ0G,01KZD823EHAFN2YF6WCG2T7XN8,01KZD823EHGFVX6NSM2ZT057SY,01KZD823EHMRAZH978P0EYQFB1,01KZD823EHXEV8Z72ZCNZA8VZH todo + class 01KZD823EG6R5E1FXFX416RQ0G,01KZD823EHGFVX6NSM2ZT057SY todo ``` ### Hierarchy @@ -64,12 +51,6 @@ graph TD ```mermaid graph TD 01KZD823EG6R5E1FXFX416RQ0G["📦 Upgrade the vendored worklog t"] - 01KZD823EHAFN2YF6WCG2T7XN8["📦 Backfill document provenance"] 01KZD823EHGFVX6NSM2ZT057SY["📦 Republish and confirm the plan"] - 01KZD823EHMRAZH978P0EYQFB1["📦 Restore merge commits as the p"] - 01KZD823EHXEV8Z72ZCNZA8VZH["📦 File the missing-module gap up"] - 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHAFN2YF6WCG2T7XN8 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHGFVX6NSM2ZT057SY - 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHMRAZH978P0EYQFB1 - 01KZD823EG6R5E1FXFX416RQ0G --> 01KZD823EHXEV8Z72ZCNZA8VZH ``` diff --git a/docs/roadmap/2026-08-01_v0.3.0-release.md b/docs/roadmap/2026-08-01_v0.3.0-release.md index d41b745..0dfd0eb 100644 --- a/docs/roadmap/2026-08-01_v0.3.0-release.md +++ b/docs/roadmap/2026-08-01_v0.3.0-release.md @@ -6,6 +6,7 @@ source_hash: fb13abec generated_at: 2026-08-01T22:04:00Z date: 2026-08-01 name: v0.3.0-release +merged_in: 6bf5d65b2d9c6a2280f7706a91988ff59be1e676 --- diff --git a/docs/roadmap/2026-08-02_v0.3.1-release.md b/docs/roadmap/2026-08-02_v0.3.1-release.md index 916b5f2..c7bb5d0 100644 --- a/docs/roadmap/2026-08-02_v0.3.1-release.md +++ b/docs/roadmap/2026-08-02_v0.3.1-release.md @@ -6,6 +6,7 @@ source_hash: 6106caeb generated_at: 2026-08-02T23:33:31Z date: 2026-08-02 name: v0.3.1-release +merged_in: c9c6c991493f92b86d176761f8aa1a8f7c1a73bf --- diff --git a/docs/roadmap/2026-08-03_v0.3.2-release.md b/docs/roadmap/2026-08-03_v0.3.2-release.md index 7a0c207..5f5d9a3 100644 --- a/docs/roadmap/2026-08-03_v0.3.2-release.md +++ b/docs/roadmap/2026-08-03_v0.3.2-release.md @@ -6,6 +6,7 @@ source_hash: 7e9991ad generated_at: 2026-08-03T03:05:01Z date: 2026-08-03 name: v0.3.2-release +merged_in: f59655acc848d91b67b2ecc97a4150736a8220fe ---