Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"hooks": {
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/hooks/session-end.sh"
}
]
}
]
}
}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 1 addition & 1 deletion .work/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ classifier:
min_confidence: 0.7
debounce: stop

installed: 0.18.0
installed: 0.22.2
18 changes: 18 additions & 0 deletions .work/todo.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,21 @@
{"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"}
{"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"}
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <intended-branch>`
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)

Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ Use `${CLAUDE_PLUGIN_ROOT}` for all intra-plugin paths in hooks and skill instru
If not, `git reset --hard <intended-branch>` 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 <sha>:<path>` 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

Expand Down
11 changes: 9 additions & 2 deletions bin/adr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
158 changes: 158 additions & 0 deletions bin/changelog.py
Original file line number Diff line number Diff line change
@@ -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<type>[a-z]+)(?:\((?P<scope>[^)]*)\))?!?:\s*(?P<rest>.+)$")
# 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))
Loading
Loading