@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
@@ -143,9 +143,6 @@ test('advice names the directory the hook lives in', () => {
143
143
  const rt = installTreeAs(project, folder);
144
144
  fs.mkdirSync(path.join(project, 'specs', 'demo'), { recursive: true });
145
145
  fs.writeFileSync(path.join(rt, 'runtime.json'), '{}');
146
- // task-scaffold-guard: a raw Write into a nested task path is refused with scaffold advice
147
- const guard = runHook(rt, 'task-scaffold-guard.cjs', { tool_name: 'Write', tool_input: { file_path: path.join(project, 'specs', 'demo', 'tasks', 'task-R0-01-x.md'), content: '# x' }, cwd: project });
148
- assert.match(guard.out, new RegExp(`node ${folder.replace('.', '\\.')}/scripts/spec-scaffold\\.cjs`), `scaffold advice must name ${folder}`);
149
146
  // inspect-block: header hint names the runtime file (static check on the installed copy)
150
147
  const src = fs.readFileSync(path.join(rt, 'hooks', 'inspect-block.cjs'), 'utf8');
151
148
  assert.match(src, /<runtime>\/runtime\.json/, 'the disable hint no longer hardcodes a platform');
@@ -86,8 +86,9 @@ test('every documented property carries a description a reader can act on', () =
86
86
  test('keys the runtime does not honour are marked deprecated, not documented as working', () => {
87
87
  // develop.parallel and the hooks toggle map are still shipped for compatibility, but
88
88
  // nothing reads them. The schema must say so rather than imply they work.
89
- for (const dead of ['hooks', 'develop']) {
90
- const node = SCHEMA.properties[dead];
89
+ for (const dead of ['hooks', 'develop', 'usage', 'spec.scaffold_guard']) {
90
+ // A dotted path walks nested `properties`: `spec.scaffold_guard` is spec.properties.scaffold_guard.
91
+ const node = dead.split('.').reduce((schema, key) => schema && schema.properties && schema.properties[key], SCHEMA);
91
92
  assert.ok(node, `${dead} is shipped in runtime.json and must appear in the schema`);
92
93
  assert.equal(node.deprecated, true, `${dead} is not honoured and must be marked deprecated`);
93
94
  assert.match(node.description, /[Nn]ot honoured/, `${dead} must say plainly that it does nothing`);
@@ -58,7 +58,7 @@ test('skill routing consumes live catalog without fixed optional commands', () =
58
58
  installScanner(SCANNER, claudeScript);
59
59
  installScanner(SCANNER, codexScript, (content) => normalizeCodexBody(content, SCANNER));
60
60
 
61
- writeSkill(path.join(root, '.claude/skills'), 'question', {
61
+ writeSkill(path.join(root, '.claude/skills'), 'ask', {
62
62
  name: 'cf:ask', description: 'Answer with evidence.', when: 'Use for factual questions.',
63
63
  category: 'utilities', keywords: ['answer', 'evidence'],
64
64
  });
@@ -66,7 +66,7 @@ test('skill routing consumes live catalog without fixed optional commands', () =
66
66
  name: 'cf:docs', description: 'Work with docs.', when: 'Use for documentation.',
67
67
  category: 'documents', keywords: ['docs'],
68
68
  });
69
- writeSkill(path.join(root, '.agents/skills'), 'question', {
69
+ writeSkill(path.join(root, '.agents/skills'), 'ask', {
70
70
  name: 'cf-ask', description: 'Answer with evidence.', when: 'Use for factual questions.',
71
71
  category: 'utilities', keywords: ['answer', 'evidence'],
72
72
  });
@@ -84,8 +84,8 @@ test('skill routing consumes live catalog without fixed optional commands', () =
84
84
  const domain = fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/rules/skill-domain-routing.md'), 'utf8');
85
85
  const codexEntry = fs.readFileSync(path.join(PACKAGE_ROOT, 'src/codex/AGENTS.md'), 'utf8');
86
86
  assert.match(workflow, /user names a valid installed skill[\s\S]*one obvious low-risk[\s\S]*direct factual conversation/);
87
- assert.match(workflow, /do not invoke Route or agents for ceremony/);
88
- assert.match(workflow.replace(/\s+/g, ' '), /numeric optimization capability remains explicit-only/);
87
+ assert.match(workflow, /do not spawn agents for ceremony/);
88
+ assert.doesNotMatch(`${workflow}\n${domain}`, /numeric optimization capability/);
89
89
  assert.match(domain, /examples below are intent hints, not a copied installed inventory/);
90
90
  assert.match(domain, /document\/artifact work \| use only a matching installed optional capability/);
91
91
  assert.doesNotMatch(domain, /\/cf:(?:docs|docx|pdf|pptx|xlsx|ai-multimodal)/);
@@ -96,17 +96,19 @@ test('skill routing consumes live catalog without fixed optional commands', () =
96
96
  test('skill catalog exposes discriminating routing metadata', () => {
97
97
  withTempRoot((root) => {
98
98
  const skills = path.join(root, 'skills');
99
- writeSkill(skills, 'route', {
100
- name: 'cf:route', description: 'Choose a bounded chain.',
99
+ writeSkill(skills, 'triage', {
100
+ name: 'cf:triage', description: 'Choose a bounded chain.',
101
101
  when: 'Use for ambiguous multi-step work.', category: 'utilities',
102
102
  keywords: ['routing', 'risk'],
103
103
  });
104
+ // An upgrade can leave a user-modified retired directory beside its replacement; both carry the
105
+ // same public name, and only the retired one may drop out.
104
106
  writeSkill(skills, 'question', {
105
- name: 'cf:ask', description: 'First duplicate.', when: 'Use for questions.',
107
+ name: 'cf:ask', description: 'User-modified retired copy.', when: 'Use for questions.',
106
108
  category: 'utilities', keywords: ['answer'],
107
109
  });
108
110
  writeSkill(skills, 'ask', {
109
- name: 'cf:ask', description: 'Second duplicate.', when: 'Use for questions.',
111
+ name: 'cf:ask', description: 'Answer with evidence.', when: 'Use for questions.',
110
112
  category: 'utilities', keywords: ['answer'],
111
113
  });
112
114
  writeSkill(skills, 'missing-description', { name: 'cf:missing-description' });
@@ -132,15 +134,16 @@ test('skill catalog exposes discriminating routing metadata', () => {
132
134
  });
133
135
 
134
136
  const catalog = runCatalog(SCANNER, root, ['--json', '--root', skills]);
135
- assert.deepEqual(catalog.skills.map((skill) => skill.public_id), ['cf:route']);
136
- assert.deepEqual(catalog.skills[0], {
137
- name: 'cf:route', public_id: 'cf:route', directory: 'route',
137
+ assert.deepEqual(catalog.skills.map((skill) => skill.public_id), ['cf:ask', 'cf:triage']);
138
+ assert.deepEqual(catalog.skills.find((skill) => skill.public_id === 'cf:triage'), {
139
+ name: 'cf:triage', public_id: 'cf:triage', directory: 'triage',
138
140
  description: 'Choose a bounded chain.', when_to_use: 'Use for ambiguous multi-step work.',
139
141
  category: 'utilities', keywords: ['routing', 'risk'], user_invocable: true,
140
142
  has_references: false, has_scripts: false,
141
143
  });
142
144
  const codes = catalog.diagnostics.map((item) => item.code);
143
- assert.equal(codes.filter((code) => code === 'duplicate_public_name').length, 2);
145
+ assert.equal(codes.filter((code) => code === 'duplicate_public_name').length, 0);
146
+ assert.ok(catalog.diagnostics.some((item) => item.directory === 'question' && item.code === 'retired_skill'));
144
147
  for (const required of [
145
148
  'folder_name_mismatch', 'malformed_frontmatter', 'missing_routing_metadata', 'retired_skill',
146
149
  ]) assert.ok(codes.includes(required), required);
@@ -157,10 +160,19 @@ test('skill catalog exposes discriminating routing metadata', () => {
157
160
  const sourceCatalog = runCatalog(SCANNER, PACKAGE_ROOT);
158
161
  const retiredDiagnostics = sourceCatalog.diagnostics
159
162
  .filter((item) => item.code === 'retired_skill').map((item) => item.directory).sort();
160
- assert.deepEqual(retiredDiagnostics, [...MANIFEST.obsolete.skills].sort());
163
+ // Retired skills kept in the source tree must be flagged; renamed ones were moved, so they are
164
+ // absent from the source and only matter to an upgrade over an older install.
165
+ const sourceSkills = path.join(PACKAGE_ROOT, 'src/claude/skills');
166
+ const keptRetired = MANIFEST.obsolete.skills.filter((name) => fs.existsSync(path.join(sourceSkills, name)));
167
+ assert.deepEqual(retiredDiagnostics, [...keptRetired].sort());
168
+ for (const renamed of ['inspect', 'hotfix', 'question']) {
169
+ assert.ok(MANIFEST.obsolete.skills.includes(renamed), renamed);
170
+ assert.equal(fs.existsSync(path.join(sourceSkills, renamed)), false, renamed);
171
+ }
161
172
  assert.equal(sourceCatalog.skills.some((skill) => MANIFEST.obsolete.skills.includes(skill.directory)), false);
162
- const route = sourceCatalog.skills.find((skill) => skill.public_id === 'cf:route');
163
- assert.ok(route?.description && route.when_to_use && route.category && route.keywords.length > 0);
173
+ assert.equal(sourceCatalog.skills.some((skill) => skill.public_id === 'cf:route'), false);
174
+ const ask = sourceCatalog.skills.find((skill) => skill.public_id === 'cf:ask');
175
+ assert.ok(ask?.description && ask.when_to_use && ask.category && ask.keywords.length > 0);
164
176
  });
165
177
 
166
178
  test('skill catalog rejects a missing root value before path resolution', () => {
@@ -1,11 +1,9 @@
1
1
  'use strict';
2
2
 
3
- // Two Stop hooks ask two different questions about the same repository, so they narrow an
4
- // ambiguous scan differently. The gate asks which packet still has unfinished work, so it
5
- // can validate that packet's receipts. Closeout approval asks which packet is claiming
6
- // closeout, so it can demand a one-time approval for it. Counting finished history as
7
- // ambiguity is what left a repository of accumulated features permanently blocked, with
8
- // the documented escape — an explicit feature target — unavailable.
3
+ // The Stop gate narrows an ambiguous scan by asking which packet still has unfinished
4
+ // work, so it can validate that packet's receipts. Counting finished history as ambiguity
5
+ // is what left a repository of accumulated features permanently blocked, with the
6
+ // documented escape — an explicit feature target — unavailable.
9
7
 
10
8
  const assert = require('node:assert/strict');
11
9
  const { spawnSync } = require('node:child_process');
@@ -38,7 +36,7 @@ function legacyPacket(root, name, { status, phase, taskStatus = 'done' } = {}) {
38
36
  }
39
37
 
40
38
  /** A process-first packet: plan.md plus one flat task file. */
41
- function workflowPacket(root, name, taskStatus) {
39
+ function workflowPacket(root, name, taskStatus, nextStatus = null) {
42
40
  const dir = path.join(root, 'specs', name);
43
41
  fs.mkdirSync(dir, { recursive: true });
44
42
  fs.writeFileSync(path.join(dir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
@@ -46,6 +44,12 @@ function workflowPacket(root, name, taskStatus) {
46
44
  path.join(dir, 'task-01-x.md'),
47
45
  `# Task 01\n\nStatus: ${taskStatus}\n\n## Dependencies\n- none\n`
48
46
  );
47
+ if (nextStatus) {
48
+ fs.writeFileSync(
49
+ path.join(dir, 'task-02-y.md'),
50
+ `# Task 02\n\nStatus: ${nextStatus}\n\n## Dependencies\n- none\n`
51
+ );
52
+ }
49
53
  }
50
54
 
51
55
  function recordActiveFeature(root, contents) {
@@ -63,34 +67,19 @@ function gateResolution(root) {
63
67
 
64
68
  // ── The gate: which packet still has work ───────────────────────────────────────────
65
69
 
66
- test('a legacy repository resolves its one unfinished packet', () => {
70
+ test('a leftover legacy packet creates no ambiguity for the gate', () => {
67
71
  withProject((root) => {
68
72
  for (let index = 1; index <= 3; index += 1) {
69
- legacyPacket(root, `done-${index}`, { status: 'done', phase: 'closeout' });
73
+ legacyPacket(root, `history-${index}`, { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
70
74
  }
71
- legacyPacket(root, 'active-now', { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
75
+ workflowPacket(root, 'active-now', 'pending');
72
76
  const resolved = gateResolution(root);
73
77
  assert.equal(resolved.error, undefined, `expected a resolution, got ${resolved.error}`);
74
78
  assert.equal(resolved.featureName, 'active-now');
79
+ assert.deepEqual(RESOLVER.findLegacyPackets(root, {}), ['history-1', 'history-2', 'history-3']);
75
80
  });
76
81
  });
77
82
 
78
- test('a legacy registry written with the older vocabulary counts as finished', () => {
79
- // Issue #79's repository was upgraded from an older release whose task_registry used
80
- // `completed`. Today's validator allows only pending/in_progress/blocked/done, so
81
- // narrowing read those packets as unfinished and the block survived the fix that was
82
- // supposed to clear it. Measured on the reported shape before this: 18 candidates.
83
- for (const status of ['done', 'completed', 'complete']) {
84
- withProject((root) => {
85
- for (let index = 1; index <= 3; index += 1) {
86
- legacyPacket(root, `history-${index}`, { taskStatus: status });
87
- }
88
- legacyPacket(root, 'active-now', { status: 'in_progress', phase: 'execution', taskStatus: 'pending' });
89
- assert.equal(gateResolution(root).featureName, 'active-now', `task status ${status} must read as finished`);
90
- });
91
- }
92
- });
93
-
94
83
  test('a process-first packet keeps the strict task vocabulary', () => {
95
84
  // The widened set is legacy-only. A process-first task saying `completed` is malformed,
96
85
  // not finished, and must not be silently read as done.
@@ -102,20 +91,6 @@ test('a process-first packet keeps the strict task vocabulary', () => {
102
91
  });
103
92
  });
104
93
 
105
- test('a legacy candidate never reaches the bulk branch', () => {
106
- // The bulk branch exits before the semantic-digest, FLASH_UNVERIFIED, feature-receipt
107
- // and completion-policy layers, so a legacy packet reaching it would turn a block into
108
- // a silent pass. With every legacy packet finished there is nothing to narrow to, and
109
- // the existing ambiguity must stand rather than the set being audited.
110
- withProject((root) => {
111
- legacyPacket(root, 'done-1', { status: 'done', phase: 'closeout' });
112
- legacyPacket(root, 'done-2', { status: 'done', phase: 'closeout' });
113
- const resolved = gateResolution(root);
114
- assert.notEqual(resolved.layoutKind, 'process-v3-completed-set');
115
- assert.ok(resolved.error, 'an all-legacy finished set must stay ambiguous');
116
- });
117
- });
118
-
119
94
  test('a process-first set still reaches the bulk branch', () => {
120
95
  withProject((root) => {
121
96
  workflowPacket(root, 'done-1', 'done');
@@ -132,116 +107,21 @@ test('one unfinished process-first packet still narrows', () => {
132
107
  });
133
108
  });
134
109
 
135
- // ── Closeout approval: which packet is claiming closeout ────────────────────────────
136
-
137
- test('one packet claiming closeout among finished ones resolves', () => {
138
- withProject((root) => {
139
- legacyPacket(root, 'shipped', { status: 'in_progress', phase: 'execution', taskStatus: 'done' });
140
- legacyPacket(root, 'closing', { status: 'done', phase: 'closeout' });
141
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
142
- assert.equal(resolved.error, undefined);
143
- assert.equal(resolved.featureName, 'closing');
144
- });
145
- });
146
-
147
- test('a lone finished packet still resolves', () => {
148
- // Four existing cases depend on this: closeout approval is claimed against a finished
149
- // spec, so a single packet must resolve whatever its status.
150
- for (const status of ['done', 'completed']) {
151
- withProject((root) => {
152
- legacyPacket(root, 'only', { status });
153
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
154
- assert.equal(resolved.featureName, 'only', `status ${status} must still resolve`);
155
- });
156
- }
157
- });
158
-
159
- test('a lone packet mid-execution still resolves', () => {
160
- // The closeout filter would drop this one, so the single-candidate return above it is
161
- // what keeps a repository holding exactly one in-flight packet resolvable. Without it
162
- // the hook goes silent for that repository instead of checking the packet.
163
- withProject((root) => {
164
- legacyPacket(root, 'solo', { status: 'in_progress', phase: 'implementation', taskStatus: 'pending' });
165
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
166
- assert.equal(resolved.featureName, 'solo');
167
- });
168
- });
169
-
170
- test('no packet claiming closeout resolves to null', () => {
171
- // null means nothing is in flight and the hook stays silent; an error would keep
172
- // blocking a repository where no closeout is being claimed at all.
173
- withProject((root) => {
174
- legacyPacket(root, 'a', { status: 'in_progress', phase: 'execution' });
175
- legacyPacket(root, 'b', { status: 'in_progress', phase: 'execution' });
176
- assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }), null);
177
- });
178
- });
179
-
180
- test('several packets claiming closeout stay ambiguous and name only those', () => {
181
- withProject((root) => {
182
- legacyPacket(root, 'closing-a', { status: 'done', phase: 'closeout' });
183
- legacyPacket(root, 'closing-b', { status: 'done', phase: 'closeout' });
184
- legacyPacket(root, 'working', { status: 'in_progress', phase: 'execution' });
185
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
186
- assert.equal(resolved.error, 'multiple_persisted');
187
- assert.deepEqual(resolved.candidates, ['closing-a', 'closing-b']);
188
- });
189
- });
190
-
191
- test('codex resolveCandidate agrees with the shared resolver', () => {
192
- // Codex kept its own candidate count, reachable because spec-utils did not export the
193
- // shared function. A Claude-only change would leave every Codex turn blocked.
194
- const codexCheck = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/completion-authority-check.cjs'));
195
- const utils = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
196
- assert.equal(typeof utils.resolvePersistedSpec, 'function', 'the wrapper must be exported');
197
- withProject((root) => {
198
- legacyPacket(root, 'shipped', { status: 'in_progress', phase: 'execution', taskStatus: 'done' });
199
- legacyPacket(root, 'closing', { status: 'done', phase: 'closeout' });
200
- const shared = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
201
- const viaCodex = codexCheck.resolveCandidate({ projectRoot: root, runtime: {}, payload: {} });
202
- assert.equal(viaCodex.featureName, shared.featureName);
203
- });
204
- });
205
-
206
110
  // ── The recorded active feature: the escape the block told users to take ────────────
207
111
 
208
- test('a recorded active feature resolves two packets claiming closeout', () => {
209
- withProject((root) => {
210
- legacyPacket(root, 'closing-a', { status: 'done', phase: 'closeout' });
211
- legacyPacket(root, 'closing-b', { status: 'done', phase: 'closeout' });
212
- assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).error, 'multiple_persisted');
213
- recordActiveFeature(root, '{"featureName": "closing-b"}\n');
214
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
215
- assert.equal(resolved.error, undefined, `expected a resolution, got ${resolved.error}`);
216
- assert.equal(resolved.featureName, 'closing-b');
217
- });
218
- });
219
-
220
- test('a payload target beats the recorded file', () => {
221
- withProject((root) => {
222
- legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
223
- legacyPacket(root, 'beta', { status: 'done', phase: 'closeout' });
224
- recordActiveFeature(root, '{"featureName": "alpha"}\n');
225
- const resolved = RESOLVER.resolvePersistedSpec({
226
- projectRoot: root, runtime: {}, target: { featureName: 'beta' },
227
- });
228
- assert.equal(resolved.featureName, 'beta', 'the caller must win');
229
- });
230
- });
231
-
232
112
  test('a blank recorded feature is ignored rather than malformed', () => {
233
113
  // explicitTargetValue turns a blank into null, and a null target becomes an
234
114
  // explicit_malformed error that blocks. The reader must return null instead, or every
235
115
  // project holding a stray file would be blocked by the very escape hatch.
236
116
  for (const contents of ['{"featureName": " "}', '{"featureName": 42}', '{}', 'not json', '[]']) {
237
117
  withProject((root) => {
238
- legacyPacket(root, 'only', { status: 'done', phase: 'closeout' });
118
+ workflowPacket(root, 'only', 'pending');
239
119
  recordActiveFeature(root, contents);
240
120
  assert.equal(
241
121
  RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} }), null,
242
122
  `${contents} must be invisible`
243
123
  );
244
- assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).featureName, 'only');
124
+ assert.equal(RESOLVER.resolveWorkflowCandidate({ projectRoot: root, runtime: {} }).featureName, 'only');
245
125
  });
246
126
  }
247
127
  });
@@ -249,11 +129,12 @@ test('a blank recorded feature is ignored rather than malformed', () => {
249
129
  test('a recorded feature cannot escape the specs root', () => {
250
130
  // The reader adds no trust: the value goes through the resolver's existing checks.
251
131
  withProject((root) => {
252
- legacyPacket(root, 'a', { status: 'done', phase: 'closeout' });
253
- legacyPacket(root, 'b', { status: 'done', phase: 'closeout' });
132
+ workflowPacket(root, 'a', 'pending');
133
+ workflowPacket(root, 'b', 'pending');
254
134
  recordActiveFeature(root, '{"featureName": "../../etc"}');
255
- const resolved = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
256
- assert.ok(resolved.error, 'an escaping name must produce the existing error, not a resolution');
135
+ const target = RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} });
136
+ const resolved = RESOLVER.resolveWorkflowCandidate({ projectRoot: root, runtime: {}, target });
137
+ assert.equal(resolved.error, 'explicit_malformed', 'an escaping name must produce the existing error, not a resolution');
257
138
  assert.notEqual(resolved.featureName, '../../etc');
258
139
  });
259
140
  });
@@ -263,7 +144,7 @@ test('a symlinked recorded feature is refused', () => {
263
144
  // project does not contain. The repository already refuses symlinked runtime
264
145
  // dependencies elsewhere; this reader follows the same rule.
265
146
  withProject((root) => {
266
- legacyPacket(root, 'only', { status: 'done', phase: 'closeout' });
147
+ workflowPacket(root, 'only', 'pending');
267
148
  const outside = path.join(root, 'outside.json');
268
149
  fs.writeFileSync(outside, '{"featureName": "only"}');
269
150
  const dir = path.join(root, 'specs', '_shared');
@@ -275,32 +156,20 @@ test('a symlinked recorded feature is refused', () => {
275
156
 
276
157
  test('an absent file leaves every project exactly as it was', () => {
277
158
  withProject((root) => {
278
- legacyPacket(root, 'only', { status: 'in_progress', phase: 'execution' });
159
+ workflowPacket(root, 'only', 'pending');
279
160
  assert.equal(RESOLVER.readActiveFeatureTarget({ projectRoot: root, runtime: {} }), null);
280
- assert.equal(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} }).featureName, 'only');
161
+ assert.equal(RESOLVER.resolveWorkflowCandidate({ projectRoot: root, runtime: {} }).featureName, 'only');
281
162
  });
282
163
  });
283
164
 
284
- test('the approval prompt names its feature', () => {
285
- // Without the name a user types a bare nonce and cannot tell which closeout they are
286
- // approving, while the recorded file can influence which packet resolves.
287
- for (const runtimeDir of ['claude', 'codex']) {
288
- const hook = fs.readFileSync(
289
- path.join(PACKAGE_ROOT, 'src', runtimeDir, 'hooks', 'completion-authority.cjs'), 'utf8'
290
- );
291
- assert.match(hook, /const feature = result\.candidate\?\.featureName;/, `${runtimeDir} must read the name`);
292
- assert.match(hook, /closeout approval is required for \$\{feature \?/, `${runtimeDir} must print it`);
293
- }
294
- });
295
-
296
165
  test('the shared workflow resolver ignores the recorded file', () => {
297
166
  // resolveWorkflowCandidate has five consumers, including spec-state.cjs and
298
167
  // precompact.cjs. Reading the file inside it would retarget hooks that never asked,
299
168
  // so the two gates apply it at their own call sites instead. Neither of those suites
300
169
  // writes the file, so nothing else pins this containment.
301
170
  withProject((root) => {
302
- legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
303
- legacyPacket(root, 'beta', { status: 'done', phase: 'closeout' });
171
+ workflowPacket(root, 'alpha', 'done');
172
+ workflowPacket(root, 'beta', 'done');
304
173
  recordActiveFeature(root, '{"featureName": "beta"}');
305
174
  const resolved = RESOLVER.resolveWorkflowCandidate({
306
175
  projectRoot: root, runtime: {}, target: null, includeCompleted: true,
@@ -315,7 +184,7 @@ test('the codex gate consults the file only after its payload', () => {
315
184
  assert.equal(typeof utils.readActiveFeatureTarget, 'function', 'the wrapper must be exported');
316
185
  assert.equal(typeof utils.extractExplicitTarget, 'function', 'the gate needs the payload extractor');
317
186
  withProject((root) => {
318
- legacyPacket(root, 'alpha', { status: 'done', phase: 'closeout' });
187
+ workflowPacket(root, 'alpha', 'done');
319
188
  recordActiveFeature(root, '{"featureName": "alpha"}');
320
189
  assert.deepEqual(
321
190
  utils.extractExplicitTarget({ featureName: 'beta' }) || utils.readActiveFeatureTarget({ projectRoot: root, runtime: {} }),
@@ -365,8 +234,8 @@ for (const [runtimeName, hook] of [['claude', 'src/claude/hooks/spec-gate.cjs']]
365
234
  // identity complaint whose stated remedy nothing could supply; naming the feature
366
235
  // makes the gate resolve it and report the violation that was there all along.
367
236
  gitProject((dir) => {
368
- legacyPacket(dir, 'alpha', { status: 'done', phase: 'closeout' });
369
- legacyPacket(dir, 'beta', { status: 'done', phase: 'closeout' });
237
+ workflowPacket(dir, 'alpha', 'done', 'pending');
238
+ workflowPacket(dir, 'beta', 'done', 'pending');
370
239
 
371
240
  const blocked = runGate(hook, dir);
372
241
  assert.match(blocked, /multiple active specs detected/, 'the reported block must reproduce');