@friedbotstudio/create-baseline 0.2.1 → 0.4.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.
- package/README.md +17 -7
- package/bin/cli.js +197 -119
- package/obj/template/.claude/commands/grant-push.md +19 -0
- package/obj/template/.claude/commands/init-project.md +26 -4
- package/obj/template/.claude/hooks/consent_gate_grant.mjs +107 -0
- package/obj/template/.claude/hooks/git_commit_guard.mjs +224 -0
- package/obj/template/.claude/hooks/harness_continuation.sh +101 -34
- package/obj/template/.claude/hooks/lib/common.mjs +283 -0
- package/obj/template/.claude/hooks/lib/common.sh +1 -1
- package/obj/template/.claude/hooks/memory_session_start.sh +20 -6
- package/obj/template/.claude/hooks/memory_stop.sh +161 -2
- package/obj/template/.claude/hooks/spec_approval_guard.sh +1 -1
- package/obj/template/.claude/hooks/swarm_approval_guard.sh +1 -1
- package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +7 -7
- package/obj/template/.claude/hooks/tests/fixtures/memory_stop_landmark_baseline.txt +21 -0
- package/obj/template/.claude/hooks/tests/fixtures/regenerate-ac008.sh +47 -0
- package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +7 -3
- package/obj/template/.claude/hooks/tests/memory_stop_intent_test.sh +329 -0
- package/obj/template/.claude/hooks/tests/regenerate_ac008_test.sh +99 -0
- package/obj/template/.claude/memory/README.md +8 -3
- package/obj/template/.claude/memory/backlog.md +12 -0
- package/obj/template/.claude/project.json +6 -1
- package/obj/template/.claude/settings.json +3 -4
- package/obj/template/.claude/skills/audit-baseline/audit.sh +28 -16
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_empty_body.md +4 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_with_entries.md +9 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_no_opener.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_opener_only.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/preamble_check_test.sh +147 -0
- package/obj/template/.claude/skills/changelog/SKILL.md +69 -0
- package/obj/template/.claude/skills/changelog/changelog.mjs +163 -0
- package/obj/template/.claude/skills/changelog/classifier.mjs +49 -0
- package/obj/template/.claude/skills/changelog/state-writer.mjs +19 -0
- package/obj/template/.claude/skills/changelog/tests/consent-expired_test.sh +126 -0
- package/obj/template/.claude/skills/changelog/tests/golden-path_test.sh +191 -0
- package/obj/template/.claude/skills/changelog/tests/idempotent-reentry_test.sh +121 -0
- package/obj/template/.claude/skills/changelog/tests/keepachangelog-unreleased-preserved_test.mjs +149 -0
- package/obj/template/.claude/skills/changelog/tests/non-git-shortcircuit_test.sh +98 -0
- package/obj/template/.claude/skills/changelog/tests/preview-only_test.sh +96 -0
- package/obj/template/.claude/skills/changelog/tests/run.sh +28 -0
- package/obj/template/.claude/skills/changelog/unreleased-writer.mjs +155 -0
- package/obj/template/.claude/skills/changelog/version-preview.mjs +124 -0
- package/obj/template/.claude/skills/chore/SKILL.md +5 -3
- package/obj/template/.claude/skills/commit/SKILL.md +5 -4
- package/obj/template/.claude/skills/copywriting/LICENSE +21 -0
- package/obj/template/.claude/skills/copywriting/NOTICE +23 -0
- package/obj/template/.claude/skills/copywriting/SKILL.md +1 -1
- package/obj/template/.claude/skills/design-ui/SKILL.md +23 -5
- package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +26 -5
- package/obj/template/.claude/skills/design-ui/references/orchestration.md +1 -0
- package/obj/template/.claude/skills/design-ui/references/state-machine.md +5 -3
- package/obj/template/.claude/skills/documentation/LICENSE +202 -0
- package/obj/template/.claude/skills/documentation/NOTICE +22 -0
- package/obj/template/.claude/skills/harness/SKILL.md +5 -1
- package/obj/template/.claude/skills/humanizer/LICENSE +21 -0
- package/obj/template/.claude/skills/humanizer/NOTICE +21 -0
- package/obj/template/.claude/skills/impeccable/LICENSE +202 -0
- package/obj/template/.claude/skills/impeccable/NOTICE +24 -0
- package/obj/template/.claude/skills/memory-flush/SKILL.md +20 -4
- package/obj/template/.claude/skills/memory-flush/sweep.py +74 -6
- package/obj/template/.claude/skills/memory-flush/tests/run.sh +300 -1
- package/obj/template/.claude/skills/tdd/SKILL.md +2 -1
- package/obj/template/.claude/skills/tdd/drift_check.py +180 -0
- package/obj/template/.claude/skills/tdd/tests/drift_check_test.sh +190 -0
- package/obj/template/.claude/skills/tdd/tests/run.sh +21 -0
- package/obj/template/.claude/skills/technical-tutorials/LICENSE +21 -0
- package/obj/template/.claude/skills/technical-tutorials/NOTICE +23 -0
- package/obj/template/.claude/skills/technical-tutorials/SKILL.md +1 -1
- package/obj/template/.claude/skills/triage/SKILL.md +11 -5
- package/obj/template/CLAUDE.md +36 -25
- package/obj/template/docs/init/seed.md +39 -24
- package/obj/template/manifest.json +73 -33
- package/package.json +5 -2
- package/src/CLAUDE.template.md +36 -25
- package/src/cli/merge.js +15 -10
- package/src/cli/tui/doctor.js +56 -0
- package/src/cli/tui/install.js +79 -0
- package/src/cli/tui/meta.js +30 -0
- package/src/cli/tui/tokens.js +38 -0
- package/src/cli/tui/upgrade.js +100 -0
- package/src/memory/backlog.template.md +12 -0
- package/src/project.template.json +6 -1
- package/src/seed.template.md +39 -24
- package/src/settings.template.json +3 -4
- package/obj/template/.claude/hooks/consent_gate_grant.sh +0 -89
- 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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
80
|
+
- **Task E — drift-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:]))
|