@friedbotstudio/create-baseline 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +7 -3
  2. package/obj/template/.claude/commands/grant-push.md +19 -0
  3. package/obj/template/.claude/commands/init-project.md +26 -4
  4. package/obj/template/.claude/hooks/consent_gate_grant.mjs +107 -0
  5. package/obj/template/.claude/hooks/git_commit_guard.mjs +224 -0
  6. package/obj/template/.claude/hooks/harness_continuation.sh +101 -34
  7. package/obj/template/.claude/hooks/lib/common.mjs +283 -0
  8. package/obj/template/.claude/hooks/lib/common.sh +1 -1
  9. package/obj/template/.claude/hooks/memory_session_start.sh +20 -6
  10. package/obj/template/.claude/hooks/memory_stop.sh +161 -2
  11. package/obj/template/.claude/hooks/spec_approval_guard.sh +1 -1
  12. package/obj/template/.claude/hooks/swarm_approval_guard.sh +1 -1
  13. package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +7 -7
  14. package/obj/template/.claude/hooks/tests/fixtures/memory_stop_landmark_baseline.txt +21 -0
  15. package/obj/template/.claude/hooks/tests/fixtures/regenerate-ac008.sh +47 -0
  16. package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +7 -3
  17. package/obj/template/.claude/hooks/tests/memory_stop_intent_test.sh +329 -0
  18. package/obj/template/.claude/hooks/tests/regenerate_ac008_test.sh +99 -0
  19. package/obj/template/.claude/memory/README.md +8 -3
  20. package/obj/template/.claude/memory/backlog.md +12 -0
  21. package/obj/template/.claude/project.json +6 -1
  22. package/obj/template/.claude/settings.json +3 -4
  23. package/obj/template/.claude/skills/audit-baseline/audit.sh +28 -16
  24. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md +3 -0
  25. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_empty_body.md +4 -0
  26. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_with_entries.md +9 -0
  27. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_no_opener.md +3 -0
  28. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_opener_only.md +3 -0
  29. package/obj/template/.claude/skills/audit-baseline/tests/preamble_check_test.sh +147 -0
  30. package/obj/template/.claude/skills/chore/SKILL.md +5 -3
  31. package/obj/template/.claude/skills/commit/SKILL.md +5 -4
  32. package/obj/template/.claude/skills/copywriting/LICENSE +21 -0
  33. package/obj/template/.claude/skills/copywriting/NOTICE +23 -0
  34. package/obj/template/.claude/skills/copywriting/SKILL.md +1 -1
  35. package/obj/template/.claude/skills/design-ui/SKILL.md +23 -5
  36. package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +26 -5
  37. package/obj/template/.claude/skills/design-ui/references/orchestration.md +1 -0
  38. package/obj/template/.claude/skills/design-ui/references/state-machine.md +5 -3
  39. package/obj/template/.claude/skills/documentation/LICENSE +202 -0
  40. package/obj/template/.claude/skills/documentation/NOTICE +22 -0
  41. package/obj/template/.claude/skills/harness/SKILL.md +3 -1
  42. package/obj/template/.claude/skills/humanizer/LICENSE +21 -0
  43. package/obj/template/.claude/skills/humanizer/NOTICE +21 -0
  44. package/obj/template/.claude/skills/impeccable/LICENSE +202 -0
  45. package/obj/template/.claude/skills/impeccable/NOTICE +24 -0
  46. package/obj/template/.claude/skills/memory-flush/SKILL.md +20 -4
  47. package/obj/template/.claude/skills/memory-flush/sweep.py +74 -6
  48. package/obj/template/.claude/skills/memory-flush/tests/run.sh +300 -1
  49. package/obj/template/.claude/skills/tdd/SKILL.md +2 -1
  50. package/obj/template/.claude/skills/tdd/drift_check.py +180 -0
  51. package/obj/template/.claude/skills/tdd/tests/drift_check_test.sh +190 -0
  52. package/obj/template/.claude/skills/tdd/tests/run.sh +21 -0
  53. package/obj/template/.claude/skills/technical-tutorials/LICENSE +21 -0
  54. package/obj/template/.claude/skills/technical-tutorials/NOTICE +23 -0
  55. package/obj/template/.claude/skills/technical-tutorials/SKILL.md +1 -1
  56. package/obj/template/.claude/skills/triage/SKILL.md +8 -3
  57. package/obj/template/CLAUDE.md +34 -23
  58. package/obj/template/docs/init/seed.md +36 -21
  59. package/obj/template/manifest.json +59 -33
  60. package/package.json +1 -1
  61. package/src/CLAUDE.template.md +34 -23
  62. package/src/memory/backlog.template.md +12 -0
  63. package/src/project.template.json +6 -1
  64. package/src/seed.template.md +36 -21
  65. package/src/settings.template.json +3 -4
  66. package/obj/template/.claude/hooks/consent_gate_grant.sh +0 -89
  67. package/obj/template/.claude/hooks/git_commit_guard.sh +0 -93
@@ -1,17 +1,23 @@
1
1
  #!/usr/bin/env python3
2
- """Deterministic Step 0 helper for /memory-flush.
2
+ """Deterministic actuator for /memory-flush Step 0 and for /commit Step 6.
3
3
 
4
4
  Scans canonical memory files for closure fields and prose closure signals,
5
5
  applies the matching action (auto-close / surface-and-confirm / stale-sweep),
6
- and emits a JSON action report. Invoked by SKILL.md Step 0 and exercised by
6
+ and emits a JSON action report. Also exposes a non-interactive stamp-closure
7
+ mode invoked by /commit (Phase 11, Step 6) to write status: picked-up +
8
+ superseded-at: today on backlog entries named in workflow.json →
9
+ source_backlog_keys. Invoked by SKILL.md Step 0 (auto-close / prose-scan /
10
+ stale-sweep) and by commit/SKILL.md Step 6 (stamp-closure). Exercised by
7
11
  the fixture tests at .claude/skills/memory-flush/tests/run.sh.
8
12
 
9
13
  CLI:
10
- --mode {auto-close, prose-scan, stale-sweep}
14
+ --mode {auto-close, prose-scan, stale-sweep, stamp-closure}
11
15
  --memory-dir <path>
16
+ --backlog-keys <csv> (required iff --mode stamp-closure)
12
17
 
13
18
  For interactive modes (prose-scan, stale-sweep), one reply per surfaced entry
14
- is read from stdin. Empty stdin / EOF defaults to "keep".
19
+ is read from stdin. Empty stdin / EOF defaults to "keep". stamp-closure is
20
+ non-interactive; --backlog-keys is the input channel.
15
21
  """
16
22
  from __future__ import annotations
17
23
  import argparse
@@ -27,9 +33,16 @@ from pathlib import Path
27
33
  CANONICAL_FILES = [
28
34
  'landmarks', 'libraries', 'decisions',
29
35
  'landmines', 'conventions', 'pending-questions',
36
+ 'backlog',
30
37
  ]
31
38
  PENDING_FILE = 'pending-questions'
32
39
 
40
+ # Files whose entries do NOT stale-age. Backlog is intent, not a verifiable
41
+ # fact about code state, so commit-distance and day-count are meaningless
42
+ # signals. Closure is still tracked via superseded-at: per the canonical
43
+ # closure-field-per-file rule.
44
+ STALE_EXEMPT_FILES = {'backlog'}
45
+
33
46
  STALE_COMMITS = 30
34
47
  STALE_DAYS = 30
35
48
 
@@ -153,6 +166,8 @@ def prose_matches(block: str) -> bool:
153
166
  return any(p.search(block) for p in PROSE_PATTERNS)
154
167
 
155
168
  def is_stale(block: str, name: str, head: str, root: Path) -> bool:
169
+ if name in STALE_EXEMPT_FILES:
170
+ return False
156
171
  if is_closed(block, name):
157
172
  return False
158
173
  stamp = read_field(block, 'verified-at')
@@ -225,6 +240,50 @@ def mode_prose_scan(memdir: Path) -> dict:
225
240
  write_file(memdir, name, new_text)
226
241
  return report
227
242
 
243
+ def mode_stamp_closure(memdir: Path, keys_csv: str) -> dict:
244
+ """Stamp the named backlog entries with status: picked-up + superseded-at: today.
245
+
246
+ Idempotent: re-running on already-stamped entries rewrites superseded-at:
247
+ to today and reports them under `already_closed`. Entries the caller named
248
+ that aren't present in backlog.md go into `missing`. The next /memory-flush
249
+ Step 0a auto-close sweep deletes the stamped entries per the existing
250
+ superseded-at: closure-trigger contract.
251
+ """
252
+ report = {'stamped': 0, 'missing': [], 'already_closed': []}
253
+ keys = [k.strip() for k in keys_csv.split(',') if k.strip()]
254
+ if not keys:
255
+ return report
256
+ text = read_file(memdir, 'backlog')
257
+ if not text:
258
+ report['missing'] = list(keys)
259
+ return report
260
+ new_text = text
261
+ today = date.today().isoformat()
262
+ for key in keys:
263
+ block = _find_entry_block(new_text, key)
264
+ if block is None:
265
+ report['missing'].append(key)
266
+ continue
267
+ was_stamped = (read_field(block, 'status') or '').strip() == 'picked-up'
268
+ updated = update_field(block, 'status', 'picked-up')
269
+ updated = update_field(updated, 'superseded-at', today)
270
+ new_text = new_text.replace(block, updated)
271
+ if was_stamped:
272
+ report['already_closed'].append(key)
273
+ else:
274
+ report['stamped'] += 1
275
+ if new_text != text:
276
+ write_file(memdir, 'backlog', new_text)
277
+ return report
278
+
279
+
280
+ def _find_entry_block(text: str, key: str):
281
+ for entry_key, block in split_entries(text):
282
+ if entry_key == key:
283
+ return block
284
+ return None
285
+
286
+
228
287
  def mode_stale_sweep(memdir: Path) -> dict:
229
288
  report = {'reverified': 0, 'deleted': 0, 'mark_closed': 0, 'kept': 0}
230
289
  root = memdir.parent.parent
@@ -267,18 +326,27 @@ MODE_DISPATCH = {
267
326
  'auto-close': mode_auto_close,
268
327
  'prose-scan': mode_prose_scan,
269
328
  'stale-sweep': mode_stale_sweep,
329
+ 'stamp-closure': mode_stamp_closure,
270
330
  }
271
331
 
272
332
  def parse_args(argv):
273
333
  p = argparse.ArgumentParser(description='Memory Step 0 sweep helper')
274
334
  p.add_argument('--mode', required=True, choices=list(MODE_DISPATCH))
275
335
  p.add_argument('--memory-dir', required=True)
276
- return p.parse_args(argv)
336
+ p.add_argument('--backlog-keys', default=None,
337
+ help='CSV of backlog stable keys; required when --mode stamp-closure')
338
+ args = p.parse_args(argv)
339
+ if args.mode == 'stamp-closure' and args.backlog_keys is None:
340
+ p.error('--backlog-keys is required when --mode stamp-closure')
341
+ return args
277
342
 
278
343
  def main(argv) -> int:
279
344
  args = parse_args(argv)
280
345
  memdir = Path(args.memory_dir).resolve()
281
- report = MODE_DISPATCH[args.mode](memdir)
346
+ if args.mode == 'stamp-closure':
347
+ report = mode_stamp_closure(memdir, args.backlog_keys or '')
348
+ else:
349
+ report = MODE_DISPATCH[args.mode](memdir)
282
350
  print(json.dumps(report))
283
351
  return 0
284
352
 
@@ -62,7 +62,7 @@ sweep() {
62
62
  seed_skel() {
63
63
  local mem="$1"
64
64
  mkdir -p "$mem"
65
- for f in landmarks libraries decisions landmines conventions pending-questions; do
65
+ for f in landmarks libraries decisions landmines conventions pending-questions backlog; do
66
66
  cat > "$mem/$f.md" <<EOF
67
67
  ---
68
68
  owners: [test]
@@ -304,6 +304,289 @@ test_when_pre_spec_entry_no_source_no_verbatim_then_grandfathered() {
304
304
  assert_file_contains "$mem/conventions.md" "## legacy-entry" "AC-006 grandfathered legacy entry survives" || return 1
305
305
  }
306
306
 
307
+ # --- Phase 10.6 (memory-flush-phase) regression traps -------------------------
308
+ # Confirms sweep.py stays scoped to canonical files and does not touch _pending.md
309
+ # regardless of whether _pending.md body is empty (the fast-path case).
310
+
311
+ PENDING_SKELETON='---
312
+ owners: [memory_stop.sh writes; /memory-flush clears]
313
+ category: auto-extracted candidates awaiting curation
314
+ verifies-against: none
315
+ ---
316
+
317
+ # Pending memory candidates
318
+
319
+ Auto-extracted by `memory_stop.sh`. Run `/memory-flush` to review.
320
+
321
+ **Content of this file is gitignored.**
322
+
323
+ ---
324
+ '
325
+
326
+ seed_pending_skeleton() {
327
+ printf '%s' "$PENDING_SKELETON" > "$1/_pending.md"
328
+ }
329
+
330
+ test_when_pending_empty_then_sweep_does_not_touch_pending() {
331
+ local mem; mem="$(mktemp -d)"; trap "rm -rf $mem" RETURN
332
+ seed_skel "$mem"
333
+ seed_pending_skeleton "$mem"
334
+ local before; before="$(cat "$mem/_pending.md")"
335
+ sweep auto-close "$mem" >/dev/null || { fail "sweep crashed on empty pending"; return 1; }
336
+ sweep prose-scan "$mem" "" >/dev/null || { fail "sweep crashed on empty pending"; return 1; }
337
+ sweep stale-sweep "$mem" "" >/dev/null || { fail "sweep crashed on empty pending"; return 1; }
338
+ local after; after="$(cat "$mem/_pending.md")"
339
+ if [ "$before" = "$after" ]; then return 0; fi
340
+ fail "_pending.md mutated by sweep.py — sweep must stay scoped to canonical files"
341
+ diff <(printf '%s' "$before") <(printf '%s' "$after") || true
342
+ return 1
343
+ }
344
+
345
+ test_when_pending_empty_AND_q999_has_resolved_at_then_q999_is_swept() {
346
+ local mem; mem="$(mktemp -d)"; trap "rm -rf $mem" RETURN
347
+ seed_skel "$mem"
348
+ seed_pending_skeleton "$mem"
349
+ add "$mem" "pending-questions" "## Q-999
350
+
351
+ - Question: fast-path canonical-sweep regression trap
352
+ - verified-at: HEAD
353
+ - last-touched: $(today)
354
+ - resolved-at: 2026-05-17"
355
+ local report; report="$(sweep auto-close "$mem")" || { fail "sweep crashed"; return 1; }
356
+ assert_file_not_contains "$mem/pending-questions.md" "## Q-999" "Q-999 must be auto-closed even when _pending.md is empty" || return 1
357
+ assert_contains "$report" '"closed": 1' "expected closed count 1 (got: $report)" || return 1
358
+ }
359
+
360
+ # --- backlog-memory-bucket: routing + bootstrap + stale-exempt + verbatim ----
361
+ # Covers AC-005, AC-006, AC-007, AC-009, AC-011 from
362
+ # docs/specs/backlog-memory-bucket.md. All start RED until sweep.py adds
363
+ # 'backlog' to CANONICAL_FILES + STALE_EXEMPT_FILES, and README.md documents
364
+ # the new register.
365
+
366
+ test_when_promote_user_candidate_writes_canonical_entry_with_status_open_and_verbatim() {
367
+ local mem; mem="$(mktemp -d)"; trap "rm -rf $mem" RETURN
368
+ seed_skel "$mem"
369
+ # Curator-style entry: the shape /memory-flush would write on promotion.
370
+ add "$mem" "backlog" "## add-retry-to-webhook-worker-3f2a
371
+
372
+ > verbatim (user, $(today)):
373
+ > TODO: add retry to webhook worker
374
+
375
+ - source: user-instruction
376
+ - status: open
377
+ - raised-on: $(today)
378
+ - raised-in-context: backlog-memory-bucket
379
+ - verified-at: HEAD
380
+ - last-touched: $(today)"
381
+ # Auto-close should NOT touch this entry (no closure field present).
382
+ local report; report="$(sweep auto-close "$mem")" || { fail "AC-005 sweep crashed"; return 1; }
383
+ assert_file_contains "$mem/backlog.md" "## add-retry-to-webhook-worker-3f2a" "AC-005 open entry must survive auto-close" || return 1
384
+ assert_file_contains "$mem/backlog.md" "status: open" "AC-005 status:open missing" || return 1
385
+ assert_file_contains "$mem/backlog.md" "> verbatim (user," "AC-005 verbatim blockquote missing" || return 1
386
+ assert_file_contains "$mem/backlog.md" "raised-on: $(today)" "AC-005 raised-on missing" || return 1
387
+ assert_file_contains "$mem/backlog.md" "raised-in-context: backlog-memory-bucket" "AC-005 raised-in-context missing" || return 1
388
+ assert_contains "$report" '"closed": 0' "AC-005 expected closed:0 (open entry shouldn't auto-close)" || return 1
389
+ }
390
+
391
+ test_when_bootstrap_entry_has_superseded_at_today_then_auto_close_removes_it() {
392
+ local mem; mem="$(mktemp -d)"; trap "rm -rf $mem" RETURN
393
+ seed_skel "$mem"
394
+ add "$mem" "backlog" "## bootstrap
395
+
396
+ - source: inferred-from-code
397
+ - status: dropped
398
+ - raised-on: $(today)
399
+ - raised-in-context: backlog-memory-bucket
400
+ - verified-at: HEAD
401
+ - last-touched: $(today)
402
+ - superseded-at: $(today)"
403
+ local report; report="$(sweep auto-close "$mem")" || { fail "AC-006 sweep crashed"; return 1; }
404
+ assert_file_not_contains "$mem/backlog.md" "## bootstrap" "AC-006 bootstrap must be auto-closed" || return 1
405
+ assert_contains "$report" '"closed": 1' "AC-006 expected closed:1 (got: $report)" || return 1
406
+ }
407
+
408
+ test_when_backlog_entry_verified_at_old_sha_then_not_classified_stale() {
409
+ local mem; mem="$(mktemp -d)"; trap "rm -rf $mem" RETURN
410
+ seed_skel "$mem"
411
+ # Non-git tempdir → stale predicate falls back to days-since(last-touched).
412
+ # 120 days ago should be stale for any other canonical file; backlog must
413
+ # be stale-exempt and NOT surface.
414
+ add "$mem" "backlog" "## ancient-intent-aaaa
415
+
416
+ > verbatim (user, $(days_ago 120)):
417
+ > TODO: this is intentionally aged
418
+
419
+ - source: user-instruction
420
+ - status: open
421
+ - raised-on: $(days_ago 120)
422
+ - raised-in-context: legacy-workflow
423
+ - verified-at: HEAD
424
+ - last-touched: $(days_ago 120)"
425
+ # stale-sweep reads one reply per surfaced entry from stdin; if backlog is
426
+ # correctly stale-exempt, NO entry is surfaced and stdin is never consumed.
427
+ local report; report="$(sweep stale-sweep "$mem" "")" || { fail "AC-009 sweep crashed"; return 1; }
428
+ assert_file_contains "$mem/backlog.md" "## ancient-intent-aaaa" "AC-009 backlog entry must survive stale-sweep" || return 1
429
+ assert_contains "$report" '"reverified": 0' "AC-009 expected reverified:0 (entry never surfaced)" || return 1
430
+ assert_contains "$report" '"deleted": 0' "AC-009 expected deleted:0" || return 1
431
+ assert_contains "$report" '"mark_closed": 0' "AC-009 expected mark_closed:0" || return 1
432
+ assert_contains "$report" '"kept": 0' "AC-009 expected kept:0 (kept counts a surfaced entry that was skipped — backlog should never surface)" || return 1
433
+ }
434
+
435
+ test_when_readme_documents_backlog_and_assistant_deferral_then_present() {
436
+ # Asserts the LIVE README.md (which the implement worker edits) — not a
437
+ # fixture. Covers AC-007 schema-doc lockstep.
438
+ local readme="$REPO_ROOT/.claude/memory/README.md"
439
+ [ -f "$readme" ] || { fail "AC-007 README missing at $readme"; return 1; }
440
+ assert_file_contains "$readme" "backlog.md" "AC-007 README missing backlog.md mention" || return 1
441
+ assert_file_contains "$readme" "assistant-deferral" "AC-007 README missing assistant-deferral provenance value" || return 1
442
+ # backlog.md should be listed under both the Files table and the stable-key
443
+ # table; greedy substring matches the row prefix.
444
+ local backlog_rows; backlog_rows="$(grep -cE '^\|\s*`backlog\.md`' "$readme" 2>/dev/null || true)"
445
+ [ -z "$backlog_rows" ] && backlog_rows=0
446
+ if [ "$backlog_rows" -lt 2 ]; then
447
+ fail "AC-007 expected backlog.md row in BOTH Files table and stable-key table (>=2 rows); got $backlog_rows"
448
+ return 1
449
+ fi
450
+ }
451
+
452
+ # --- workflow-loop-closing-hygiene: stamp-closure mode (Goal 3) --------------
453
+ # Covers AC-005, AC-006, AC-007, AC-008 from
454
+ # docs/specs/workflow-loop-closing-hygiene.md. All start RED until sweep.py
455
+ # adds `mode_stamp_closure` + `--backlog-keys` arg + a 4th MODE_DISPATCH entry.
456
+
457
+ # Reuse the existing `sweep` helper for stamp-closure: pass a 4th positional
458
+ # argument forwarding the --backlog-keys CSV. The current helper signature
459
+ # `sweep mode mem replies` — we extend behavior with a sibling invoker so the
460
+ # existing call sites stay untouched.
461
+ sweep_stamp_closure() {
462
+ local mem="$1" keys="${2:-}"
463
+ if [ ! -f "$SWEEP" ]; then
464
+ echo "{\"error\":\"sweep.py missing\"}"
465
+ return 127
466
+ fi
467
+ python3 "$SWEEP" --mode stamp-closure --memory-dir "$mem" --backlog-keys "$keys"
468
+ }
469
+
470
+ # Add a backlog entry shaped like the canonical write from /memory-flush.
471
+ add_open_backlog_entry() {
472
+ local mem="$1" key="$2"
473
+ add "$mem" "backlog" "## $key
474
+
475
+ > verbatim (user, $(today)):
476
+ > stub intent line for $key
477
+
478
+ - source: user-instruction
479
+ - status: open
480
+ - raised-on: $(today)
481
+ - raised-in-context: test-fixture
482
+ - verified-at: HEAD
483
+ - last-touched: $(today)"
484
+ }
485
+
486
+ test_when_stamp_closure_runs_then_status_and_superseded_at_set() {
487
+ local mem; mem="$(mktemp -d)"; trap "rm -rf $mem" RETURN
488
+ seed_skel "$mem"
489
+ add_open_backlog_entry "$mem" "k1"
490
+ add_open_backlog_entry "$mem" "k2"
491
+ add_open_backlog_entry "$mem" "k3"
492
+ local report; report="$(sweep_stamp_closure "$mem" "k1,k2")" \
493
+ || { fail "AC-005 sweep crashed (got: $report)"; return 1; }
494
+ assert_contains "$report" '"stamped": 2' "AC-005 expected stamped:2 (got: $report)" || return 1
495
+ # k1 and k2 must now carry status: picked-up + superseded-at: today.
496
+ # k3 must remain status: open (no superseded-at:).
497
+ python3 - "$mem/backlog.md" "$(today)" <<'PY' || return 1
498
+ import re, sys
499
+ path, today = sys.argv[1], sys.argv[2]
500
+ text = open(path).read()
501
+ def find(key):
502
+ m = re.search(rf'^## {re.escape(key)}$(.*?)(?=^## |\Z)', text, re.M | re.DOTALL)
503
+ return m.group(1) if m else ''
504
+ for key in ("k1", "k2"):
505
+ block = find(key)
506
+ if not block:
507
+ sys.exit(f"missing block for {key}")
508
+ if 'status: picked-up' not in block:
509
+ sys.exit(f"{key}: expected status: picked-up; got: {block!r}")
510
+ if f'superseded-at: {today}' not in block:
511
+ sys.exit(f"{key}: expected superseded-at: {today}; got: {block!r}")
512
+ k3 = find("k3")
513
+ if 'status: open' not in k3:
514
+ sys.exit(f"k3: expected status: open (untouched); got: {k3!r}")
515
+ if 'superseded-at:' in k3:
516
+ sys.exit(f"k3: expected NO superseded-at: (untouched); got: {k3!r}")
517
+ PY
518
+ }
519
+
520
+ test_when_stamp_closure_then_auto_close_deletes() {
521
+ local mem; mem="$(mktemp -d)"; trap "rm -rf $mem" RETURN
522
+ seed_skel "$mem"
523
+ add_open_backlog_entry "$mem" "k1"
524
+ add_open_backlog_entry "$mem" "k2"
525
+ sweep_stamp_closure "$mem" "k1,k2" >/dev/null \
526
+ || { fail "AC-007 stamp-closure crashed"; return 1; }
527
+ local report; report="$(sweep auto-close "$mem")" \
528
+ || { fail "AC-007 auto-close crashed"; return 1; }
529
+ assert_file_not_contains "$mem/backlog.md" "## k1" "AC-007 expected k1 deleted by auto-close" || return 1
530
+ assert_file_not_contains "$mem/backlog.md" "## k2" "AC-007 expected k2 deleted by auto-close" || return 1
531
+ assert_contains "$report" '"closed": 2' "AC-007 expected closed:2 (got: $report)" || return 1
532
+ }
533
+
534
+ test_when_stamp_closure_with_empty_keys_then_zero_stamped() {
535
+ local mem; mem="$(mktemp -d)"; trap "rm -rf $mem" RETURN
536
+ seed_skel "$mem"
537
+ add_open_backlog_entry "$mem" "k1"
538
+ local before; before="$(cat "$mem/backlog.md")"
539
+ local report; report="$(sweep_stamp_closure "$mem" "")" \
540
+ || { fail "AC-008 sweep crashed on empty keys"; return 1; }
541
+ assert_contains "$report" '"stamped": 0' "AC-008 expected stamped:0 (got: $report)" || return 1
542
+ local after; after="$(cat "$mem/backlog.md")"
543
+ if [ "$before" = "$after" ]; then return 0; fi
544
+ fail "AC-008 backlog.md was modified despite empty keys"
545
+ diff <(printf '%s' "$before") <(printf '%s' "$after") || true
546
+ return 1
547
+ }
548
+
549
+ test_when_stamp_closure_with_nonexistent_key_then_missing_list() {
550
+ local mem; mem="$(mktemp -d)"; trap "rm -rf $mem" RETURN
551
+ seed_skel "$mem"
552
+ add_open_backlog_entry "$mem" "k1"
553
+ local report; report="$(sweep_stamp_closure "$mem" "nonexistent-key")" \
554
+ || { fail "AC-008 sweep crashed (got: $report)"; return 1; }
555
+ assert_contains "$report" '"stamped": 0' "AC-008 expected stamped:0 for missing key" || return 1
556
+ assert_contains "$report" '"missing"' "AC-008 expected missing list in report (got: $report)" || return 1
557
+ assert_contains "$report" 'nonexistent-key' "AC-008 expected key in missing list" || return 1
558
+ }
559
+
560
+ test_when_stamp_closure_called_twice_then_idempotent() {
561
+ local mem; mem="$(mktemp -d)"; trap "rm -rf $mem" RETURN
562
+ seed_skel "$mem"
563
+ add_open_backlog_entry "$mem" "k1"
564
+ sweep_stamp_closure "$mem" "k1" >/dev/null \
565
+ || { fail "AC-005 first stamp crashed"; return 1; }
566
+ sweep_stamp_closure "$mem" "k1" >/dev/null \
567
+ || { fail "AC-005 second stamp crashed"; return 1; }
568
+ # status: picked-up should appear exactly once; superseded-at: today exactly once.
569
+ local picked_count; picked_count="$(grep -c 'status: picked-up' "$mem/backlog.md")"
570
+ local sup_count; sup_count="$(grep -c "superseded-at: $(today)" "$mem/backlog.md")"
571
+ if [ "$picked_count" -ne 1 ] || [ "$sup_count" -ne 1 ]; then
572
+ fail "AC-005 idempotency violated: status-count=$picked_count, superseded-count=$sup_count (expected 1, 1)"
573
+ return 1
574
+ fi
575
+ }
576
+
577
+ test_when_stamp_closure_missing_keys_arg_then_argparse_error() {
578
+ if [ ! -f "$SWEEP" ]; then
579
+ fail "AC-005 sweep.py missing"
580
+ return 1
581
+ fi
582
+ python3 "$SWEEP" --mode stamp-closure --memory-dir "$REPO_ROOT/.claude/memory" >/dev/null 2>&1
583
+ local ec=$?
584
+ if [ "$ec" -ne 2 ]; then
585
+ fail "AC-005 expected argparse exit 2 when --backlog-keys missing, got $ec"
586
+ return 1
587
+ fi
588
+ }
589
+
307
590
  # --- runner -------------------------------------------------------------------
308
591
 
309
592
  run test_when_resolved_at_present_on_pending_then_flush_removes_block
@@ -317,6 +600,22 @@ run test_when_2_stale_then_flush_offers_reverify_then_delete_prompts
317
600
  run test_when_stale_mark_closed_on_pending_then_resolved_at_added_not_deleted
318
601
  run test_when_no_closure_no_prose_no_stale_then_entry_survives_all_paths
319
602
  run test_when_pre_spec_entry_no_source_no_verbatim_then_grandfathered
603
+ run test_when_pending_empty_then_sweep_does_not_touch_pending
604
+ run test_when_pending_empty_AND_q999_has_resolved_at_then_q999_is_swept
605
+
606
+ # backlog-memory-bucket coverage
607
+ run test_when_promote_user_candidate_writes_canonical_entry_with_status_open_and_verbatim
608
+ run test_when_bootstrap_entry_has_superseded_at_today_then_auto_close_removes_it
609
+ run test_when_backlog_entry_verified_at_old_sha_then_not_classified_stale
610
+ run test_when_readme_documents_backlog_and_assistant_deferral_then_present
611
+
612
+ # workflow-loop-closing-hygiene: stamp-closure coverage
613
+ run test_when_stamp_closure_runs_then_status_and_superseded_at_set
614
+ run test_when_stamp_closure_then_auto_close_deletes
615
+ run test_when_stamp_closure_with_empty_keys_then_zero_stamped
616
+ run test_when_stamp_closure_with_nonexistent_key_then_missing_list
617
+ run test_when_stamp_closure_called_twice_then_idempotent
618
+ run test_when_stamp_closure_missing_keys_arg_then_argparse_error
320
619
 
321
620
  echo "----"
322
621
  echo "Passed: $PASS Failed: $FAIL"
@@ -77,7 +77,8 @@ Create tasks via `TaskCreate`; wire `addBlockedBy` so the chain is sequential. U
77
77
  - **Task B — implement-tick**: subject `"Run /implement for <slug>"`; metadata `{phase: "implement-tick", slug}`; activeForm `"Running implement"`; `addBlockedBy [A]`.
78
78
  - **Task C — verify-tick**: subject `"Run inline verify for <slug>"`; metadata `{phase: "verify-tick", slug}`; activeForm `"Running verify (inlined)"`; `addBlockedBy [B]`. The harness, when this task becomes next-pending, inlines the four mechanical operations from `.claude/skills/verify/SKILL.md` rather than invoking that skill via the Skill tool (the verify skill is contract-only after the harness-auto-continuation refactor).
79
79
  - **Tasks D₁..D_N — design-ui-tick (post-verify design implementation step; only when design_calls_rows is non-empty AND the implement write_set intersects `tdd.ui_globs`)**: one task per row. Subject `"Run /design-ui for <row.slug>"`; metadata `{phase: "design-ui-tick", slug, row_index: i}`; activeForm `"Running design-ui row <i>"`; `addBlockedBy [C]` for D₁, then chained `addBlockedBy [D_{i-1}]`. The design-ui worker handles the design implementation per the spec's `## Design calls` rows. After every D_i completes, the harness inlines a second verify pass (re-stamps `last_test_result`) to confirm the design work did not break behavior tests.
80
- - **Task Ztdd-finalize**: subject `"Finalize tdd for <slug>"`; metadata `{phase: "tdd-finalize", slug}`; activeForm `"Finalizing tdd"`; `addBlockedBy` the last D_i (or C if no design-ui). On execution, the harness appends `"tdd"` to `workflow.json completed`, writes `harness_state: continue` with reason "tdd green; next: simplify", and proceeds.
80
+ - **Task Edrift-check-tick (spec-to-implementation drift analysis; seeded for every spec-track workflow)**: subject `"Run drift-check for <slug>"`; metadata `{phase: "drift-check-tick", slug}`; activeForm `"Running drift-check (inlined)"`; `addBlockedBy [D_N]` if any design-ui-tick exists, else `[C]`. The harness inlines `python3 .claude/skills/tdd/drift_check.py --slug <slug>` against the approved spec and the branch diff. On exit 0 (zero unresolved): write the drift report path to the harness log; continue to Task Z. On exit 1 (≥ 1 unresolved): EXIT LOOP with YIELD (`reason: "drift analysis: <N> unresolved items"`); the user investigates and either fixes the impl gap or amends the spec + re-`/approve-spec`s. NO auto-loop. On `chore`-track workflows (no spec on disk), drift_check exits 0 with "no spec; skipped" and the harness proceeds to Z. On the workflow that initially introduces drift-check-tick, the harness instance in flight at that workflow predates the SKILL.md update and SHALL NOT seed Task E — the helper is unit-tested via the recipe scenarios and live runtime use begins in the next spec-track workflow.
81
+ - **Task Z — tdd-finalize**: subject `"Finalize tdd for <slug>"`; metadata `{phase: "tdd-finalize", slug}`; activeForm `"Finalizing tdd"`; `addBlockedBy` Task E (if seeded), else the last D_i, else C. On execution, the harness appends `"tdd"` to `workflow.json → completed`, writes `harness_state: continue` with reason "tdd green; next: simplify", and proceeds.
81
82
 
82
83
  ## 7. Write harness_state and yield
83
84
 
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ """Spec-to-implementation drift analysis (Phase 6 worker step).
3
+
4
+ CLI:
5
+ python3 drift_check.py --slug <slug> [--project-root <path>] [--diff <path>]
6
+
7
+ Reads `docs/specs/<slug>.md` from `--project-root`, scores every numbered AC
8
+ in the ## Acceptance criteria table and every row of the ## Design calls
9
+ table against the implementation diff (--diff override, else `git diff
10
+ <merge-base>..HEAD` against the main branch). Writes a markdown report at
11
+ `<project-root>/.claude/state/drift/<slug>.md` with a per-item verdict of
12
+ `resolved | unresolved | unknown` plus evidence.
13
+
14
+ Exit codes:
15
+ 0 zero unresolved (`resolved` and `unknown` items are advisory)
16
+ 1 >=1 unresolved
17
+ 2 tool error (handled by argparse / unhandled exception)
18
+
19
+ Special case: spec file missing at the named slug → print "no spec; skipped"
20
+ to stdout, exit 0, no report file written (supports chore-track workflows).
21
+ """
22
+ from __future__ import annotations
23
+ import argparse
24
+ import re
25
+ import subprocess
26
+ import sys
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+
30
+ # --- Foundation: regex + IO --------------------------------------------------
31
+
32
+ AC_ROW_RE = re.compile(r'^\|\s*(AC-\d+)\s*\|', re.MULTILINE)
33
+ DESIGN_CALLS_SECTION_RE = re.compile(
34
+ r'^##\s+Design calls\s*\n(.*?)(?=^##\s|\Z)',
35
+ re.MULTILINE | re.DOTALL,
36
+ )
37
+ DESIGN_ROW_RE = re.compile(r'^\|\s*([^|]+?)\s*\|', re.MULTILINE)
38
+ NONE_BODY_RE = re.compile(r'^[\s\-]*\*?\(?none\)?\*?[\s\-]*$', re.IGNORECASE)
39
+
40
+
41
+ def load_spec(project_root: Path, slug: str) -> str | None:
42
+ spec_path = project_root / 'docs' / 'specs' / f'{slug}.md'
43
+ if not spec_path.is_file():
44
+ return None
45
+ return spec_path.read_text(encoding='utf-8', errors='replace')
46
+
47
+
48
+ def load_diff(project_root: Path, diff_path: Path | None) -> str:
49
+ if diff_path:
50
+ return diff_path.read_text(encoding='utf-8', errors='replace')
51
+ try:
52
+ merge_base = subprocess.check_output(
53
+ ['git', '-C', str(project_root), 'merge-base', 'HEAD', 'main'],
54
+ stderr=subprocess.DEVNULL, text=True,
55
+ ).strip()
56
+ return subprocess.check_output(
57
+ ['git', '-C', str(project_root), 'diff', f'{merge_base}..HEAD'],
58
+ stderr=subprocess.DEVNULL, text=True,
59
+ )
60
+ except Exception:
61
+ return ''
62
+
63
+
64
+ def write_report(project_root: Path, slug: str, body: str) -> Path:
65
+ out_dir = project_root / '.claude' / 'state' / 'drift'
66
+ out_dir.mkdir(parents=True, exist_ok=True)
67
+ out_path = out_dir / f'{slug}.md'
68
+ out_path.write_text(body, encoding='utf-8')
69
+ return out_path
70
+
71
+
72
+ def now_iso() -> str:
73
+ return datetime.now(tz=timezone.utc).isoformat(timespec='seconds').replace('+00:00', 'Z')
74
+
75
+
76
+ def added_lines(diff_text: str) -> list[str]:
77
+ return [ln for ln in diff_text.splitlines()
78
+ if ln.startswith('+') and not ln.startswith('+++')]
79
+
80
+
81
+ # --- Domain: parse + score ---------------------------------------------------
82
+
83
+ def parse_acs(spec_text: str) -> list[str]:
84
+ return AC_ROW_RE.findall(spec_text)
85
+
86
+
87
+ def parse_design_calls(spec_text: str) -> list[str]:
88
+ """Return the list of row-slugs in the spec's `## Design calls` table.
89
+ Empty list iff section absent or body is the *(none)* sentinel."""
90
+ m = DESIGN_CALLS_SECTION_RE.search(spec_text)
91
+ if not m:
92
+ return []
93
+ body = m.group(1).strip()
94
+ if NONE_BODY_RE.match(body) or '*(none)*' in body or '(none)' in body.lower():
95
+ return []
96
+ rows = []
97
+ for row_match in DESIGN_ROW_RE.finditer(body):
98
+ first_cell = row_match.group(1).strip()
99
+ if re.match(r'^[\s:|\-]+$', first_cell):
100
+ continue
101
+ if first_cell.lower() in ('slug', 'kind'):
102
+ continue
103
+ rows.append(first_cell)
104
+ return rows
105
+
106
+
107
+ def score_against_diff(item_id: str, diff_added: list[str]) -> tuple[str, str]:
108
+ for ln in diff_added:
109
+ if item_id in ln:
110
+ snippet = ln.strip()
111
+ if len(snippet) > 120:
112
+ snippet = snippet[:117] + '...'
113
+ return ('resolved', f'found in diff: {snippet}')
114
+ return ('unresolved', 'no diff added-line references this item')
115
+
116
+
117
+ def render_report(slug: str,
118
+ acs: list[tuple[str, str, str]],
119
+ design_rows: list[tuple[str, str, str]]) -> str:
120
+ lines = [
121
+ f'# Drift report — {slug}',
122
+ '',
123
+ f'Generated at: {now_iso()}',
124
+ '',
125
+ '## Acceptance criteria',
126
+ '',
127
+ '| kind | id | verdict | evidence |',
128
+ '|---|---|---|---|',
129
+ ]
130
+ for ac_id, verdict, evidence in acs:
131
+ lines.append(f'| ac | {ac_id} | {verdict} | {evidence} |')
132
+ lines.append('')
133
+ lines.append('## Design calls')
134
+ lines.append('')
135
+ if not design_rows:
136
+ lines.append('no design calls — skipped')
137
+ else:
138
+ lines.append('| kind | id | verdict | evidence |')
139
+ lines.append('|---|---|---|---|')
140
+ for row_slug, verdict, evidence in design_rows:
141
+ lines.append(f'| design-call | {row_slug} | {verdict} | {evidence} |')
142
+ lines.append('')
143
+ return '\n'.join(lines)
144
+
145
+
146
+ # --- Orchestration -----------------------------------------------------------
147
+
148
+ def main(argv) -> int:
149
+ parser = argparse.ArgumentParser(description='Spec-to-implementation drift analysis')
150
+ parser.add_argument('--slug', required=True)
151
+ parser.add_argument('--project-root', default='.')
152
+ parser.add_argument('--diff', default=None)
153
+ args = parser.parse_args(argv)
154
+
155
+ project_root = Path(args.project_root).resolve()
156
+ spec_text = load_spec(project_root, args.slug)
157
+ if spec_text is None:
158
+ print('no spec; skipped')
159
+ return 0
160
+
161
+ diff_path = Path(args.diff).resolve() if args.diff else None
162
+ diff_text = load_diff(project_root, diff_path)
163
+ diff_added = added_lines(diff_text)
164
+
165
+ ac_results = [(ac_id, *score_against_diff(ac_id, diff_added))
166
+ for ac_id in parse_acs(spec_text)]
167
+
168
+ design_results = [(slug_, *score_against_diff(slug_, diff_added))
169
+ for slug_ in parse_design_calls(spec_text)]
170
+
171
+ report = render_report(args.slug, ac_results, design_results)
172
+ write_report(project_root, args.slug, report)
173
+
174
+ unresolved = sum(1 for _, v, _ in ac_results if v == 'unresolved')
175
+ unresolved += sum(1 for _, v, _ in design_results if v == 'unresolved')
176
+ return 0 if unresolved == 0 else 1
177
+
178
+
179
+ if __name__ == '__main__':
180
+ sys.exit(main(sys.argv[1:]))