-
Notifications
You must be signed in to change notification settings - Fork 0
Plan 2026 07 17_worklog core_plan
Current — the latest status report. Reports freeze once published; corrections appear in later reports.
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Assemble the worklog repo from the reviewed artifacts in inputs/, then implement the roadmap renderer, the plan-capture command, their CI guard, and the ExitPlanMode hook — completing spec §18 steps 3–4.
Architecture: Local-first, git-native work tracking (see inputs/worklog-spec.md, v1.2). .work/todo.jsonl is an append-only event log; state is a fold over events (bin/fold.py, done). This plan adds two pure generators on top of the fold: render_roadmap.py (deterministic markdown from the log) and plan_capture.py (markdown draft → items), both wired as bin/worklog subcommands. Everything downstream of the log — compaction, ticket/wiki adapters, sync, status reports — is deliberately out of scope (spec: "Do not build sync before you've lived with the log") and gets its own plan later.
Tech Stack: Python 3 stdlib only (no dependencies, no pip). Bash for hooks. GitHub Actions for CI.
Copied from inputs/worklog-spec.md; every task implicitly includes these.
- Python 3 stdlib only. No third-party imports anywhere.
- Nothing but
bin/worklog'sappend()writes to.work/*.jsonl(invariant §15.4). New code that needs to append events must go through it. - Every
.jsonlwrite ends in\n(invariant §15.1).MAX_BODY = 2048stays a code constant, never a config key (§8.3). - Fold order is by
ev(ULID string sort), never bytsor file position (§6). -
docs/roadmap.mdis generated and never hand-edited (invariant §15.7). Renderer output must be byte-deterministic: same log → same bytes, or the freshness diff inhooks/pre-commit:36-40false-positives. Thereforegenerated-atderives from the newest event's ULID timestamp, not wall clock. - A plan in
docs/plans/is never edited or regenerated (invariant §15.8).plan-capturemust refuse to overwrite. - The core never reads or branches on ticketing/wiki specifics (§4.2 resolution rule). Nothing in this plan touches adapters.
- The item ULID is the primary key, always (§5.4).
- Do NOT copy
inputs/todo.jsonlinto the repo — it is demo data ending in a deliberately corrupt fused line. The real logs start empty. - Deliberate simplification (ponytail): scripts hardcode the spec-default paths (
.work/todo.jsonl,docs/roadmap.md,docs/plans/) instead of parsing.work/config.yml, because stdlib has no YAML parser. Revisit when someone actually changespaths:in config..work/schema/*.jsonfiles are skipped for the same reason — the pre-commit hook already validates the envelope inline.
Files:
- Create (copy):
bin/worklog,bin/fold.py,bin/ulid.py← frominputs/ - Create (copy):
tests/test_fold.py,tests/test_ulid.py← frominputs/ - Create (copy):
hooks/pre-commit← frominputs/pre-commit - Create (copy):
CLAUDE.md← frominputs/CLAUDE.md;AGENTS.mdas a symlink to it - Create (copy):
.work/config.yml← frominputs/config.yml - Create:
.work/todo.jsonl,.work/done.jsonl(both empty),.gitattributes,.gitignore,docs/plans/.gitkeep,docs/status/.gitkeep
Interfaces:
-
Consumes: nothing (first task; repo has no commits yet).
-
Produces: working
bin/worklogCLI (add,update,close,list,show,foldsubcommands;plan-capture/sync/status/roadmap-render/compactstubbed),fold(paths) -> FoldResultinbin/fold.py(attributes:.itemsdict,.open_items(),.closed_items(),.conflicts(),.orphans,.errors),ulid.new(timestamp_ms=None)andulid.timestamp_ms(ulid_str)inbin/ulid.py, armed pre-commit hook. Later tasks import these exact names. -
Step 1: Copy files into place
cd /Users/richardhightower/clients/spillwave/src/wiki_ticket_sdd
mkdir -p bin tests hooks .work docs/plans docs/status
cp inputs/worklog inputs/fold.py inputs/ulid.py bin/
cp inputs/test_fold.py inputs/test_ulid.py tests/
cp inputs/pre-commit hooks/pre-commit
cp inputs/CLAUDE.md CLAUDE.md
cp inputs/config.yml .work/config.yml
: > .work/todo.jsonl
: > .work/done.jsonl
touch docs/plans/.gitkeep docs/status/.gitkeep
ln -sf CLAUDE.md AGENTS.md
chmod +x bin/worklog hooks/pre-commitDo not copy inputs/todo.jsonl, inputs/done.jsonl, inputs/AGENTS.md (placeholder prose — the symlink above replaces it), or any .pyc files.
- Step 2: Write
.gitattributes(union merge, spec §8.1)
.work/todo.jsonl merge=union
.work/done.jsonl merge=union
- Step 3: Write
.gitignore(spec §3)
.work/sync-state.json
.work/changeset.json
.work/results/
__pycache__/
*.pyc
- Step 4: Arm the hook and run the existing test suites
git config core.hooksPath hooks
python3 tests/test_fold.py
python3 tests/test_ulid.pyExpected: OK from both — 21 tests and 11 tests respectively. If an import fails, the sys.path.insert lines in the tests expect the layout tests/../bin/; verify the copies landed in bin/.
- Step 5: Smoke the CLI end to end
ID=$(bin/worklog add "Smoke test item" --priority P3)
bin/worklog show "$ID" | grep '"title": "Smoke test item"'
bin/worklog close "$ID" --status cancelled
bin/worklog list --all | grep cancelledExpected: the grep lines match. This exercises append()'s newline invariant on the real log. The two events stay in the log — they're legitimate history and prove the hook passes on a non-empty file.
- Step 6: Commit (the hook fires for the first time here)
git add -A ':!inputs'
git commit -m "feat: worklog core — event log, fold, CLI, hooks (spec §18 steps 1–2)"Expected: hook runs (newline check + schema check + fold tests), commit succeeds. inputs/ stays uncommitted as raw material; delete it at the end of the plan if desired.
Files:
- Create:
bin/render_roadmap.py - Test:
tests/test_render_roadmap.py
Interfaces:
-
Consumes:
fold(paths) -> FoldResultfrombin/fold.py;ulid.timestamp_ms(str) -> intfrombin/ulid.py;write_log(lines) -> pathhelper fromtests/test_fold.py. -
Produces:
render(paths=(".work/todo.jsonl", ".work/done.jsonl")) -> strreturning the full markdown document, and a__main__that prints it to stdout (that exact stdout contract is whathooks/pre-commit:37invokes). Task 3 importsrender_roadmap.render. -
Step 1: Write the failing tests
Create tests/test_render_roadmap.py:
#!/usr/bin/env python3
"""Tests for bin/render_roadmap.py -- WORKLOG-SPEC section 13.1.
The renderer must be byte-deterministic: the pre-commit freshness check
regenerates and diffs, so any nondeterminism (wall-clock timestamps, dict
order) makes every commit fail.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bin"))
sys.path.insert(0, os.path.dirname(__file__))
from render_roadmap import render # noqa: E402
from test_fold import write_log # noqa: E402
def ev(n, item, op, **kw):
e = {"ev": f"01J8X{n:04d}A0", "ts": "t", "actor": "t", "item": item, "op": op}
e.update(kw)
return e
class TestDeterminism(unittest.TestCase):
EVENTS = [
ev(1, "E1", "create", set={"type": "epic", "title": "Auth refactor",
"status": "todo", "priority": "P0"}),
ev(2, "T1", "create", set={"type": "task", "title": "Extract middleware",
"status": "in_progress", "priority": "P0",
"parent": "E1"}),
ev(3, "T2", "create", set={"type": "task", "title": "Session migration",
"status": "todo", "priority": "P1",
"parent": "E1", "depends_on": ["T1"]}),
]
def test_identical_output_regardless_of_file_order(self):
a = render([write_log(self.EVENTS)])
b = render([write_log(list(reversed(self.EVENTS)))])
self.assertEqual(a, b)
def test_two_renders_of_same_log_are_identical(self):
path = write_log(self.EVENTS)
self.assertEqual(render([path]), render([path]))
def test_generated_marker_and_hash_present(self):
out = render([write_log(self.EVENTS)])
self.assertIn("<!-- GENERATED by worklog roadmap-render. DO NOT EDIT. -->", out)
self.assertIn("<!-- source-hash: ", out)
self.assertIn("<!-- generated-at: ", out)
self.assertNotIn("generated-at: never", out)
class TestSections(unittest.TestCase):
def test_in_progress_lands_in_now(self):
out = render([write_log([
ev(1, "A", "create", set={"type": "task", "title": "Mid-flight",
"status": "in_progress", "priority": "P2"}),
])])
now = out.split("## Now")[1].split("## Next")[0]
self.assertIn("Mid-flight", now)
def test_unblocked_p2_is_next_blocked_p2_is_later(self):
out = render([write_log([
ev(1, "D1", "create", set={"type": "task", "title": "Dep",
"status": "todo", "priority": "P2"}),
ev(2, "B1", "create", set={"type": "task", "title": "Waiting",
"status": "todo", "priority": "P2",
"depends_on": ["D1"]}),
])])
nxt = out.split("## Next")[1].split("## Later")[0]
later = out.split("## Later")[1]
self.assertIn("Dep", nxt)
self.assertIn("Waiting", later)
def test_blocked_by_column_names_the_open_blocker(self):
out = render([write_log([
ev(1, "DEP12345XX", "create", set={"type": "task", "title": "Dep",
"status": "todo", "priority": "P2"}),
ev(2, "B1", "create", set={"type": "task", "title": "Waiting",
"status": "todo", "priority": "P2",
"depends_on": ["DEP12345XX"]}),
])])
self.assertIn("DEP12345", out.split("## Later")[1])
def test_closed_items_and_epics_do_not_appear_as_rows(self):
out = render([write_log([
ev(1, "E1", "create", set={"type": "epic", "title": "TheEpic",
"status": "todo", "priority": "P1"}),
ev(2, "T1", "create", set={"type": "task", "title": "Shipped",
"status": "todo", "priority": "P1",
"parent": "E1"}),
ev(3, "T1", "close", set={"status": "done"}),
ev(4, "T2", "create", set={"type": "task", "title": "Open task",
"status": "todo", "priority": "P1",
"parent": "E1"}),
])])
self.assertNotIn("| Shipped |", out) # closed: no table row
self.assertNotIn("| TheEpic |", out) # epic: heading, not row
self.assertIn("### TheEpic", out)
class TestEpicProgress(unittest.TestCase):
def test_done_counts_cancelled_does_not(self):
out = render([write_log([
ev(1, "E1", "create", set={"type": "epic", "title": "Auth",
"status": "todo", "priority": "P1"}),
ev(2, "T1", "create", set={"type": "task", "title": "One",
"status": "todo", "priority": "P1", "parent": "E1"}),
ev(3, "T2", "create", set={"type": "task", "title": "Two",
"status": "todo", "priority": "P1", "parent": "E1"}),
ev(4, "T3", "create", set={"type": "task", "title": "Three",
"status": "todo", "priority": "P1", "parent": "E1"}),
ev(5, "T2", "close", set={"status": "done"}),
ev(6, "T3", "close", set={"status": "cancelled"}),
])])
self.assertIn("1 of 2 done", out)
class TestNeedsAttention(unittest.TestCase):
def test_conflict_is_surfaced_with_resolve_hint(self):
out = render([write_log([
ev(1, "A1", "create", set={"type": "task", "title": "Contested",
"status": "todo", "priority": "P1"}),
ev(2, "A1", "conflict", set={"field": "priority",
"local": "P1", "remote": "P0"}),
])])
self.assertIn("## Needs attention", out)
self.assertIn("worklog resolve A1 --field priority", out)
if __name__ == "__main__":
unittest.main()- Step 2: Run tests to verify they fail
Run: python3 tests/test_render_roadmap.py
Expected: ModuleNotFoundError: No module named 'render_roadmap'
- Step 3: Write the implementation
Create bin/render_roadmap.py:
#!/usr/bin/env python3
"""
render_roadmap.py -- generate the roadmap from the log. WORKLOG-SPEC 13.1.
Pure function of the log: fold in, markdown out, byte-deterministic. The
pre-commit hook regenerates and diffs, so `generated-at` is derived from the
newest event's ULID timestamp -- wall clock here would fail every commit.
Read-only for humans (invariant 15.7). There is no parser; to change the
roadmap, change the work items.
"""
import hashlib
import json
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ulid
from fold import fold, CLOSED_STATUSES
PATHS = (".work/todo.jsonl", ".work/done.jsonl")
# ponytail: paths hardcoded to spec defaults; parse .work/config.yml when
# someone actually changes paths: (stdlib has no yaml).
def max_ev(paths):
"""Newest event ULID across the input files; None on an empty log."""
top = None
for path in paths:
try:
fh = open(path, encoding="utf-8")
except FileNotFoundError:
continue
with fh:
for line in fh:
if not line.strip():
continue
try:
e = json.loads(line).get("ev")
except (json.JSONDecodeError, AttributeError):
continue
if isinstance(e, str) and (top is None or e > top):
top = e
return top
def root_epic_id(item_id, items):
seen = set()
cur = items.get(item_id)
while cur and cur.get("parent") in items and cur["parent"] not in seen:
seen.add(cur["parent"])
cur = items[cur["parent"]]
return cur["id"] if cur and cur.get("type") == "epic" else None
def ref(item):
ext = item.get("external") or {}
if ext.get("key") and ext.get("url"):
return f"[{ext['key']}]({ext['url']})"
if ext.get("key"):
return ext["key"]
return item["id"][:8]
def open_blockers(item, items):
out = []
for dep in item.get("depends_on") or []:
blocker = items.get(dep)
if blocker is None or blocker.get("status") not in CLOSED_STATUSES:
out.append(ref(blocker) if blocker else dep[:8])
return out
def section(item, items):
if item.get("priority") == "P0" or item.get("status") == "in_progress":
return "Now"
if item.get("priority") == "P1":
return "Next"
if item.get("priority") == "P2" and not open_blockers(item, items):
return "Next"
return "Later"
def epic_progress(eid, items):
members = [i for i in items.values()
if i.get("type") != "epic" and root_epic_id(i["id"], items) == eid]
done = sum(1 for i in members if i.get("status") == "done")
total = sum(1 for i in members if i.get("status") != "cancelled")
return total, done
def render(paths=PATHS):
r = fold(paths)
items = r.items
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)
gen = (time.strftime("%Y-%m-%dT%H:%M:%SZ",
time.gmtime(ulid.timestamp_ms(top) / 1000))
if top else "never")
open_non_epic = [i for i in r.open_items() if i.get("type") != "epic"]
blocked = [i for i in open_non_epic
if i.get("status") == "blocked" or open_blockers(i, items)]
epics_in_flight = {root_epic_id(i["id"], items) for i in open_non_epic} - {None}
lines = [
"<!-- GENERATED by worklog roadmap-render. DO NOT EDIT. -->",
f"<!-- source-hash: {source_hash} -->",
f"<!-- generated-at: {gen} -->",
"",
"> This file is generated from `.work/todo.jsonl`. Edits will be overwritten.",
"> To change the roadmap, change the work items: `worklog add|update|close`.",
"",
"# Roadmap",
"",
f"_{len(epics_in_flight)} epic(s) in flight, "
f"{len(open_non_epic)} open item(s), {len(blocked)} blocked._",
]
for sec in ("Now", "Next", "Later"):
bucket = [i for i in open_non_epic if section(i, items) == sec]
lines += ["", f"## {sec}"]
if not bucket:
lines += ["", "_Nothing here._"]
continue
groups = {}
for i in bucket:
groups.setdefault(root_epic_id(i["id"], items), []).append(i)
for eid in sorted(groups, key=lambda e: e or "~"): # "~" sorts no-epic last
epic = items.get(eid) if eid else None
lines.append("")
if epic:
total, done = epic_progress(eid, items)
head = f"### {epic.get('title', '?')}"
if epic.get("priority"):
head += f" · {epic['priority']}"
head += f" · {done} of {total} done"
lines.append(head)
if epic.get("body"):
lines += [epic["body"].strip()]
else:
lines.append("### (no epic)")
lines += ["", "| # | Item | Type | Priority | Status | Blocked by |",
"|---|---|---|---|---|---|"]
for i in sorted(groups[eid], key=lambda x: (x.get("priority", "P9"), x["id"])):
blockers = ", ".join(open_blockers(i, items)) or "—"
lines.append(
f"| {ref(i)} | {i.get('title', '')} | {i.get('type', '')} "
f"| {i.get('priority', '—')} "
f"| {i.get('status', '').replace('_', ' ')} | {blockers} |")
attention = []
for iid, c in r.conflicts():
attention.append(
f"- **{ref(items[iid])}** — `{c.get('field', '?')}` conflicts: "
f"local `{c.get('local', '?')}`, remote `{c.get('remote', '?')}`. "
f"Resolve with `worklog resolve {iid} --field {c.get('field', '?')} "
f"--take local|remote`.")
for iid in sorted(set(r.orphans)):
attention.append(f"- Orphan events for `{iid[:8]}` — no create/snapshot yet.")
if attention:
lines += ["", "## Needs attention", ""] + attention
return "\n".join(lines) + "\n"
if __name__ == "__main__":
sys.stdout.write(render())- Step 4: Run tests to verify they pass
Run: python3 tests/test_render_roadmap.py
Expected: OK, 9 tests. Also re-run python3 tests/test_fold.py — still 21 passing (renderer must not have touched fold).
- Step 5: Commit
git add bin/render_roadmap.py tests/test_render_roadmap.py
git commit -m "feat: deterministic roadmap renderer (spec §13.1, §18 step 3)"Note: the hook's freshness check does not fire yet — docs/roadmap.md doesn't exist and the hook guards on both files existing.
Files:
- Modify:
bin/worklog(replace theroadmap-renderstub, ~line 137) - Create:
docs/roadmap.md(generated)
Interfaces:
-
Consumes:
render_roadmap.render() -> strfrom Task 2. -
Produces:
bin/worklog roadmap-rendersubcommand that writesdocs/roadmap.mdand prints the path. From this commit on, every commit must carry a fresh roadmap — the hook's freshness check is now live. Any later task that appends events must runbin/worklog roadmap-renderbefore committing. -
Step 1: Replace the stub in
bin/worklog
Add this function after cmd_fold (around line 99):
def cmd_roadmap_render(a):
import render_roadmap
md = render_roadmap.render()
os.makedirs("docs", exist_ok=True)
with open("docs/roadmap.md", "w", encoding="utf-8") as fh:
fh.write(md)
print("docs/roadmap.md")Then change the stub loop at the bottom from:
for name in ("plan-capture", "sync", "status", "roadmap-render", "compact"):
sub.add_parser(name).set_defaults(fn=stub(name))to:
sub.add_parser("roadmap-render").set_defaults(fn=cmd_roadmap_render)
for name in ("plan-capture", "sync", "status", "compact"):
sub.add_parser(name).set_defaults(fn=stub(name))- Step 2: Generate and eyeball
bin/worklog roadmap-render
cat docs/roadmap.mdExpected: header comments, summary line (0 epic(s) in flight, 0 open item(s)… if the Task 1 smoke item was cancelled), three section headings. The cancelled smoke item must NOT appear.
- Step 3: Verify the freshness guard actually guards
echo "vandalism" >> docs/roadmap.md
git add -A ':!inputs' && git commit -m "should fail" ; echo "exit=$?"Expected: docs/roadmap.md is stale or hand-edited. Run: worklog roadmap-render, exit=1. Then repair:
bin/worklog roadmap-render- Step 4: Commit
git add -A ':!inputs'
git commit -m "feat: worklog roadmap-render subcommand + generated roadmap"Expected: hook passes (regenerate + diff clean), commit succeeds.
Files:
- Create:
.github/workflows/worklog.yml
Interfaces:
-
Consumes:
hooks/pre-commit(runs unchanged in CI — spec §18 step 2's outstanding item: "the hook is local-only; CI must re-run it"). -
Produces: CI enforcement of invariants §15.1/§15.7 + all test suites on every push/PR.
-
Step 1: Write the workflow
name: worklog-invariants
on: [push, pull_request]
jobs:
invariants:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Same script as the local hook: trailing newline, event schema,
# roadmap freshness. A dev can --no-verify past the local hook; not this.
- name: log invariants
run: hooks/pre-commit
- name: tests
run: |
python3 tests/test_fold.py
python3 tests/test_ulid.py
python3 tests/test_render_roadmap.py
python3 tests/test_plan_capture.pyNote: tests/test_plan_capture.py arrives in Task 5. If executing tasks strictly in order, this workflow will fail on any push between Tasks 4 and 5 — fine if there's no remote yet (this repo has none); otherwise swap Task 4 after Task 5.
- Step 2: Sanity-check locally (CI has no runner here; simulate the job)
hooks/pre-commit && python3 tests/test_fold.py && python3 tests/test_ulid.py && python3 tests/test_render_roadmap.pyExpected: all pass.
- Step 3: Commit
git add .github/workflows/worklog.yml
git commit -m "ci: enforce log invariants and run test suites (spec §14)"Files:
- Create:
bin/plan_capture.py - Test:
tests/test_plan_capture.py
Interfaces:
-
Consumes: nothing from other modules (pure functions — all
.jsonlwriting stays inbin/worklog, invariant §15.4). -
Produces:
parse_tasks(draft: str) -> list[dict]where each dict is{"title": str, "priority": "P0".."P3", "subtask": bool}, in document order;front_matter(date, slug, title, epic_id, item_ids) -> str. Task 6'scmd_plan_captureimports both names exactly. -
Step 1: Write the failing tests
Create tests/test_plan_capture.py:
#!/usr/bin/env python3
"""Tests for bin/plan_capture.py -- WORKLOG-SPEC sections 12 and 13.2."""
import os
import sys
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bin"))
from plan_capture import parse_tasks, front_matter # noqa: E402
DRAFT = """# Auth refactor plan
Prose about why. Not a task:
- [ ] this checkbox is above the Tasks heading and must be ignored
## Tasks
- [ ] (P0) Extract auth middleware
- [ ] Write failing test
- [x] Session store migration
## Notes
- [ ] this checkbox is after the Tasks section and must be ignored
"""
class TestParseTasks(unittest.TestCase):
def test_only_the_tasks_section_is_parsed(self):
self.assertEqual(
[t["title"] for t in parse_tasks(DRAFT)],
["Extract auth middleware", "Write failing test",
"Session store migration"])
def test_priority_token_and_default(self):
tasks = parse_tasks(DRAFT)
self.assertEqual(tasks[0]["priority"], "P0")
self.assertEqual(tasks[2]["priority"], "P2") # no token -> P2
def test_indent_marks_subtask(self):
self.assertEqual([t["subtask"] for t in parse_tasks(DRAFT)],
[False, True, False])
def test_no_tasks_section_yields_nothing(self):
self.assertEqual(parse_tasks("# Plan\n\n- [ ] loose checkbox\n"), [])
class TestFrontMatter(unittest.TestCase):
def test_shape(self):
fm = front_matter("2026-07-17", "auth", "Auth refactor",
"01EPIC", ["01A", "01B"])
self.assertTrue(fm.startswith("---\n"))
self.assertIn("date: 2026-07-17\n", fm)
self.assertIn("slug: auth\n", fm)
self.assertIn("epic: 01EPIC\n", fm)
self.assertIn("items: [01A, 01B]\n", fm)
self.assertTrue(fm.endswith("---\n\n"))
if __name__ == "__main__":
unittest.main()- Step 2: Run tests to verify they fail
Run: python3 tests/test_plan_capture.py
Expected: ModuleNotFoundError: No module named 'plan_capture'
- Step 3: Write the implementation
Create bin/plan_capture.py:
#!/usr/bin/env python3
"""
plan_capture.py -- parse a plan draft into work items. WORKLOG-SPEC 12, 13.2.
Pure functions only. All log writing happens in `worklog` (invariant 15.4).
Task syntax, inside a `## Tasks` section of the draft:
- [ ] (P1) Extract auth middleware
- [ ] Write failing test <- indented = subtask of the task above
`[x]` boxes count too (a captured plan may arrive partially done). Checkboxes
outside the Tasks section are prose and are ignored. Priority token optional,
default P2.
"""
import re
TASK_RE = re.compile(r"^(\s+)?- \[[ xX]\] (?:\((P[0-3])\)\s+)?(.+?)\s*$")
def parse_tasks(draft):
tasks = []
in_tasks = False
for line in draft.splitlines():
stripped = line.strip()
if stripped.startswith("#"):
in_tasks = stripped.lstrip("#").strip().lower() == "tasks"
continue
if not in_tasks:
continue
m = TASK_RE.match(line)
if not m:
continue
indent, prio, title = m.groups()
tasks.append({"title": title, "priority": prio or "P2",
"subtask": bool(indent)})
return tasks
def front_matter(date, slug, title, epic_id, item_ids):
return "\n".join([
"---",
f"date: {date}",
f"slug: {slug}",
f"title: {title}",
f"epic: {epic_id}",
"items: [" + ", ".join(item_ids) + "]",
"---",
"", # blank line between front matter and the draft body
"",
])- Step 4: Run tests to verify they pass
Run: python3 tests/test_plan_capture.py
Expected: OK, 5 tests.
- Step 5: Commit
git add bin/plan_capture.py tests/test_plan_capture.py
git commit -m "feat: plan draft parser (spec §13.2)"Files:
- Modify:
bin/worklog(replace theplan-capturestub)
Interfaces:
-
Consumes:
parse_tasks,front_matterfrom Task 5;append(),base(),ulid.new()already inbin/worklog. -
Produces:
bin/worklog plan-capture --slug S --title T [--file F] [--priority P]— reads the draft from--fileor stdin, creates one epic + one item per task (subtasks parented to the preceding task, tasks to the epic, every item'splanfield set), writesdocs/plans/<YYYY-MM-DD>-<slug>.mdwith front matter, refuses to overwrite, prints the plan path then the epic ULID (two lines). Task 7's hook text references this exact invocation. -
Step 1: Add
cmd_plan_capturetobin/worklog(aftercmd_roadmap_render)
def cmd_plan_capture(a):
import plan_capture
draft = open(a.file, encoding="utf-8").read() if a.file else sys.stdin.read()
tasks = plan_capture.parse_tasks(draft)
if not tasks:
sys.exit("worklog: no '- [ ]' tasks found under a '## Tasks' heading")
date = time.strftime("%Y-%m-%d", time.gmtime())
path = f"docs/plans/{date}-{a.slug}.md"
if os.path.exists(path):
sys.exit(f"worklog: {path} exists; plans are never rewritten "
"(invariant 15.8) — pick a new slug to supersede")
epic_id = ulid.new()
ev = base(epic_id, "create", a.actor)
ev["set"] = {"type": "epic", "title": a.title, "status": "todo",
"priority": a.priority, "plan": path}
append(ev)
item_ids, last_task = [], epic_id
for t in tasks:
iid = ulid.new()
ev = base(iid, "create", a.actor)
ev["set"] = {"type": "subtask" if t["subtask"] else "task",
"title": t["title"], "status": "todo",
"priority": t["priority"],
"parent": last_task if t["subtask"] else epic_id,
"plan": path}
append(ev)
item_ids.append(iid)
if not t["subtask"]:
last_task = iid
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(draft)
print(path)
print(epic_id)Replace the stub wiring:
sub.add_parser("roadmap-render").set_defaults(fn=cmd_roadmap_render)
for name in ("sync", "status", "compact"):
sub.add_parser(name).set_defaults(fn=stub(name))and add the parser next to the others:
pc = sub.add_parser("plan-capture"); pc.set_defaults(fn=cmd_plan_capture)
pc.add_argument("--slug", required=True)
pc.add_argument("--title", required=True)
pc.add_argument("--file")
pc.add_argument("--priority", default="P1", choices=["P0", "P1", "P2", "P3"])- Step 2: End-to-end smoke in a throwaway dir (keeps demo items out of the real log)
T=$(mktemp -d) && cp -R bin "$T/" && mkdir -p "$T/.work" && cd "$T"
: > .work/todo.jsonl && : > .work/done.jsonl
cat > draft.md <<'EOF'
# Demo plan
Why we are doing this.
## Tasks
- [ ] (P1) First task
- [ ] A subtask
- [ ] Second task
EOF
bin/worklog plan-capture --slug demo --title "Demo plan" --file draft.md
bin/worklog list
bin/worklog roadmap-render
grep "First task" docs/roadmap.md && grep "### Demo plan" docs/roadmap.md
head -8 docs/plans/*-demo.md
bin/worklog plan-capture --slug demo --title "Demo plan" --file draft.md; echo "exit=$?"
cd - >/dev/nullExpected: list shows 4 open items (epic + 2 tasks + 1 subtask); both greps match; front matter shows items: [...] with 3 ULIDs; the second capture fails with the invariant-15.8 message, exit=1.
- Step 3: Regenerate the real roadmap and run all suites
cd /Users/richardhightower/clients/spillwave/src/wiki_ticket_sdd
bin/worklog roadmap-render
python3 tests/test_fold.py && python3 tests/test_ulid.py && \
python3 tests/test_render_roadmap.py && python3 tests/test_plan_capture.pyExpected: all OK. (The real log was untouched, but regenerating is free and keeps the hook green.)
- Step 4: Commit
git add -A ':!inputs'
git commit -m "feat: worklog plan-capture — plans become tracked items (spec §18 step 4)"Files:
- Create:
.claude/skills/plan-capture/SKILL.md - Create:
hooks/exit-plan-capture.sh - Create:
.claude/settings.json
Interfaces:
-
Consumes: the exact
bin/worklog plan-capture --slug … --title … --file …invocation from Task 6. -
Produces: a skill the model can invoke on demand, plus a
PostToolUsehook that fires on everyExitPlanModeand injects the capture requirement into context — the mechanism backing the CLAUDE.md policy line "Exiting plan mode is not optional bookkeeping." -
Step 1: Write the skill
Create .claude/skills/plan-capture/SKILL.md:
---
name: plan-capture
description: Capture an approved plan as tracked work items. Use when exiting plan mode, when the user approves a plan, or says "capture this plan". Writes docs/plans/<date>-<slug>.md and appends the plan's tasks to .work/todo.jsonl via bin/worklog.
---
# Plan capture
1. Write the approved plan as markdown. It MUST contain a `## Tasks` section:
## Tasks
- [ ] (P1) Task title
- [ ] Subtask of the task above
Priority token `(P0)`–`(P3)` optional, default P2. Prose (the *why*) goes
in other sections and is preserved verbatim in the plan doc.
2. Save it to a temp file and run:
bin/worklog plan-capture --slug <kebab-slug> --title "<plan title>" --file <tempfile>
3. Run `bin/worklog roadmap-render`, then commit `docs/plans/`,
`docs/roadmap.md`, and `.work/todo.jsonl` together.
Never append to `.work/*.jsonl` directly (invariant 15.4). Never overwrite an
existing plan (invariant 15.8) — a changed design gets a NEW plan that
supersedes the old one.- Step 2: Write the hook script
Create hooks/exit-plan-capture.sh (mode 755):
#!/usr/bin/env bash
# WORKLOG-SPEC section 12, "Hooks, not hope": a CLAUDE.md instruction holds
# maybe 80% of the time; this hook holds 100%. Fires after ExitPlanMode and
# puts the capture requirement in front of the model, non-optionally.
cat <<'EOF'
{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"POLICY (worklog): a plan was just approved. Before implementing anything, capture it: save the plan markdown (with a '## Tasks' section of '- [ ] (P#) title' checkboxes; two-space indent = subtask) to a temp file, then run `bin/worklog plan-capture --slug <kebab-slug> --title '<title>' --file <tempfile>`, then `bin/worklog roadmap-render`, and commit docs/plans/, docs/roadmap.md, and .work/todo.jsonl together. See .claude/skills/plan-capture/SKILL.md."}}
EOF- Step 3: Register the hook
Create .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/hooks/exit-plan-capture.sh"
}
]
}
]
}
}- Step 4: Verify the hook script's output is valid JSON
chmod +x hooks/exit-plan-capture.sh
hooks/exit-plan-capture.sh | python3 -m json.tool >/dev/null && echo VALIDExpected: VALID. (The live ExitPlanMode firing can only be observed in a future session that uses plan mode — note that in the final report.)
- Step 5: Commit
git add .claude hooks/exit-plan-capture.sh
git commit -m "feat: plan-capture skill + ExitPlanMode hook (spec §12)"-
Compaction (
bin/compact.py+ nightly CI job) — spec §7, step 5. Needed once the log grows; not before. -
Adapter contract +
ticket-github— §9, step 6. First adapter; lives outside the core. - Push-only sync, then pull/echo-suppression/conflicts — §10–§11, steps 7–8. Three-phase orchestration.
-
Wiki adapter + publish (
published.json) — §9.2, step 9. -
status-report(daily/weekly),plan-next— §13.3, step 10.timecardblocked on spec open question 4 (hours or narrative) — a human decision, flag it when that plan is written. -
worklog resolve— referenced by the roadmap's Needs-attention hint; only meaningful once sync can produce conflicts. Ships with step 8.
The spec's plan-capture writes docs/plans/<YYYY-MM-DD>-<slug>.md. This plan document itself uses the session convention <DATE>_<NAME>_plan.md as requested; the two coexist in docs/plans/ harmlessly. If you want one convention, say which and the other gets renamed in a follow-up.
- Roadmap
- Design-Doc · Code-Walkthrough
- Plan: Plan-ia-content-model
- Plan: Plan-ticket-sync-and-init-detection
- ADR-0001-event-log-fold-union-merge
- ADR-0002-skill-based-edges-typed-contract
- ADR-0003-green-gates-merge
- Index-Releases
- Latest snapshot: Roadmap-2026-07-29_v0.18.0-release
- Index-Status
- Index-Decisions