@haposoft/cafekit 0.16.7 → 0.18.1

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 (231) hide show
  1. package/README.md +14 -22
  2. package/bin/__tests__/codex-hooks-ownership.test.js +2 -2
  3. package/bin/__tests__/codex-hooks.test.js +169 -272
  4. package/bin/__tests__/codex-native.test.js +100 -202
  5. package/bin/__tests__/develop-contract.test.js +122 -1413
  6. package/bin/__tests__/docs-sync-on-demand.test.js +93 -0
  7. package/bin/__tests__/grok-envelope.test.js +0 -56
  8. package/bin/__tests__/hook-payload.test.js +4 -6
  9. package/bin/__tests__/legacy-authority-decoupled.test.js +154 -0
  10. package/bin/__tests__/legacy-hooks-retired.test.js +221 -0
  11. package/bin/__tests__/omp-bridge.test.js +20 -17
  12. package/bin/__tests__/omp-hooks.test.js +0 -19
  13. package/bin/__tests__/optional-skill-inventory.test.js +112 -4
  14. package/bin/__tests__/orca-session.test.js +0 -2
  15. package/bin/__tests__/package-inventory.test.js +90 -925
  16. package/bin/__tests__/runtime-dir.test.js +0 -3
  17. package/bin/__tests__/runtime-schema.test.js +3 -2
  18. package/bin/__tests__/skill-routing-source.test.js +27 -15
  19. package/bin/__tests__/spec-narrowing.test.js +30 -161
  20. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -437
  21. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  22. package/bin/__tests__/ui-ux-skill.test.js +101 -0
  23. package/bin/__tests__/usage-hook-retired.test.js +114 -0
  24. package/bin/__tests__/validator-grounding.test.js +18 -2949
  25. package/bin/lib/managed-writer.js +2 -3
  26. package/bin/phases/codex-runtime.js +27 -0
  27. package/bin/phases/copy-payload.js +13 -28
  28. package/bin/phases/omp-runtime.js +25 -0
  29. package/package.json +2 -3
  30. package/src/claude/CLAUDE.md +3 -10
  31. package/src/claude/agents/brainstormer.md +7 -3
  32. package/src/claude/agents/code-auditor.md +44 -56
  33. package/src/claude/agents/debugger.md +8 -88
  34. package/src/claude/agents/deployer.md +3 -5
  35. package/src/claude/agents/docs-keeper.md +6 -14
  36. package/src/claude/agents/git-ops.md +2 -4
  37. package/src/claude/agents/implementer.md +12 -16
  38. package/src/claude/agents/inspector.md +4 -3
  39. package/src/claude/agents/project-manager.md +9 -17
  40. package/src/claude/agents/researcher.md +9 -3
  41. package/src/claude/agents/spec-maker.md +15 -15
  42. package/src/claude/agents/test-runner.md +8 -11
  43. package/src/claude/agents/ui-ux-designer.md +0 -1
  44. package/src/claude/hooks/__tests__/fixtures/statusline-default.golden +1 -1
  45. package/src/claude/hooks/__tests__/spec-gate.test.js +244 -333
  46. package/src/claude/hooks/__tests__/spec-state-touched.test.js +160 -0
  47. package/src/claude/hooks/__tests__/state.test.js +68 -0
  48. package/src/claude/hooks/__tests__/statusline.test.js +140 -11
  49. package/src/claude/hooks/agent.cjs +2 -1
  50. package/src/claude/hooks/lib/hook-payload.cjs +2 -2
  51. package/src/claude/hooks/rules.cjs +2 -1
  52. package/src/claude/hooks/session.cjs +0 -1
  53. package/src/claude/hooks/spec-gate.cjs +17 -83
  54. package/src/claude/hooks/spec-state.cjs +142 -39
  55. package/src/claude/hooks/state.cjs +21 -2
  56. package/src/claude/migration-manifest.json +42 -22
  57. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  58. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  59. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  60. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  61. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  62. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  63. package/src/claude/rules/ai-dev-rules.md +2 -3
  64. package/src/claude/rules/hook-protocols.md +8 -15
  65. package/src/claude/rules/manage-docs.md +9 -23
  66. package/src/claude/rules/orchestrator.md +3 -4
  67. package/src/claude/rules/review-audit-self-decision.md +3 -3
  68. package/src/claude/rules/skill-domain-routing.md +1 -3
  69. package/src/claude/rules/skill-workflow-routing.md +4 -5
  70. package/src/claude/rules/state-sync.md +3 -12
  71. package/src/claude/rules/workflow.md +11 -19
  72. package/src/claude/runtime.schema.json +5 -3
  73. package/src/claude/scripts/generate-skill-catalog.cjs +7 -6
  74. package/src/claude/scripts/provenance.cjs +23 -5
  75. package/src/claude/scripts/spec-receipt.cjs +24 -121
  76. package/src/claude/scripts/spec-resolver.cjs +41 -458
  77. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  78. package/src/claude/settings/settings.json +1 -32
  79. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  80. package/src/claude/skills/{question → ask}/SKILL.md +9 -75
  81. package/src/claude/skills/brainstorm/SKILL.md +43 -131
  82. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  83. package/src/claude/skills/code-review/SKILL.md +29 -81
  84. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -8
  85. package/src/claude/skills/debug/SKILL.md +29 -149
  86. package/src/claude/skills/develop/SKILL.md +26 -83
  87. package/src/claude/skills/develop/references/parallel-waves.md +0 -8
  88. package/src/claude/skills/develop/references/quality-gate.md +15 -13
  89. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  90. package/src/claude/skills/docs/SKILL.md +2 -2
  91. package/src/claude/skills/docs/references/update-workflow.md +1 -1
  92. package/src/claude/skills/fix/SKILL.md +153 -0
  93. package/src/claude/skills/{hotfix → fix}/references/diagnosis-protocol.md +2 -2
  94. package/src/claude/skills/{hotfix → fix}/references/parallel-patterns.md +2 -2
  95. package/src/claude/skills/{hotfix → fix}/references/prevention-gate.md +3 -3
  96. package/src/claude/skills/{hotfix → fix}/references/review-cycle.md +1 -1
  97. package/src/claude/skills/git/SKILL.md +48 -9
  98. package/src/claude/skills/git/references/commit-protocols.md +35 -16
  99. package/src/claude/skills/git/references/worktree-blueprint.md +42 -6
  100. package/src/claude/skills/orca/SKILL.md +9 -8
  101. package/src/claude/skills/research/SKILL.md +17 -46
  102. package/src/claude/skills/scout/SKILL.md +61 -0
  103. package/src/claude/skills/{inspect → scout}/references/internal-inspection.md +26 -10
  104. package/src/claude/skills/specs/SKILL.md +22 -29
  105. package/src/claude/skills/specs/references/review.md +8 -6
  106. package/src/claude/skills/specs/references/templates.md +34 -41
  107. package/src/claude/skills/sync/SKILL.md +16 -45
  108. package/src/claude/skills/sync/references/rebind-and-audit.md +59 -0
  109. package/src/claude/skills/sync/references/sync-protocols.md +4 -13
  110. package/src/claude/skills/test/SKILL.md +39 -104
  111. package/src/claude/skills/test/references/execution-strategy.md +93 -21
  112. package/src/claude/skills/test/references/failure-triage.md +1 -1
  113. package/src/claude/skills/ui-ux/LICENSE +21 -0
  114. package/src/claude/skills/ui-ux/SKILL.md +497 -0
  115. package/src/claude/skills/ui-ux/references/brand-tokens.md +90 -0
  116. package/src/claude/skills/ui-ux/references/budgets.md +113 -0
  117. package/src/claude/skills/ui-ux/references/checklist.md +271 -0
  118. package/src/claude/skills/ui-ux/references/components/accordion.md +246 -0
  119. package/src/claude/skills/ui-ux/references/components/avatar.md +153 -0
  120. package/src/claude/skills/ui-ux/references/components/banner.md +43 -0
  121. package/src/claude/skills/ui-ux/references/components/breadcrumb.md +201 -0
  122. package/src/claude/skills/ui-ux/references/components/button.md +142 -0
  123. package/src/claude/skills/ui-ux/references/components/card.md +113 -0
  124. package/src/claude/skills/ui-ux/references/components/charts.md +376 -0
  125. package/src/claude/skills/ui-ux/references/components/chat.md +132 -0
  126. package/src/claude/skills/ui-ux/references/components/choice-controls.md +369 -0
  127. package/src/claude/skills/ui-ux/references/components/comment-thread.md +17 -0
  128. package/src/claude/skills/ui-ux/references/components/description-list.md +116 -0
  129. package/src/claude/skills/ui-ux/references/components/empty-state.md +98 -0
  130. package/src/claude/skills/ui-ux/references/components/file-upload.md +107 -0
  131. package/src/claude/skills/ui-ux/references/components/inline-edit.md +128 -0
  132. package/src/claude/skills/ui-ux/references/components/input.md +161 -0
  133. package/src/claude/skills/ui-ux/references/components/list-row.md +56 -0
  134. package/src/claude/skills/ui-ux/references/components/loading.md +198 -0
  135. package/src/claude/skills/ui-ux/references/components/logo.md +147 -0
  136. package/src/claude/skills/ui-ux/references/components/otp-input.md +43 -0
  137. package/src/claude/skills/ui-ux/references/components/quantity-input.md +100 -0
  138. package/src/claude/skills/ui-ux/references/components/range-slider.md +16 -0
  139. package/src/claude/skills/ui-ux/references/components/small-controls.md +340 -0
  140. package/src/claude/skills/ui-ux/references/components/sortable-header.md +86 -0
  141. package/src/claude/skills/ui-ux/references/components/tag-input.md +14 -0
  142. package/src/claude/skills/ui-ux/references/components/timeline.md +38 -0
  143. package/src/claude/skills/ui-ux/references/components/tree.md +30 -0
  144. package/src/claude/skills/ui-ux/references/design-process.md +650 -0
  145. package/src/claude/skills/ui-ux/references/layouts/app-kanban.html +303 -0
  146. package/src/claude/skills/ui-ux/references/layouts/app.md +1369 -0
  147. package/src/claude/skills/ui-ux/references/layouts/form.md +430 -0
  148. package/src/claude/skills/ui-ux/references/layouts/overlay.md +552 -0
  149. package/src/claude/skills/ui-ux/references/layouts/pricing.md +204 -0
  150. package/src/claude/skills/ui-ux/references/locked-rules.md +35 -0
  151. package/src/claude/skills/ui-ux/references/principles.md +408 -0
  152. package/src/claude/skills/ui-ux/references/refactor.md +245 -0
  153. package/src/claude/skills/ui-ux/references/responsive.md +195 -0
  154. package/src/claude/skills/ui-ux/references/review.md +528 -0
  155. package/src/claude/skills/ui-ux/references/rules-color.md +623 -0
  156. package/src/claude/skills/ui-ux/references/rules-form.md +235 -0
  157. package/src/claude/skills/ui-ux/references/rules-state.md +680 -0
  158. package/src/claude/skills/ui-ux/references/rules-type.md +369 -0
  159. package/src/claude/skills/ui-ux/references/styles.md +311 -0
  160. package/src/claude/skills/ui-ux/references/system.md +219 -0
  161. package/src/claude/skills/ui-ux/references/tailwind-v4-traps.md +237 -0
  162. package/src/claude/skills/ui-ux/references/tokens.css +362 -0
  163. package/src/claude/skills/ui-ux/scripts/lint-skill.mjs +216 -0
  164. package/src/claude/skills/ui-ux/scripts/package.json +9 -0
  165. package/src/claude/skills/ui-ux/scripts/probe.mjs +4447 -0
  166. package/src/claude/skills/ui-ux-pro-max/SKILL.md +25 -80
  167. package/src/claude/skills/web-testing/SKILL.md +2 -20
  168. package/src/claude/status.cjs +149 -144
  169. package/src/codex/AGENTS.md +8 -13
  170. package/src/codex/hooks/agent.cjs +2 -1
  171. package/src/codex/hooks/lib/spec-receipt.cjs +4 -32
  172. package/src/codex/hooks/lib/spec-utils.cjs +6 -52
  173. package/src/codex/hooks/rules.cjs +2 -1
  174. package/src/codex/hooks/session.cjs +0 -2
  175. package/src/codex/hooks/spec-gate.cjs +13 -81
  176. package/src/codex/hooks/spec-state.cjs +37 -41
  177. package/src/codex/hooks.json +0 -20
  178. package/src/codex/rules/state-sync.md +3 -12
  179. package/src/common/AGENTS.md +6 -12
  180. package/src/omp/extensions/cafekit-bridge.mjs +6 -6
  181. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  182. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  183. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  184. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  185. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  186. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  187. package/src/claude/hooks/__tests__/completion-authority.test.js +0 -990
  188. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +0 -259
  189. package/src/claude/hooks/completion-authority-check.cjs +0 -56
  190. package/src/claude/hooks/completion-authority-state.cjs +0 -527
  191. package/src/claude/hooks/completion-authority.cjs +0 -163
  192. package/src/claude/hooks/lib/context.cjs +0 -616
  193. package/src/claude/hooks/lib/detect.cjs +0 -474
  194. package/src/claude/hooks/semantic-review-authority.cjs +0 -154
  195. package/src/claude/hooks/task-scaffold-guard.cjs +0 -114
  196. package/src/claude/hooks/usage.cjs +0 -197
  197. package/src/claude/scripts/change-firewall.cjs +0 -1748
  198. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  199. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  200. package/src/claude/scripts/spec-final-state.cjs +0 -290
  201. package/src/claude/scripts/spec-ground.cjs +0 -650
  202. package/src/claude/scripts/spec-readiness.cjs +0 -425
  203. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  204. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  205. package/src/claude/scripts/validate-spec-output.cjs +0 -3941
  206. package/src/claude/skills/code-review/references/verification-gate.md +0 -46
  207. package/src/claude/skills/delegate/SKILL.md +0 -124
  208. package/src/claude/skills/delegate/references/codex-delegation.md +0 -71
  209. package/src/claude/skills/delegate/references/grok-delegation.md +0 -84
  210. package/src/claude/skills/hotfix/SKILL.md +0 -289
  211. package/src/claude/skills/inspect/SKILL.md +0 -142
  212. package/src/claude/skills/loop/SKILL.md +0 -114
  213. package/src/claude/skills/loop/references/bounded-loop-protocol.md +0 -104
  214. package/src/claude/skills/loop/references/metric-and-guard-contract.md +0 -76
  215. package/src/claude/skills/route/SKILL.md +0 -94
  216. package/src/claude/skills/route/references/agent-timing.md +0 -46
  217. package/src/claude/skills/route/references/chaining-patterns.md +0 -53
  218. package/src/claude/skills/route/references/task-taxonomy.md +0 -48
  219. package/src/claude/skills/specs/templates/design.md +0 -82
  220. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  221. package/src/claude/skills/specs/templates/requirements.md +0 -42
  222. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  223. package/src/claude/skills/specs/templates/task.md +0 -47
  224. package/src/codex/hooks/completion-authority-check.cjs +0 -67
  225. package/src/codex/hooks/completion-authority-state.cjs +0 -527
  226. package/src/codex/hooks/completion-authority.cjs +0 -109
  227. package/src/codex/hooks/semantic-review-authority.cjs +0 -130
  228. package/src/codex/hooks/task-scaffold-guard.cjs +0 -63
  229. /package/src/claude/skills/{question → ask}/templates/question.md +0 -0
  230. /package/src/claude/skills/{hotfix → fix}/references/escalation-tactics.md +0 -0
  231. /package/src/claude/skills/{hotfix → fix}/references/workflow-specialized.md +0 -0
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+
3
+ // spec-state injects the active packet's tollgate on every prompt. With several sessions in
4
+ // one checkout, a Claude session kept seeing a packet another session was working on. On
5
+ // Claude the hook now speaks only about packets this session touched; omp, grok and
6
+ // payloads without a Claude session id keep the old behaviour. Each case names the failure
7
+ // it prevents.
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert');
11
+ const { spawnSync } = require('node:child_process');
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+
16
+ const CLAUDE_ROOT = path.join(__dirname, '..', '..');
17
+
18
+ function taskContent(status) {
19
+ return ['# Task 01 — work', '', `Status: ${status}`, '', '## Dependencies', '', '- none', '',
20
+ '## Verification Plan', '', '- Command: node --test', ''].join('\n');
21
+ }
22
+
23
+ function writePacket(root, name) {
24
+ const dir = path.join(root, 'specs', name);
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ fs.writeFileSync(path.join(dir, 'plan.md'), `# ${name}\nSpecs-Contract: process-first-ready-v1\n`);
27
+ fs.writeFileSync(path.join(dir, 'task-01-work.md'), taskContent('pending'));
28
+ return dir;
29
+ }
30
+
31
+ /** A project with the installed hook tree under `<root>/<runtimeDir>/hooks` and the given packets. */
32
+ function fixture(packets, runtimeDir = '.claude') {
33
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-touched-'));
34
+ const hooks = path.join(root, runtimeDir, 'hooks');
35
+ const scripts = path.join(root, runtimeDir, 'scripts');
36
+ fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
37
+ fs.mkdirSync(scripts, { recursive: true });
38
+ fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'spec-state.cjs'), path.join(hooks, 'spec-state.cjs'));
39
+ for (const lib of ['runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs']) {
40
+ fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'lib', lib), path.join(hooks, 'lib', lib));
41
+ }
42
+ for (const file of ['provenance.cjs', 'spec-receipt.cjs', 'spec-resolver.cjs', 'workflow-policy.cjs']) {
43
+ fs.copyFileSync(path.join(CLAUDE_ROOT, 'scripts', file), path.join(scripts, file));
44
+ }
45
+ for (const name of packets) writePacket(root, name);
46
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'],
47
+ ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
48
+ const git = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
49
+ assert.strictEqual(git.status, 0, git.stderr);
50
+ }
51
+ const run = (payload) => {
52
+ const result = spawnSync(process.execPath, [path.join(hooks, 'spec-state.cjs')], {
53
+ cwd: root, env: { ...process.env, PROJECT_ROOT: root },
54
+ input: JSON.stringify({ cwd: root, ...payload }), encoding: 'utf8',
55
+ });
56
+ assert.strictEqual(result.status, 0, result.stderr);
57
+ return result.stdout.trim();
58
+ };
59
+ const prompt = (session, text) => run({ hook_event_name: 'UserPromptSubmit', session_id: session, prompt: text });
60
+ const cacheFile = path.join(hooks, '.logs', 'tollgate-last.txt');
61
+ return { root, hooks, run, prompt, cacheFile, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) };
62
+ }
63
+
64
+ function within(fx, fn) {
65
+ try { return fn(fx); } finally { fx.cleanup(); }
66
+ }
67
+
68
+ test('an untouched packet prints nothing on Claude', () => {
69
+ within(fixture(['auth-flow']), (fx) => {
70
+ assert.strictEqual(fx.prompt('s1', 'Continue'), '');
71
+ assert.strictEqual(fs.existsSync(fx.cacheFile), false, 'a filtered session must not claim the cache slot');
72
+ });
73
+ });
74
+
75
+ test('naming the packet in the prompt touches it and keeps the full block', () => {
76
+ within(fixture(['auth-flow']), (fx) => {
77
+ assert.strictEqual(fx.prompt('s1', 'Continue'), '');
78
+ assert.match(fx.prompt('s1', 'continue auth-flow'), /Spec state changed/);
79
+ assert.match(fx.prompt('s1', 'Continue'), /Tollgate active/);
80
+ });
81
+ });
82
+
83
+ test('editing a file inside the packet touches it silently', () => {
84
+ within(fixture(['auth-flow']), (fx) => {
85
+ // macOS tmp dirs live under /private/var behind the /var symlink; send the other spelling.
86
+ const real = fs.realpathSync(fx.root);
87
+ const alias = real.startsWith('/private/var/') ? real.slice('/private'.length) : (() => {
88
+ const link = path.join(os.tmpdir(), `ck-alias-${process.pid}-${Date.now()}`);
89
+ fs.symlinkSync(real, link);
90
+ return link;
91
+ })();
92
+ const edited = path.join(alias, 'specs', 'auth-flow', 'plan.md');
93
+ assert.notStrictEqual(edited, fs.realpathSync(edited), 'the path sent must not be canonical');
94
+ const out = fx.run({ hook_event_name: 'PostToolUse', session_id: 's2', tool_name: 'Edit', tool_input: { file_path: edited } });
95
+ assert.strictEqual(out, '');
96
+ assert.strictEqual(fs.existsSync(fx.cacheFile), false, 'a touch record must not run the tollgate');
97
+ assert.match(fx.prompt('s2', 'Continue'), /Spec state changed/);
98
+ if (!real.startsWith('/private/var/')) fs.rmSync(alias, { force: true });
99
+ });
100
+ });
101
+
102
+ test('a plain one-word slug needs specs/ prefix', () => {
103
+ within(fixture(['docs']), (fx) => {
104
+ assert.strictEqual(fx.prompt('s1', 'fix the docs'), '');
105
+ assert.match(fx.prompt('s1', 'look at specs/docs'), /Spec state changed/);
106
+ });
107
+ });
108
+
109
+ test("another session's touch does not leak", () => {
110
+ within(fixture(['auth-flow']), (fx) => {
111
+ assert.match(fx.prompt('s3', 'specs/auth-flow'), /Spec state changed/);
112
+ assert.strictEqual(fx.prompt('s4', 'Continue'), '');
113
+ });
114
+ });
115
+
116
+ test('omp, grok and session-less payloads keep the old behaviour', () => {
117
+ within(fixture(['auth-flow'], '.omp'), (fx) => {
118
+ assert.match(fx.prompt('s5', 'Continue'), /Spec state changed/, 'omp is unchanged');
119
+ });
120
+ within(fixture(['auth-flow']), (fx) => {
121
+ assert.match(fx.run({ hook_event_name: 'UserPromptSubmit', sessionId: 's6', prompt: 'Continue' }), /Spec/, 'grok sends sessionId');
122
+ });
123
+ within(fixture(['auth-flow']), (fx) => {
124
+ assert.match(fx.run({ hook_event_name: 'UserPromptSubmit', prompt: 'Continue' }), /Spec/, 'no session id');
125
+ });
126
+ });
127
+
128
+ test('a prompt touch is remembered even when two packets are active', () => {
129
+ within(fixture(['auth-flow', 'billing-2']), (fx) => {
130
+ assert.match(fx.prompt('s7', 'work on specs/auth-flow'), /Multiple active specs/);
131
+ fs.rmSync(path.join(fx.root, 'specs', 'billing-2'), { recursive: true, force: true });
132
+ assert.match(fx.prompt('s7', 'Continue'), /auth-flow/);
133
+ });
134
+ });
135
+
136
+ function writeLegacyPacket(root, name) {
137
+ const dir = path.join(root, 'specs', name);
138
+ fs.mkdirSync(path.join(dir, 'tasks'), { recursive: true });
139
+ fs.writeFileSync(path.join(dir, 'spec.json'), JSON.stringify({ feature_name: name, status: 'in_progress' }));
140
+ }
141
+
142
+ const LEGACY_NOTICE = /`specs\/old` is a legacy Specs packet without plan\.md; CafeKit no longer reads it/;
143
+
144
+ test('a leftover legacy packet is noticed once per session, before the touch filter', () => {
145
+ within(fixture(['auth-flow']), (fx) => {
146
+ writeLegacyPacket(fx.root, 'old');
147
+ const first = fx.prompt('s1', 'Continue');
148
+ assert.match(first, LEGACY_NOTICE);
149
+ assert.strictEqual(first.match(new RegExp(LEGACY_NOTICE.source, 'g')).length, 1);
150
+ assert.strictEqual(fx.prompt('s1', 'Continue'), '', 'the same session is not told twice');
151
+ assert.match(fx.prompt('s2', 'Continue'), LEGACY_NOTICE, 'a new session is told once');
152
+ });
153
+ });
154
+
155
+ test('a legacy packet is noticed even when it is the only packet', () => {
156
+ within(fixture([]), (fx) => {
157
+ writeLegacyPacket(fx.root, 'old');
158
+ assert.match(fx.prompt('s1', 'Continue'), LEGACY_NOTICE);
159
+ });
160
+ });
@@ -316,3 +316,71 @@ test('Stop snapshot includes untracked files before the first commit', () => {
316
316
  fs.rmSync(dir, { recursive: true, force: true });
317
317
  }
318
318
  });
319
+
320
+ // The SessionStart block used to print even when it held only headings, placeholders and
321
+ // timestamp-only agent results, which told a resumed Claude session nothing.
322
+ const PLACEHOLDER_STATE = [
323
+ '# Session State',
324
+ '<!-- Generated: GENERATED -->',
325
+ '<!-- Branch: unknown -->',
326
+ '',
327
+ '## What Worked (Verified)',
328
+ '- (No completed tasks recorded)',
329
+ '',
330
+ "## What's Left",
331
+ '- (All tasks completed)',
332
+ '',
333
+ '## Key Files Modified',
334
+ '- (No file changes detected)',
335
+ '',
336
+ '## Agent Result: code-auditor (01:44:00)',
337
+ '- Completed at 01:44:00',
338
+ '',
339
+ '## Agent Result: code-auditor (01:44:21)',
340
+ '- Completed at 01:44:21',
341
+ '',
342
+ '## Agent Result: code-auditor (01:44:50)',
343
+ '- Completed at 01:44:50',
344
+ '',
345
+ ].join('\n');
346
+
347
+ function sessionStartWith(content, runtimeFolder = '.claude') {
348
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-state-start-'));
349
+ try {
350
+ let hook = STATE_HOOK;
351
+ if (runtimeFolder !== '.claude') {
352
+ const hooks = path.join(dir, runtimeFolder, 'hooks');
353
+ fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
354
+ fs.copyFileSync(STATE_HOOK, path.join(hooks, 'state.cjs'));
355
+ for (const lib of ['parser.cjs', 'runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs']) {
356
+ fs.copyFileSync(path.join(path.dirname(STATE_HOOK), 'lib', lib), path.join(hooks, 'lib', lib));
357
+ }
358
+ hook = path.join(hooks, 'state.cjs');
359
+ }
360
+ const stateFolder = path.join(dir, runtimeFolder, 'session-state');
361
+ fs.mkdirSync(stateFolder, { recursive: true });
362
+ fs.writeFileSync(path.join(stateFolder, 'latest.md'), content.replace('GENERATED', new Date().toISOString()));
363
+ const result = spawnSync(process.execPath, [hook], {
364
+ cwd: dir,
365
+ input: JSON.stringify({ hook_event_name: 'SessionStart', session_id: 'start-1', cwd: dir }),
366
+ encoding: 'utf8',
367
+ });
368
+ assert.strictEqual(result.status, 0, result.stderr);
369
+ return result.stdout.trim();
370
+ } finally {
371
+ fs.rmSync(dir, { recursive: true, force: true });
372
+ }
373
+ }
374
+
375
+ test('SessionStart prints nothing when prior context is only placeholders', () => {
376
+ assert.strictEqual(sessionStartWith(PLACEHOLDER_STATE), '');
377
+ });
378
+
379
+ test('SessionStart prints prior context that has a completed todo', () => {
380
+ const withTodo = PLACEHOLDER_STATE.replace('- (No completed tasks recorded)', '- Ship the statusline');
381
+ assert.match(sessionStartWith(withTodo), /Prior Execution Context[\s\S]*Ship the statusline/);
382
+ });
383
+
384
+ test('SessionStart under .omp keeps printing placeholder-only context', () => {
385
+ assert.match(sessionStartWith(PLACEHOLDER_STATE, '.omp'), /Prior Execution Context/);
386
+ });
@@ -10,6 +10,9 @@ const path = require('node:path');
10
10
  const STATUS_PATH = path.resolve(__dirname, '..', '..', 'status.cjs');
11
11
  const GOLDEN_PATH = path.join(__dirname, 'fixtures', 'statusline-default.golden');
12
12
  const NBSP = / /g;
13
+ const ANSI = /\x1b\[[0-9;]*m/g;
14
+ /** Visible text: colour codes removed, non-breaking spaces read as spaces, reset countdown masked. */
15
+ const visible = (text) => text.replace(ANSI, '').replace(NBSP, ' ').replace(/\d+h\d+m/g, '<reset>');
13
16
 
14
17
  const cleanupDirs = [];
15
18
  process.on('exit', () => {
@@ -79,6 +82,16 @@ function runStatus({ runtime = { statusline: 'full', statuslineColors: true }, p
79
82
  }
80
83
  }
81
84
 
85
+ /** Claude Code's own quota payload: used percentages and epoch-second resets 2h29m and 2d5h ahead. */
86
+ function rateLimits({ fiveHour = 20, weekly = 45 } = {}) {
87
+ const nowSec = Math.floor(Date.now() / 1000);
88
+ return {
89
+ five_hour: { used_percentage: fiveHour, resets_at: nowSec + 2 * 3600 + 29 * 60 + 30 },
90
+ seven_day: { used_percentage: weekly, resets_at: nowSec + 2 * 86400 + 5 * 3600 },
91
+ };
92
+ }
93
+
94
+ /** The file the retired usage hook used to write; the statusline must ignore it. */
82
95
  function freshUsageCache(now = Date.now()) {
83
96
  return {
84
97
  status: 'available',
@@ -138,21 +151,19 @@ test('statusline empty or invalid layout falls back to the default renderers', (
138
151
  }
139
152
  });
140
153
 
141
- test('statusline shows five-hour and weekly windows with countdowns when the cache is fresh', () => {
142
- const run = runStatus({ tmpFiles: { 'ck-usage-limits-cache.json': freshUsageCache() } });
154
+ test('statusline shows five-hour and weekly windows with countdowns from the payload', () => {
155
+ const run = runStatus({ payload: basePayload({ rate_limits: rateLimits() }) });
143
156
  assert.equal(run.status, 0, run.stderr);
144
- const plain = run.stdout.replace(NBSP, ' ');
145
- assert.match(plain, /⌛ \d+h \d+m left \(20% used\)/, plain);
146
- assert.match(plain, /wk 45% \(\d+d \d+h\)/, plain);
157
+ const plain = visible(run.stdout);
158
+ assert.match(plain, /⧗ 20% <reset>/, plain);
159
+ assert.match(plain, /◷ 45%/, plain);
147
160
  });
148
161
 
149
- test('statusline hides the quota area when the cache is stale', () => {
150
- const stale = freshUsageCache(Date.now() - 10 * 60 * 1000);
151
- stale.timestamp = Date.now() - 10 * 60 * 1000;
152
- const run = runStatus({ tmpFiles: { 'ck-usage-limits-cache.json': stale } });
162
+ test('statusline shows no quota when the payload has no rate_limits, even with a fresh usage cache', () => {
163
+ const run = runStatus({ tmpFiles: { 'ck-usage-limits-cache.json': freshUsageCache() } });
153
164
  assert.equal(run.status, 0, run.stderr);
154
- assert.doesNotMatch(run.stdout, /⌛/);
155
- assert.doesNotMatch(run.stdout, /wk /);
165
+ assert.doesNotMatch(run.stdout, /⧗/);
166
+ assert.doesNotMatch(run.stdout, /◷/);
156
167
  });
157
168
 
158
169
  test('statusline cost renders only when the layout enables it, billing is api, and data exists', () => {
@@ -194,3 +205,121 @@ test('statusline honors NO_COLOR with no ANSI escapes in output', () => {
194
205
  assert.equal(run.status, 0, run.stderr);
195
206
  assert.doesNotMatch(run.stdout, /\[/);
196
207
  });
208
+
209
+ test('statusline default is one labelled line and narrow terminals drop the least useful parts first', () => {
210
+ const payload = basePayload({ rate_limits: rateLimits(), cost: { total_lines_added: 120, total_lines_removed: 30 } });
211
+ const plainLines = (run) => visible(run.stdout).trimEnd().split('\n');
212
+ const runtime = { statusline: 'full', statuslineColors: false };
213
+
214
+ const wide = runStatus({ runtime, payload });
215
+ assert.equal(wide.status, 0, wide.stderr);
216
+ assert.deepEqual(plainLines(wide), ['◆ TestModel ◑ 53% ⧗ 20% <reset> ◷ 45% ± +120/-30']);
217
+
218
+ const narrow = runStatus({ runtime, payload, envExtra: { COLUMNS: '34' } });
219
+ assert.deepEqual(plainLines(narrow), ['◆ TestModel ◑ 53% ⧗ 20% <reset>']);
220
+ const tiny = runStatus({ runtime, payload, envExtra: { COLUMNS: '10' } });
221
+ assert.deepEqual(plainLines(tiny), ['◆ TestModel ◑ 53%']);
222
+ });
223
+
224
+ test('statusline compact is the default line alone and minimal keeps model and context', () => {
225
+ const payload = basePayload({ rate_limits: rateLimits() });
226
+ const plain = (run) => visible(run.stdout).trimEnd();
227
+ const compact = runStatus({ runtime: { statusline: 'compact', statuslineColors: false }, payload });
228
+ assert.equal(plain(compact), '◆ TestModel ◑ 53% ⧗ 20% <reset> ◷ 45%');
229
+ const minimal = runStatus({ runtime: { statusline: 'minimal', statuslineColors: false }, payload });
230
+ assert.equal(plain(minimal), '◆ TestModel ◑ 53%');
231
+ });
232
+
233
+ test('statusline colours percentages by threshold and prints no escape when statuslineColors is false', () => {
234
+ const hot = basePayload({ rate_limits: rateLimits({ fiveHour: 90 }) });
235
+ const coloured = runStatus({ payload: hot });
236
+ assert.match(coloured.stdout, /\x1b\[31m90%/, 'a five-hour quota at 90% is red');
237
+ assert.match(coloured.stdout, /\x1b\[32m◑[ \u00A0]53%/, 'context at 53% is green');
238
+ const plain = runStatus({ runtime: { statusline: 'full', statuslineColors: false }, payload: hot });
239
+ assert.doesNotMatch(plain.stdout, /\x1b\[/);
240
+ });
241
+
242
+ test('statusline shows the live effort level next to the model, and nothing when the payload has none', () => {
243
+ const runtime = { statusline: 'minimal', statuslineColors: false };
244
+ const withEffort = runStatus({ runtime, payload: basePayload({ effort: { level: 'xhigh' } }) });
245
+ assert.equal(visible(withEffort.stdout).trimEnd(), '◆ TestModel (xhigh) ◑ 53%');
246
+ const narrow = runStatus({ runtime: { statusline: 'full', statuslineColors: false }, payload: basePayload({ effort: { level: 'high' } }), envExtra: { COLUMNS: '10' } });
247
+ assert.equal(visible(narrow.stdout).trimEnd(), '◆ TestModel (high) ◑ 53%', 'effort travels with the model and never drops');
248
+ const without = runStatus({ runtime });
249
+ assert.equal(visible(without.stdout).trimEnd(), '◆ TestModel ◑ 53%');
250
+ });
251
+
252
+ test('statusline reads quota from the payload rate_limits', () => {
253
+ const nowSec = Math.floor(Date.now() / 1000);
254
+ const payload = basePayload({ rate_limits: {
255
+ five_hour: { used_percentage: 36, resets_at: nowSec + 2 * 3600 + 12 * 60 + 30 },
256
+ seven_day: { used_percentage: 43, resets_at: nowSec + 2 * 86400 },
257
+ } });
258
+ const run = runStatus({ runtime: { statusline: 'compact', statuslineColors: false }, payload,
259
+ tmpFiles: { 'ck-usage-limits-cache.json': freshUsageCache() } });
260
+ assert.equal(visible(run.stdout).trimEnd(), '◆ TestModel ◑ 53% ⧗ 36% <reset> ◷ 43%', 'the payload is the only source (the cache says 20%/45%)');
261
+ });
262
+
263
+ test('statusline layout quota section renders from the payload', () => {
264
+ const run = runStatus({
265
+ runtime: { statusline: 'full', statuslineColors: false, statuslineLayout: { lines: [['quota']] } },
266
+ payload: basePayload({ rate_limits: rateLimits({ fiveHour: 36, weekly: 43 }) }),
267
+ });
268
+ assert.equal(run.status, 0, run.stderr);
269
+ // Not visible(): its mask matches only \d+h\d+m, and the layout prints "2h 29m" plus a
270
+ // weekly countdown that can roll over between seconds.
271
+ const line = run.stdout.replace(ANSI, '').replace(NBSP, ' ').trimEnd();
272
+ assert.match(line, /⌛ \d+h \d+m left \(36% used\) wk 43% \(\d+d \d+h\)/, line);
273
+ });
274
+
275
+ /** A throwaway git repo on branch `feat`, so git, PR and worktree marks have a branch to ride on. */
276
+ function gitRepo() {
277
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sl-git-'));
278
+ cleanupDirs.push(dir);
279
+ for (const args of [['init', '-q', '-b', 'feat'], ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']]) {
280
+ const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
281
+ assert.equal(r.status, 0, r.stderr);
282
+ }
283
+ return dir;
284
+ }
285
+
286
+ const stripAnsi = (text) => text.replace(ANSI, '').replace(NBSP, ' ').trimEnd();
287
+
288
+ test('statusline shows fast mode, thinking, PR state, worktree, session time and cache hit only when present', () => {
289
+ const runtime = { statusline: 'compact', statuslineColors: false };
290
+ const repo = gitRepo();
291
+ const rich = basePayload({
292
+ fast_mode: true,
293
+ thinking: { enabled: true },
294
+ pr: { number: 80, review_state: 'approved' },
295
+ workspace: { current_dir: repo, git_worktree: 'feat-x' },
296
+ cost: { total_duration_ms: 28147114 },
297
+ prompt_cache: { requests: 77, hit_ratio: 0.986 },
298
+ });
299
+ assert.equal(stripAnsi(runStatus({ runtime, payload: rich }).stdout), '◆ TestModel ϟ✱ ◑ 53% ⎇ feat #80✓ ⊟ ⏱ 7h49m ↻ 99%');
300
+
301
+ const off = basePayload({
302
+ fast_mode: false, thinking: { enabled: false }, workspace: { current_dir: repo },
303
+ cost: { total_duration_ms: 30000 }, prompt_cache: { requests: 0, hit_ratio: 0 },
304
+ });
305
+ assert.equal(stripAnsi(runStatus({ runtime, payload: off }).stdout), '◆ TestModel ◑ 53% ⎇ feat');
306
+ });
307
+
308
+ test('statusline marks each PR review state and a GitLab merge request', () => {
309
+ const runtime = { statusline: 'minimal', statuslineColors: true };
310
+ const repo = gitRepo();
311
+ const line = (pr) => runStatus({ runtime: { statusline: 'compact', statuslineColors: true }, payload: basePayload({ pr, workspace: { current_dir: repo } }) }).stdout;
312
+ assert.match(line({ number: 7, review_state: 'approved' }), /#7\x1b\[32m✓/);
313
+ assert.match(line({ number: 7, review_state: 'changes_requested' }), /#7\x1b\[31m✗/);
314
+ assert.match(line({ number: 7, review_state: 'draft' }), /\x1b\[2m#7\x1b\[0m/);
315
+ assert.match(stripAnsi(line({ number: 7, review_state: 'pending' })), /⎇ feat #7$/);
316
+ assert.match(stripAnsi(line({ number: 12, kind: 'mr' })), /⎇ feat !12$/);
317
+ assert.doesNotMatch(runStatus({ runtime, payload: basePayload({ pr: { number: 7 }, workspace: { current_dir: repo } }) }).stdout, /#7/, 'minimal keeps only the branch name');
318
+ });
319
+
320
+ test('statusline drops session time and cache before changes on a narrow terminal', () => {
321
+ const payload = basePayload({ cost: { total_duration_ms: 28147114, total_lines_added: 5, total_lines_removed: 1 }, prompt_cache: { requests: 3, hit_ratio: 0.4 } });
322
+ const runtime = { statusline: 'full', statuslineColors: false };
323
+ assert.equal(stripAnsi(runStatus({ runtime, payload, envExtra: { COLUMNS: '34' } }).stdout), '◆ TestModel ◑ 53% ± +5/-1');
324
+ assert.equal(stripAnsi(runStatus({ runtime, payload, envExtra: { COLUMNS: '44' } }).stdout), '◆ TestModel ◑ 53% ± +5/-1 ⏱ 7h49m');
325
+ });
@@ -59,6 +59,7 @@ try {
59
59
  const baseDir = agentCwd;
60
60
  const plansPath = path.join(baseDir, runtime.paths?.plans || 'plans');
61
61
  const docsPath = path.join(baseDir, runtime.paths?.docs || 'docs');
62
+ const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
62
63
 
63
64
  // Build context block
64
65
  const lines = [];
@@ -76,7 +77,7 @@ try {
76
77
  const venv = resolveVenv(agentCwd);
77
78
 
78
79
  lines.push('## Rules');
79
- lines.push(`- Plans → ${plansPath}/ | Docs → ${docsPath}/`);
80
+ lines.push(`- Specs → ${specsPath}/ | Plans → ${plansPath}/ | Docs → ${docsPath}/`);
80
81
  if (venv) {
81
82
  lines.push(`- Python in ${skillsDir()}/: use \`${venv}\``);
82
83
  lines.push('- Never use global pip install');
@@ -54,8 +54,8 @@ const EVENT_NAMES = Object.freeze({
54
54
  * Grok's registry and matcher aliases come first, then omp's lowercase registry. Where
55
55
  * both hosts use a spelling (`write`, `grep`) they agree on the destination. `write` is
56
56
  * grok's file-creating tool and maps to `Write`, while `search_replace` edits an existing
57
- * file and maps to `Edit`; conflating them would make the scaffold guard reject ordinary
58
- * edits. `delete_file` maps to `Write` so a delete is gated like any other file write.
57
+ * file and maps to `Edit`, which keeps grok's file creation distinct from an edit.
58
+ * `delete_file` maps to `Write` so a delete is gated like any other file write.
59
59
  */
60
60
  const TOOL_ALIASES = Object.freeze({
61
61
  // grok — every spelling that reaches a Bash matcher
@@ -70,6 +70,7 @@ try {
70
70
  const baseDir = cwd;
71
71
  const plansPath = path.join(baseDir, runtime.paths?.plans || 'plans');
72
72
  const docsPath = path.join(baseDir, runtime.paths?.docs || 'docs');
73
+ const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
73
74
  const maxLoc = runtime.docs?.maxLoc || 800;
74
75
 
75
76
  const lines = [];
@@ -84,7 +85,7 @@ try {
84
85
 
85
86
  lines.push(
86
87
  '## Rules',
87
- `- Markdown files: Plans → "${plansPath}/" | Docs → "${docsPath}/"`,
88
+ `- Markdown files: Specs → "${specsPath}/" | Plans → "${plansPath}/" | Docs → "${docsPath}/"`,
88
89
  '- **DO NOT** create markdown files outside of those directories unless explicitly asked.',
89
90
  `- docs.maxLoc: ${maxLoc} lines max per doc file`
90
91
  );
@@ -202,7 +202,6 @@ try {
202
202
  const envFile = process.env.CLAUDE_ENV_FILE;
203
203
  const cwd = projectRoot();
204
204
  const runtime = readRuntime(cwd);
205
- try { require('./completion-authority-state.cjs').clearState(cwd, payload.session_id); } catch { /* old installs remain fail-open */ }
206
205
 
207
206
  // Check CafeKit update in parallel with project detection (async, fail-open).
208
207
  const updateCheckPromise = checkCafeKitUpdate(cwd);
@@ -90,17 +90,16 @@ try {
90
90
  let POLICY;
91
91
  let RESOLVER;
92
92
  let RECEIPT;
93
- let FINAL_STATE;
94
93
  try {
95
94
  POLICY = require(policyPath);
95
+ // One gate run checks every done receipt against the same checkout; capture it once.
96
+ require(path.join(__dirname, '..', 'scripts', 'provenance.cjs')).enableSnapshotMemo();
96
97
  RESOLVER = require(path.join(__dirname, '..', 'scripts', 'spec-resolver.cjs'));
97
98
  RECEIPT = require(path.join(__dirname, '..', 'scripts', 'spec-receipt.cjs'));
98
- FINAL_STATE = require('./completion-authority-check.cjs');
99
99
  if (typeof POLICY.validateCanonicalReceipt !== 'function'
100
- || typeof POLICY.completionDecisionForSpec !== 'function'
101
- || typeof RECEIPT.checkTaskReceipt !== 'function'
100
+ || typeof RECEIPT.checkWorkflowTaskReceipt !== 'function'
102
101
  || typeof RECEIPT.checkWorkflowReceiptSet !== 'function'
103
- || typeof FINAL_STATE.evaluateCloseout !== 'function') {
102
+ || typeof RESOLVER.resolveWorkflowCandidate !== 'function') {
104
103
  throw new Error('shared workflow policy lacks completion authority functions');
105
104
  }
106
105
  } catch (error) {
@@ -127,9 +126,7 @@ try {
127
126
  || (typeof RESOLVER.readActiveFeatureTarget === 'function'
128
127
  ? RESOLVER.readActiveFeatureTarget({ projectRoot: baseDir, runtime })
129
128
  : null);
130
- let resolved = typeof RESOLVER.resolveWorkflowCandidate === 'function'
131
- ? RESOLVER.resolveWorkflowCandidate({ projectRoot: baseDir, runtime, target, includeCompleted: true })
132
- : FINAL_STATE.resolveCandidate({ resolver: RESOLVER, projectRoot: baseDir, runtime, payload });
129
+ let resolved = RESOLVER.resolveWorkflowCandidate({ projectRoot: baseDir, runtime, target, includeCompleted: true });
133
130
  if (typeof RESOLVER.refineWorkflowGateResolution === 'function') {
134
131
  resolved = RESOLVER.refineWorkflowGateResolution(resolved);
135
132
  }
@@ -150,7 +147,7 @@ try {
150
147
  const lines = [`⚠️ Completion gate: ${checked.failures.length} done task(s) lack a verification receipt.`];
151
148
  for (const failure of checked.failures) {
152
149
  lines.push(`- \`${failure.featureName}/${failure.taskPath}\`: failed check(s) ${failure.failures.join(', ')}`);
153
- const hint = typeof RECEIPT.receiptFixHint === 'function' ? RECEIPT.receiptFixHint(failure.failures) : null;
150
+ const hint = typeof RECEIPT.receiptFixHint === 'function' ? RECEIPT.receiptFixHint(failure.failures, failure.body) : null;
154
151
  lines.push(` Fix in \`specs/${failure.featureName}/${failure.taskPath}\`: ${hint || 'write a runtime-bound `## Receipt` with the planned command'}.`);
155
152
  }
156
153
  emitBlock(lines.slice(0, 8).join('\n'));
@@ -166,7 +163,7 @@ try {
166
163
  if (resolved.error === 'invalid_specs') {
167
164
  process.stdout.write(JSON.stringify({
168
165
  decision: 'block',
169
- reason: `Completion gate: invalid spec JSON detected (${resolved.candidates.join(', ')}): ${resolved.reason}. Fix or remove malformed spec.json before completing tasks.`
166
+ reason: `Completion gate: invalid workflow packet detected (${resolved.candidates.join(', ')}): ${resolved.reason}. Fix the packet's plan.md or task files before completing tasks.`,
170
167
  }) + '\n');
171
168
  process.exit(0);
172
169
  }
@@ -179,31 +176,12 @@ try {
179
176
  }
180
177
  if (resolved.error) process.exit(0);
181
178
 
182
- const activeSpec = resolved.spec || {};
183
- const processWorkflow = resolved.layoutKind === 'process-v3';
184
179
  const featureName = resolved.featureName;
185
180
  const specsPath = resolved.specsDir;
186
- const lifecyclePhase = activeSpec.current_phase || activeSpec.phase;
187
- const explicitCloseout = ['done', 'completed', 'complete'].includes(activeSpec.status)
188
- || ['closeout', 'completion', 'completed', 'complete'].includes(lifecyclePhase);
189
- if (!processWorkflow && activeSpec.schema_version === '2.1') {
190
- const finalState = FINAL_STATE.evaluateCloseout({
191
- resolver: RESOLVER,
192
- policy: POLICY,
193
- projectRoot: baseDir,
194
- runtime,
195
- payload: { ...payload, session_id: sessionIdentity(payload) },
196
- });
197
- if (!finalState.ok) {
198
- emitBlock(`Completion gate: ${finalState.reason}`);
199
- process.exit(0);
200
- }
201
- if (finalState.active) process.exit(0);
202
- }
203
181
  const runtimeContext = POLICY.deriveRuntimeContext({
204
182
  projectRoot: baseDir,
205
183
  specsRoot: specsPath,
206
- specFile: resolved.stateFile || resolved.specFile || path.join(specsPath, featureName, 'spec.json'),
184
+ specFile: resolved.stateFile,
207
185
  featureName,
208
186
  runtimeSession: sessionIdentity(payload),
209
187
  });
@@ -213,7 +191,7 @@ try {
213
191
  emitBlock('Completion gate: runtime.spec.completion_gate is a worker-writable flag, not an authorization; no completion-gate bypass is supported. Remove the flag and satisfy the gate.');
214
192
  process.exit(0);
215
193
  }
216
- const taskRegistry = resolved.taskRegistry || activeSpec.task_registry || {};
194
+ const taskRegistry = resolved.taskRegistry || {};
217
195
  const cacheFile = path.join(require('./lib/hook-state-dir.cjs').hookStateDir(), 'spec-gate-last.json');
218
196
  const cacheExists = fs.existsSync(cacheFile);
219
197
  let cache = {};
@@ -226,17 +204,6 @@ try {
226
204
  currentStatuses[tp] = task?.status || 'pending';
227
205
  }
228
206
 
229
- const staleFlashTasks = processWorkflow ? [] : Object.entries(taskRegistry)
230
- .filter(([, task]) => POLICY.isStaleFlashDone(task))
231
- .map(([taskPath]) => taskPath);
232
- if (staleFlashTasks.length > 0) {
233
- process.stdout.write(JSON.stringify({
234
- decision: 'block',
235
- reason: `Completion gate: ${staleFlashTasks.length} task(s) marked done with FLASH_UNVERIFIED (${staleFlashTasks.join(', ')}). Run /cf:test for exact proof, then use explicit sync-finalize.`
236
- }) + '\n');
237
- process.exit(0);
238
- }
239
-
240
207
  // Cache hardening: every Stop re-reads and re-checks the receipt of every
241
208
  // task currently marked done, so a cached PASS cannot hide later mutations
242
209
  // (deletion, placeholder, changed Verification/Command/Exit, missing
@@ -251,29 +218,11 @@ try {
251
218
  );
252
219
  const featureDir = path.join(specsPath, featureName);
253
220
  const failures = allDoneTasks
254
- .map((taskPath) => ({
255
- taskPath,
256
- fails: processWorkflow
257
- ? RECEIPT.checkWorkflowTaskReceipt(featureDir, taskPath, runtimeContext, POLICY).failures
258
- : RECEIPT.checkTaskReceipt(featureDir, taskPath, taskRegistry[taskPath], runtimeContext, POLICY).failures,
259
- }))
260
- .filter((f) => f.fails.length > 0);
261
-
262
- const featureCloseoutRequired = !processWorkflow && explicitCloseout && allDoneTasks.length > 0;
263
- const featureReceipt = featureCloseoutRequired
264
- ? RECEIPT.checkFeatureReceipt(featureDir, runtimeContext, POLICY)
265
- : null;
266
- if (featureReceipt && featureReceipt.failures.length) {
267
- failures.push({ taskPath: 'feature-receipt.md', fails: featureReceipt.failures });
268
- }
269
- const completion = featureCloseoutRequired && featureReceipt?.failures.length === 0
270
- && Object.prototype.hasOwnProperty.call(activeSpec, 'workflow_policy')
271
- ? POLICY.completionDecisionForSpec(activeSpec, {
272
- runtimeContext,
273
- executionReceipt: featureReceipt.body,
274
- taskContext: {},
221
+ .map((taskPath) => {
222
+ const proof = RECEIPT.checkWorkflowTaskReceipt(featureDir, taskPath, runtimeContext, POLICY);
223
+ return { taskPath, fails: proof.failures, body: proof.body };
275
224
  })
276
- : null;
225
+ .filter((f) => f.fails.length > 0);
277
226
 
278
227
  // Always persist status transitions, including done → pending. Keep
279
228
  // failing done tasks at their previous cached status so a stale valid
@@ -290,27 +239,12 @@ try {
290
239
  fs.writeFileSync(cacheFile, JSON.stringify(cache));
291
240
  } catch { /* fail-open */ }
292
241
 
293
- const completionBlocked = completion && completion.completion !== 'complete' && completion.completion !== 'not_applicable';
294
- if (failures.length === 0 && !completionBlocked) process.exit(0);
242
+ if (failures.length === 0) process.exit(0);
295
243
 
296
- const lines = [
297
- failures.length > 0
298
- ? `⚠️ Completion gate: ${failures.length} done task(s) lack a verification receipt.`
299
- : '⚠️ Completion gate: workflow completion proof is incomplete.',
300
- ];
301
- for (const { taskPath, fails } of failures) {
244
+ const lines = [`⚠️ Completion gate: ${failures.length} done task(s) lack a verification receipt.`];
245
+ for (const { taskPath, fails, body } of failures) {
302
246
  lines.push(`- \`${taskPath}\`: failed check(s) ${fails.join(', ')}`);
303
- lines.push(taskPath === 'feature-receipt.md'
304
- ? ` Fix: run final integration proof, then write \`specs/${featureName}/feature-receipt.md\`.`
305
- : processWorkflow
306
- ? ` Fix in \`specs/${featureName}/${path.posix.basename(taskPath)}\`: ${(typeof RECEIPT.receiptFixHint === 'function' && RECEIPT.receiptFixHint(fails)) || 'write a runtime-bound `## Receipt` with command output'}.`
307
- : ` Fix: write canonical proof to \`specs/${featureName}/receipts/${path.posix.basename(taskPath)}\`; legacy \`## Evidence\` remains read-compatible.`);
308
- }
309
- if (completionBlocked) {
310
- lines.push(`- Completion decision unfinished: ${completion.blocker || 'required workflow proof is missing.'}`);
311
- if (Array.isArray(completion.missingProof) && completion.missingProof.length > 0) {
312
- lines.push(` Missing proof: ${completion.missingProof.join(', ')}`);
313
- }
247
+ lines.push(` Fix in \`specs/${featureName}/${path.posix.basename(taskPath)}\`: ${(typeof RECEIPT.receiptFixHint === 'function' && RECEIPT.receiptFixHint(fails, body)) || 'write a runtime-bound `## Receipt` with command output'}.`);
314
248
  }
315
249
  process.stdout.write(JSON.stringify({ decision: 'block', reason: lines.slice(0, 8).join('\n') }) + '\n');
316
250
  process.exit(0);