@friedbotstudio/create-baseline 0.2.0 → 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 (88) 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 +39 -21
  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/google-analytics/SKILL.md +129 -0
  42. package/obj/template/.claude/skills/google-analytics/references/audiences.md +389 -0
  43. package/obj/template/.claude/skills/google-analytics/references/bigquery.md +470 -0
  44. package/obj/template/.claude/skills/google-analytics/references/custom-dimensions.md +355 -0
  45. package/obj/template/.claude/skills/google-analytics/references/custom-events.md +383 -0
  46. package/obj/template/.claude/skills/google-analytics/references/data-management.md +416 -0
  47. package/obj/template/.claude/skills/google-analytics/references/debugview.md +364 -0
  48. package/obj/template/.claude/skills/google-analytics/references/events-fundamentals.md +398 -0
  49. package/obj/template/.claude/skills/google-analytics/references/gtag.md +502 -0
  50. package/obj/template/.claude/skills/google-analytics/references/gtm-integration.md +483 -0
  51. package/obj/template/.claude/skills/google-analytics/references/measurement-protocol.md +519 -0
  52. package/obj/template/.claude/skills/google-analytics/references/privacy.md +441 -0
  53. package/obj/template/.claude/skills/google-analytics/references/recommended-events.md +464 -0
  54. package/obj/template/.claude/skills/google-analytics/references/reporting.md +397 -0
  55. package/obj/template/.claude/skills/google-analytics/references/setup.md +344 -0
  56. package/obj/template/.claude/skills/google-analytics/references/user-tracking.md +417 -0
  57. package/obj/template/.claude/skills/harness/SKILL.md +3 -1
  58. package/obj/template/.claude/skills/humanizer/LICENSE +21 -0
  59. package/obj/template/.claude/skills/humanizer/NOTICE +21 -0
  60. package/obj/template/.claude/skills/impeccable/LICENSE +202 -0
  61. package/obj/template/.claude/skills/impeccable/NOTICE +24 -0
  62. package/obj/template/.claude/skills/memory-flush/SKILL.md +20 -4
  63. package/obj/template/.claude/skills/memory-flush/sweep.py +74 -6
  64. package/obj/template/.claude/skills/memory-flush/tests/run.sh +300 -1
  65. package/obj/template/.claude/skills/optimize-seo/SKILL.md +313 -0
  66. package/obj/template/.claude/skills/optimize-seo/scripts/pagespeed.mjs +197 -0
  67. package/obj/template/.claude/skills/pagespeed-insights/LICENSE.md +37 -0
  68. package/obj/template/.claude/skills/pagespeed-insights/SKILL.md +446 -0
  69. package/obj/template/.claude/skills/pagespeed-insights/reference.md +50 -0
  70. package/obj/template/.claude/skills/tdd/SKILL.md +2 -1
  71. package/obj/template/.claude/skills/tdd/drift_check.py +180 -0
  72. package/obj/template/.claude/skills/tdd/tests/drift_check_test.sh +190 -0
  73. package/obj/template/.claude/skills/tdd/tests/run.sh +21 -0
  74. package/obj/template/.claude/skills/technical-tutorials/LICENSE +21 -0
  75. package/obj/template/.claude/skills/technical-tutorials/NOTICE +23 -0
  76. package/obj/template/.claude/skills/technical-tutorials/SKILL.md +1 -1
  77. package/obj/template/.claude/skills/triage/SKILL.md +8 -3
  78. package/obj/template/CLAUDE.md +37 -26
  79. package/obj/template/docs/init/seed.md +38 -23
  80. package/obj/template/manifest.json +80 -33
  81. package/package.json +1 -1
  82. package/src/CLAUDE.template.md +37 -26
  83. package/src/memory/backlog.template.md +12 -0
  84. package/src/project.template.json +6 -1
  85. package/src/seed.template.md +38 -23
  86. package/src/settings.template.json +3 -4
  87. package/obj/template/.claude/hooks/consent_gate_grant.sh +0 -89
  88. package/obj/template/.claude/hooks/git_commit_guard.sh +0 -93
@@ -0,0 +1,24 @@
1
+ impeccable
2
+ ==========
3
+
4
+ This skill is vendored from the `impeccable` repository published by Phil
5
+ Bakaus (pbakaus) and is redistributed here under the terms of the Apache
6
+ License, Version 2.0 (see LICENSE in this directory).
7
+
8
+ Upstream:
9
+ Repository: https://github.com/pbakaus/impeccable
10
+ Author: Phil Bakaus (pbakaus)
11
+
12
+ Vendored into: .claude/skills/impeccable/
13
+ Vendored on: 2026-05-15 (this commit) — prior install date unknown; LICENSE
14
+ + NOTICE added retroactively as part of the licensing-attribution
15
+ drift fix in the branch-aware-git-policy workflow.
16
+
17
+ Local changes:
18
+ - Frontmatter `owner: baseline` added so the audit-baseline drift check
19
+ tracks this file as a shipped baseline artifact. The Apache 2.0 license
20
+ permits redistribution under attribution; this NOTICE file fulfills the
21
+ notice-preservation requirement.
22
+ - PROJECT_NOTES.md captures local annotations made in the course of using
23
+ impeccable within the baseline. The vendored SKILL.md and agents/* files
24
+ are otherwise unmodified from upstream.
@@ -1,9 +1,19 @@
1
1
  ---
2
2
  name: memory-flush
3
3
  owner: baseline
4
- description: Review the auto-extracted candidates in `.claude/memory/_pending.md` and commit keepers to the canonical memory files (`landmarks.md`, `libraries.md`, `decisions.md`, `landmines.md`, `conventions.md`, `pending-questions.md`). Invoke at session start when the SessionStart hook reports pending candidates, or any time `_pending.md` has accumulated entries you want to curate. Reset the pending body after flushing.
4
+ description: Review the auto-extracted candidates in `.claude/memory/_pending.md` and commit keepers to the canonical memory files (`landmarks.md`, `libraries.md`, `decisions.md`, `landmines.md`, `conventions.md`, `pending-questions.md`, `backlog.md`). Invoke at session start when the SessionStart hook reports pending candidates, or any time `_pending.md` has accumulated entries you want to curate. Reset the pending body after flushing.
5
5
  ---
6
6
 
7
+ # When invoked as Phase 10.6
8
+
9
+ This skill runs as **Phase 10.6** of every workflow track (intake / spec / tdd / chore), between `/archive` (Phase 10.5) and `/grant-commit` (Phase 11). The harness loop reads `.claude/memory/_pending.md` body, runs Step 0 canonical sweeps unconditionally, and on **empty pending** (zero `## CANDIDATE:` blocks) short-circuits the **fast-path**: Steps 1–5 are skipped, Step 6 emits a one-line "no pending candidates" report, and the skill returns success. This keeps the no-op cost bounded at ≤ 3 sweep.py invocations per Phase 10.6 invocation.
10
+
11
+ The skill is also user-invokable outside the workflow (ad-hoc curation). When invoked ad-hoc and `_pending.md` is non-empty, the full Steps 1–5 flow runs identically. The fast-path activates per-invocation based on `_pending.md` body state, not on workflow context.
12
+
13
+ `/commit` (Phase 11) refuses to proceed unless `memory-flush` is in `workflow.json → completed` (or in `exceptions`). Empty-pending fast-path still appends `"memory-flush"` to `completed` — the prereq is satisfied either way.
14
+
15
+ (See "Method" below for the full Step 0 / Steps 1–5 / Step 6 flow.)
16
+
7
17
  # memory-flush — curate auto-extracted memory candidates
8
18
 
9
19
  The `memory_stop.sh` hook appends candidates to `.claude/memory/_pending.md` after every turn. This skill reviews them in main context (where conversation richness is preserved), commits the keepers to the right canonical file with proper metadata, and resets the pending body.
@@ -18,13 +28,13 @@ The hook is a passive collector. **You are the curator.** Discard noise, promote
18
28
  - field: value
19
29
  - field: value
20
30
  ```
21
- - The six canonical files at `.claude/memory/<name>.md`. Read each before deciding where a candidate lands and whether it duplicates existing content.
31
+ - The seven canonical files at `.claude/memory/<name>.md`. Read each before deciding where a candidate lands and whether it duplicates existing content.
22
32
 
23
33
  # Method
24
34
 
25
35
  ## Step 0 — Canonical sweep (closure semantics)
26
36
 
27
- Before reviewing `_pending.md`, sweep the six canonical files for closed entries and stale entries. The `sweep.py` helper at `.claude/skills/memory-flush/sweep.py` is the deterministic actuator; this SOP composes the three modes.
37
+ Before reviewing `_pending.md`, sweep the seven canonical files for closed entries and stale entries. The `sweep.py` helper at `.claude/skills/memory-flush/sweep.py` is the deterministic actuator; this SOP composes the three modes.
28
38
 
29
39
  ### Step 0a — Auto-close structured closure fields
30
40
 
@@ -55,6 +65,10 @@ For each entry without a structured closure field, the helper scans the body aga
55
65
 
56
66
  You drive this step interactively: ask the user `Close <key> from <file>? (y / n / skip)` for each entry the helper surfaces, then feed the answers to the helper one per line.
57
67
 
68
+ ### Step 0a-bis — Stamp-closure mode (invoked from /commit, not from /memory-flush)
69
+
70
+ `sweep.py` also exposes a `--mode stamp-closure --backlog-keys <csv>` mode that writes `status: picked-up` + `superseded-at: <today>` to each named `backlog.md` entry. This mode is NOT invoked by `/memory-flush` Step 0; it is invoked by `/commit` Step 6 when `workflow.json → source_backlog_keys` is non-empty. The mode is idempotent (re-running on stamped entries rewrites `superseded-at:` to today; reports them under `already_closed`). The next `/memory-flush` Step 0a auto-close sweep then deletes the stamped entries per the existing `superseded-at:` closure trigger — so `/memory-flush`'s contract is unchanged; it just sees more closures in its `auto-close` step. Report shape: `{"stamped": N, "missing": [keys], "already_closed": [keys]}`.
71
+
58
72
  ### Step 0c — Stale sweep
59
73
 
60
74
  Only run when `memory_session_start.sh` reported stale > 0 this session, or the user asks. Invoke:
@@ -76,9 +90,11 @@ Read `_pending.md` in full. Then read the canonical file each candidate targets
76
90
  For each `## CANDIDATE:` block, decide one of:
77
91
 
78
92
  - **Promote.** The candidate is signal. Build the canonical entry shape (see `.claude/memory/README.md`) and append to the right file. If the candidate's stable key already exists in the canonical file → **replace** that entry; do not duplicate.
79
- - **Discard.** The candidate is noise (touched-once file with no clear role; a context7 query that resolved nothing useful; a path under generated/vendored code). No canonical write.
93
+ - **Discard.** The candidate is noise (touched-once file with no clear role; a context7 query that resolved nothing useful; a path under generated/vendored code; an intent line that was a passing chat phrase rather than real future work). No canonical write.
80
94
  - **Defer.** Useful but you don't have enough context to write a clean entry. Move the candidate verbatim to `pending-questions.md` as a `Q-NNN` entry phrased as "Should X be a landmark?" so the next session can decide. The pending body still gets reset at the end.
81
95
 
96
+ **Backlog candidates** (`## CANDIDATE: backlog → <slug>-<4hash>`) route to `backlog.md` with the canonical entry shape plus these fields: `status: open` (the initial state; transitions to `picked-up` or `dropped` are later edits), `raised-on: <ISO>`, `raised-in-context: <slug-or-(no active workflow)>`, the verbatim blockquote of the user/assistant intent line. Provenance is `source: user-instruction` for `role: user` candidates or `source: assistant-deferral` for `role: assistant` candidates. The verbatim is REQUIRED for both — `/memory-flush` SHALL reject promotion without it (per `.claude/memory/README.md → Source provenance`).
97
+
82
98
  ## Step 3 — Verify before promoting
83
99
 
84
100
  Per the project memory contract: every entry on the canonical files must have a `verified-at:` field. Verify the candidate's claim before writing:
@@ -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"