@haposoft/cafekit 0.16.0-rc.6 → 0.16.0-rc.8

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 (87) hide show
  1. package/README.md +21 -58
  2. package/bin/__tests__/change-firewall.semantic-firewall.test.js +4 -5
  3. package/bin/__tests__/codex-hooks.test.js +382 -0
  4. package/bin/__tests__/codex-native.test.js +579 -80
  5. package/bin/__tests__/develop-contract.test.js +155 -76
  6. package/bin/__tests__/installer-safety.test.js +19 -154
  7. package/bin/__tests__/package-inventory.test.js +494 -26
  8. package/bin/__tests__/release-preflight.semantic-firewall.test.js +2 -2
  9. package/bin/__tests__/validator-grounding.test.js +11 -11
  10. package/bin/install.js +2 -7
  11. package/bin/lib/context.js +0 -45
  12. package/bin/lib/copy-utils.js +1 -1
  13. package/bin/lib/i18n.js +0 -3
  14. package/bin/lib/manifest.js +1 -1
  15. package/bin/phases/claude-runtime.js +1 -3
  16. package/bin/phases/codex-runtime.js +4 -2
  17. package/bin/phases/copy-payload.js +3 -15
  18. package/bin/phases/post-install.js +5 -17
  19. package/bin/phases/root-config.js +2 -3
  20. package/bin/phases/select-platform.js +2 -5
  21. package/bin/phases/summary.js +0 -1
  22. package/package.json +2 -5
  23. package/src/claude/CLAUDE.md +19 -6
  24. package/src/claude/agents/project-manager.md +41 -9
  25. package/src/claude/agents/spec-maker.md +82 -155
  26. package/src/claude/gitignore +1 -1
  27. package/src/claude/hooks/__tests__/spec-gate.test.js +238 -0
  28. package/src/claude/hooks/__tests__/state.test.js +249 -3
  29. package/src/claude/hooks/lib/context.cjs +1 -1
  30. package/src/claude/hooks/lib/detect.cjs +1 -1
  31. package/src/claude/hooks/spec-gate.cjs +44 -9
  32. package/src/claude/hooks/spec-state.cjs +51 -13
  33. package/src/claude/hooks/task-scaffold-guard.cjs +1 -1
  34. package/src/claude/migration-manifest.json +17 -3
  35. package/src/claude/rules/manage-docs.md +43 -24
  36. package/src/claude/rules/state-sync.md +57 -62
  37. package/src/claude/rules/workflow.md +30 -13
  38. package/src/claude/scripts/change-firewall.cjs +2 -2
  39. package/src/claude/scripts/generate-skill-catalog.cjs +0 -1
  40. package/src/claude/scripts/spec-receipt.cjs +196 -1
  41. package/src/claude/scripts/spec-resolver.cjs +428 -0
  42. package/src/claude/skills/develop/SKILL.md +89 -125
  43. package/src/claude/skills/develop/references/parallel-waves.md +75 -162
  44. package/src/claude/skills/develop/references/quality-gate.md +61 -121
  45. package/src/claude/skills/develop/references/subagent-patterns.md +31 -38
  46. package/src/claude/skills/question/SKILL.md +1 -1
  47. package/src/claude/skills/specs/SKILL.md +152 -204
  48. package/src/claude/skills/specs/references/review.md +124 -243
  49. package/src/claude/skills/specs/references/templates.md +202 -0
  50. package/src/claude/skills/sync/SKILL.md +66 -90
  51. package/src/claude/skills/sync/references/sync-protocols.md +62 -83
  52. package/src/codex/AGENTS.md +20 -7
  53. package/src/codex/hooks/lib/spec-receipt.cjs +18 -0
  54. package/src/codex/hooks/lib/spec-utils.cjs +25 -0
  55. package/src/codex/hooks/spec-gate.cjs +46 -16
  56. package/src/codex/hooks/spec-state.cjs +54 -16
  57. package/src/codex/rules/state-sync.md +57 -61
  58. package/src/common/AGENTS.md +8 -1
  59. package/bin/__tests__/opencode-spec-gate.test.js +0 -799
  60. package/bin/lib/opencode-install.js +0 -899
  61. package/bin/phases/opencode-runtime.js +0 -38
  62. package/src/claude/skills/specs/references/archive-workflow.md +0 -74
  63. package/src/claude/skills/specs/references/ask-user-question-gates.md +0 -70
  64. package/src/claude/skills/specs/references/codebase-analysis.md +0 -102
  65. package/src/claude/skills/specs/references/cross-spec-dependency.md +0 -76
  66. package/src/claude/skills/specs/references/research-strategy.md +0 -107
  67. package/src/claude/skills/specs/references/scope-inquiry.md +0 -107
  68. package/src/claude/skills/specs/references/translation-mirror.md +0 -83
  69. package/src/claude/skills/specs/rules/design-discovery-full.md +0 -45
  70. package/src/claude/skills/specs/rules/design-discovery-light.md +0 -28
  71. package/src/claude/skills/specs/rules/design-principles.md +0 -138
  72. package/src/claude/skills/specs/rules/design-review.md +0 -55
  73. package/src/claude/skills/specs/rules/ears-format.md +0 -42
  74. package/src/claude/skills/specs/rules/phase-decision-matrix.md +0 -56
  75. package/src/claude/skills/specs/rules/task-scoring-rubric.md +0 -52
  76. package/src/claude/skills/specs/rules/tasks-generation.md +0 -189
  77. package/src/opencode/AGENTS.md +0 -41
  78. package/src/opencode/plugins/docs-sync.ts +0 -175
  79. package/src/opencode/plugins/inspect-block.ts +0 -164
  80. package/src/opencode/plugins/privacy-block.ts +0 -182
  81. package/src/opencode/plugins/rules.ts +0 -155
  82. package/src/opencode/plugins/session.ts +0 -179
  83. package/src/opencode/plugins/spec-gate.ts +0 -457
  84. package/src/opencode/plugins/spec-state.ts +0 -311
  85. package/src/opencode/plugins/state.ts +0 -253
  86. package/src/opencode/plugins/task-scaffold-guard.ts +0 -161
  87. package/src/opencode/plugins/usage.ts +0 -178
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @haposoft/cafekit
2
2
 
3
- > Native spec-driven workflow and runtime bundle for Claude Code, Codex CLI, and OpenCode.
3
+ > Native spec-driven workflow and runtime bundle for Claude Code and Codex CLI.
4
4
 
5
5
  [![Version](https://img.shields.io/badge/version-0.16.0-blue.svg)](https://github.com/haposoft/cafekit)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
@@ -28,14 +28,6 @@ Codex CLI install support:
28
28
  - merges a CafeKit-owned block into root `AGENTS.md`
29
29
  - requires no generated `.codex/config.toml` and never changes global trust config
30
30
 
31
- OpenCode install support:
32
- - installs command wrappers under `.opencode/commands/`
33
- - installs converted OpenCode agents under `.opencode/agents/`
34
- - installs CafeKit skills under `.opencode/skills/`
35
- - installs OpenCode plugin runtime files under `.opencode/plugins/`
36
- - merges `opencode.json` and root `AGENTS.md`
37
- - omits the Claude `hapo:` command prefix
38
-
39
31
  ## Install
40
32
 
41
33
  Run in your project root:
@@ -59,7 +51,7 @@ npx @haposoft/cafekit --platform codex --force-overwrite
59
51
 
60
52
  Requirements:
61
53
  - Node.js 18+
62
- - Claude Code, Codex CLI, or OpenCode; choose a runtime when prompted or use `--platform`
54
+ - Claude Code or Codex CLI; choose a runtime when prompted or use `--platform`
63
55
 
64
56
  ## Git ignore policy
65
57
 
@@ -75,15 +67,13 @@ plans/*
75
67
  .cafekit-backup/
76
68
  .cafekit.lock
77
69
  .claude/
78
- .opencode/
79
70
  .codex/
80
71
  .agents/
81
72
  ```
82
73
 
83
74
  Runtime folders are local — reinstall with `npx @haposoft/cafekit` rather than
84
- committing them. Inside the runtime, CafeKit also installs `.claude/.gitignore`,
85
- `.opencode/.gitignore`, or the Codex pair `.codex/.gitignore` +
86
- `.agents/.gitignore`. These keep secrets, skill dependencies, session state,
75
+ committing them. Inside the runtime, CafeKit also installs `.claude/.gitignore` or the
76
+ Codex pair `.codex/.gitignore` + `.agents/.gitignore`. These keep secrets, skill dependencies, session state,
87
77
  and hook logs out of git even if someone partially un-ignores a runtime.
88
78
  Teams may deliberately commit runtime files, but should also commit the
89
79
  ownership manifest so selective updates share the same baseline.
@@ -141,37 +131,11 @@ without requiring Git and remain stable when a session starts in a subdirectory.
141
131
  CafeKit uses Codex's native status and usage UI instead of installing the
142
132
  Claude statusline.
143
133
 
144
- OpenCode targets:
145
-
146
- ```text
147
- .opencode/
148
- ├── .gitignore
149
- ├── commands/
150
- ├── agents/
151
- ├── skills/
152
- ├── rules/
153
- ├── scripts/
154
- ├── references/
155
- ├── plugins/
156
- ├── cafekit.json
157
- ├── runtime.json
158
- └── package.json
159
-
160
- AGENTS.md
161
- opencode.json
162
- ```
163
-
164
- OpenCode setup also:
165
- - binds generated commands to matching CafeKit subagents with `agent` + `subtask`
166
- - configures `permission.skill` and `permission.task`
167
- - optionally writes `model` to `opencode.json` from `OPENCODE_MODEL`, `OPENCODE_DEFAULT_MODEL`, or installer input
168
-
169
134
  To check the installed CafeKit package version:
170
135
 
171
136
  ```bash
172
137
  cat .claude/cafekit.json
173
138
  cat .codex/cafekit.json
174
- cat .opencode/cafekit.json
175
139
  ```
176
140
 
177
141
  ## Core Skills
@@ -224,17 +188,6 @@ $hapo-test meet-transcript-mvp --full
224
188
  $hapo-code-review meet-transcript-mvp --pending
225
189
  ```
226
190
 
227
- OpenCode uses the generated command names without the Claude `hapo:` prefix:
228
-
229
- ```bash
230
- /question "Which files define the current CafeKit install/runtime behavior?" --repo
231
- /brainstorm Explore approaches for a Google Meet transcript extension
232
- /specs Build a Google Meet transcript extension with AI summaries
233
- /develop meet-transcript-mvp
234
- /test --full
235
- /code-review --pending
236
- ```
237
-
238
191
  Reconstruct as-is docs from a legacy codebase:
239
192
 
240
193
  ```bash
@@ -253,16 +206,26 @@ hosted or specialized tools may not enter the local hook path.
253
206
 
254
207
  ## Spec Output
255
208
 
256
- Specs are stored under:
209
+ New process-first Specs packets are stored under:
257
210
 
258
211
  ```text
259
212
  specs/<feature-name>/
260
- ├── spec.json
261
- ├── requirements.md
262
- ├── research.md
263
- ├── design.md
264
- └── tasks/task-R*.md
265
- ```
213
+ ├── plan.md
214
+ ├── task-01-<slug>.md
215
+ └── task-NN-<slug>.md
216
+ ```
217
+
218
+ The process-first workflow has three human decision gates. `hapo:specs` opens
219
+ C1 to fix scope before authoring and C2 to resolve adversarial findings, then
220
+ stops. A later explicit `hapo:develop` invocation executes the tasks and
221
+ presents C3 for closeout only after real execution proof. Each task has one
222
+ `Status:` field and keeps its canonical `## Receipt` inline, including the
223
+ exact Verification Plan command, `Exit: 0`, `Verification: PASS`,
224
+ runtime-derived Base and Head, and current command output.
225
+
226
+ Existing packets with `spec.json`, nested `tasks/task-R*.md`, or separate
227
+ receipts remain supported through the legacy compatibility adapter. New Specs
228
+ work does not author those legacy artifacts.
266
229
 
267
230
  ## Development
268
231
 
@@ -66,7 +66,7 @@ function initRepo(root, { version = '0.0.1-fixture' } = {}) {
66
66
  }
67
67
 
68
68
  function writeLegacyBridge(root, { baseCommit, semanticDigest = cf.sha256Tag('bridge-digest') }) {
69
- writeJson(path.join(root, 'specs/cafekit-semantic-eval-firewall/reports/bootstrap-legacy-bridge-review.json'), {
69
+ writeJson(path.join(root, 'specs/archive/cafekit-semantic-eval-firewall/reports/bootstrap-legacy-bridge-review.json'), {
70
70
  schema_version: '1',
71
71
  verdict: 'PASS',
72
72
  blocking_count: 0,
@@ -78,7 +78,7 @@ function writeLegacyBridge(root, { baseCommit, semanticDigest = cf.sha256Tag('br
78
78
  }
79
79
 
80
80
  function writeFeatureSeedPass(root, { reviewReceiptDigest = cf.sha256Tag('seed-review-receipt') } = {}) {
81
- writeJson(path.join(root, 'specs/cafekit-semantic-eval-firewall/spec.json'), {
81
+ writeJson(path.join(root, 'specs/archive/cafekit-semantic-eval-firewall/spec.json'), {
82
82
  validation: {
83
83
  semantic_review_history: {
84
84
  entries: [{
@@ -201,7 +201,7 @@ test('R1.14 negative: an absent or malformed legacy-bridge artifact fails the pr
201
201
  const inst = cf.createChangeFirewall({ root });
202
202
  assert.throws(() => inst.assertLegacyBridgeArtifact(), (error) => error.code === 'legacy_bridge_missing');
203
203
 
204
- writeJson(path.join(root, 'specs/cafekit-semantic-eval-firewall/reports/bootstrap-legacy-bridge-review.json'), {
204
+ writeJson(path.join(root, 'specs/archive/cafekit-semantic-eval-firewall/reports/bootstrap-legacy-bridge-review.json'), {
205
205
  schema_version: '1', verdict: 'FAIL', blocking_count: 0,
206
206
  reviewed_criteria: ['R1.1'], semantic_digest: cf.sha256Tag('x'),
207
207
  bootWindowBaseCommit: '0'.repeat(40), written_at: new Date().toISOString(),
@@ -379,7 +379,7 @@ test('C14/R1.8: bootstrapBaseline refuses before the seed PASS exists, and refus
379
379
  const inst = cf.createChangeFirewall({ root });
380
380
 
381
381
  // feature spec.json exists but carries no semantic_review_history yet (no seed PASS).
382
- writeJson(path.join(root, 'specs/cafekit-semantic-eval-firewall/spec.json'), { validation: {} });
382
+ writeJson(path.join(root, 'specs/archive/cafekit-semantic-eval-firewall/spec.json'), { validation: {} });
383
383
  assert.throws(
384
384
  () => inst.bootstrapBaseline({ baseCommit, headCommit, bootstrapReviewDigest: cf.sha256Tag('x'), packageVersion: '0.0.1-fixture' }),
385
385
  (error) => error.code === 'seed_pass_missing',
@@ -2131,4 +2131,3 @@ test('REGRESSION round7 (critical): a committed C10 authority renamed away then
2131
2131
  assert.throws(() => inst.createFreezeManifest(null), (error) => error.code === 'out_of_band_edit');
2132
2132
  });
2133
2133
  });
2134
-
@@ -45,6 +45,16 @@ function bindReceipt(root, value, feature = 'auth', session = 'session-a') {
45
45
  return value.replaceAll(VALID_BASE, context.base).replaceAll(VALID_HEAD, context.head);
46
46
  }
47
47
 
48
+ function workflowRuntimeContext(root, feature = 'auth', session = 'session-a') {
49
+ return PROVENANCE.deriveRuntimeContext({
50
+ projectRoot: root,
51
+ specsRoot: path.join(root, 'specs'),
52
+ specFile: path.join(root, 'specs', feature, 'plan.md'),
53
+ featureName: feature,
54
+ runtimeSession: session,
55
+ });
56
+ }
57
+
48
58
  function installFeatureReceipts(root, session = 'session-a') {
49
59
  const specsRoot = path.join(root, 'specs');
50
60
  if (!fs.existsSync(specsRoot)) return;
@@ -128,6 +138,219 @@ function inHookFixture(run, options = {}) {
128
138
  }
129
139
  }
130
140
 
141
+ function processTaskContent(title, status, dependency = 'none') {
142
+ return [
143
+ `# ${title}`, '', `Status: ${status}`, '',
144
+ '## Dependencies', '', `- ${dependency}`, '',
145
+ '## Verification Plan', '', '- Command: node --test', '',
146
+ ].join('\n');
147
+ }
148
+
149
+ function canonicalProcessTask(root, title, dependency = 'none') {
150
+ const context = workflowRuntimeContext(root, 'auth', 'session-auth-state');
151
+ return [
152
+ processTaskContent(title, 'done', dependency),
153
+ '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
154
+ `Base: ${context.base}`, `Head: ${context.head}`,
155
+ '```text', 'node --test', '1 test passed', '```', '',
156
+ ].join('\n');
157
+ }
158
+
159
+ function inInstalledProcessSpecStateFixture(run) {
160
+ return inHookFixture((root, hooks) => {
161
+ const feature = path.join(root, 'specs', 'auth');
162
+ fs.mkdirSync(feature, { recursive: true });
163
+ fs.writeFileSync(path.join(feature, 'plan.md'), '# Auth plan\nSpecs-Contract: process-first-ready-v1\n');
164
+
165
+ const writeTask = (basename, title, status, dependency = 'none') => {
166
+ fs.writeFileSync(
167
+ path.join(feature, basename),
168
+ processTaskContent(title, status, dependency),
169
+ );
170
+ };
171
+ const runState = () => runHook(path.join(hooks, 'spec-state.cjs'), root, {
172
+ cwd: root,
173
+ featureName: 'auth',
174
+ session_id: 'session-auth-state',
175
+ hook_event_name: 'UserPromptSubmit',
176
+ prompt: 'Continue',
177
+ });
178
+ const findCacheFile = () => {
179
+ const cacheDir = path.join(hooks, '.logs');
180
+ const cacheNames = fs.existsSync(cacheDir)
181
+ ? fs.readdirSync(cacheDir).filter((file) => file.startsWith('tollgate-'))
182
+ : [];
183
+ assert.equal(cacheNames.length, 1, 'one installed session cache must exist');
184
+ return path.join(cacheDir, cacheNames[0]);
185
+ };
186
+ return run({
187
+ feature,
188
+ findCacheFile,
189
+ hooks,
190
+ root,
191
+ runState,
192
+ writePlan: (content) => fs.writeFileSync(path.join(feature, 'plan.md'), content),
193
+ writeTask,
194
+ });
195
+ });
196
+ }
197
+
198
+ test('Codex installed spec-state re-evaluates a standalone task when blocked becomes pending', () => {
199
+ inInstalledProcessSpecStateFixture(({ findCacheFile, hooks, runState, writeTask }) => {
200
+ writeTask('task-01-auth.md', 'Task 01: auth', 'blocked');
201
+
202
+ const blocked = runState();
203
+ assert.equal(blocked.status, 0, blocked.stderr);
204
+ assert.match(blocked.stdout, /Spec state changed: `auth`/);
205
+ assert.doesNotMatch(blocked.stdout, /Next unblocked/);
206
+ const cacheFile = findCacheFile();
207
+ assert.ok(cacheFile.startsWith(`${hooks}${path.sep}`), 'cache must stay inside temporary installed hooks');
208
+ const blockedCache = fs.readFileSync(cacheFile, 'utf8');
209
+
210
+ writeTask('task-01-auth.md', 'Task 01: auth', 'pending');
211
+ const pending = runState();
212
+ assert.equal(pending.status, 0, pending.stderr);
213
+ assert.match(pending.stdout, /Spec state changed: `auth`/);
214
+ assert.match(pending.stdout, /Next unblocked: `task-01-auth\.md`/);
215
+ assert.notEqual(fs.readFileSync(cacheFile, 'utf8'), blockedCache, 'state cache must change');
216
+ });
217
+ });
218
+
219
+ test('Codex installed spec-state requires current canonical dependency proof and keys receipt mutations', () => {
220
+ inInstalledProcessSpecStateFixture(({ feature, findCacheFile, hooks, root, runState, writeTask }) => {
221
+ const predecessor = 'task-01-semantic-review.md';
222
+ const dependent = 'task-02-implementation.md';
223
+ writeTask(predecessor, 'Task 01: semantic review', 'blocked');
224
+ writeTask(dependent, 'Task 02: implementation', 'pending', predecessor);
225
+ const dependentPath = path.join(feature, dependent);
226
+ const unchangedDependent = fs.readFileSync(dependentPath, 'utf8');
227
+
228
+ const blocked = runState();
229
+ assert.equal(blocked.status, 0, blocked.stderr);
230
+ assert.match(blocked.stdout, /Tasks: 0 done \/ 2 total/);
231
+ assert.doesNotMatch(blocked.stdout, /Next unblocked/);
232
+ const cacheFile = findCacheFile();
233
+ assert.ok(cacheFile.startsWith(`${hooks}${path.sep}`), 'cache must stay inside temporary installed hooks');
234
+ const blockedCache = fs.readFileSync(cacheFile, 'utf8');
235
+
236
+ writeTask(predecessor, 'Task 01: semantic review', 'done');
237
+ const missing = runState();
238
+ assert.equal(missing.status, 0, missing.stderr);
239
+ assert.doesNotMatch(missing.stdout, /Next unblocked/);
240
+ const missingCache = fs.readFileSync(cacheFile, 'utf8');
241
+ assert.notEqual(missingCache, blockedCache, 'done without proof must change cached proof state');
242
+
243
+ fs.appendFileSync(path.join(feature, predecessor), '\n## Receipt\n\nVerification: PASS\n');
244
+ const malformed = runState();
245
+ assert.equal(malformed.status, 0, malformed.stderr);
246
+ assert.doesNotMatch(malformed.stdout, /Next unblocked/);
247
+
248
+ fs.writeFileSync(path.join(feature, predecessor), canonicalProcessTask(root, 'Task 01: semantic review'));
249
+ const ready = runState();
250
+ assert.equal(ready.status, 0, ready.stderr);
251
+ assert.match(ready.stdout, /Next unblocked: `task-02-implementation\.md`/);
252
+ const readyCache = fs.readFileSync(cacheFile, 'utf8');
253
+ assert.notEqual(readyCache, missingCache, 'canonical proof must change cached proof state');
254
+
255
+ fs.writeFileSync(
256
+ path.join(feature, predecessor),
257
+ fs.readFileSync(path.join(feature, predecessor), 'utf8').replace('Verification: PASS', 'Verification: FAIL'),
258
+ );
259
+ const mutated = runState();
260
+ assert.equal(mutated.status, 0, mutated.stderr);
261
+ assert.doesNotMatch(mutated.stdout, /Next unblocked/);
262
+ assert.notEqual(fs.readFileSync(cacheFile, 'utf8'), readyCache, 'receipt-only mutation must change cache');
263
+ assert.equal(fs.readFileSync(dependentPath, 'utf8'), unchangedDependent, 'dependent must remain pending');
264
+ });
265
+ });
266
+
267
+ test('Codex installed spec-state keeps unversioned pending packets out of Next with migration guidance', () => {
268
+ inInstalledProcessSpecStateFixture(({ feature, runState, writePlan, writeTask }) => {
269
+ const fencedMarkerPlan = '# Auth plan\n\n```markdown\nSpecs-Contract: process-first-ready-v1\n```\n';
270
+ writePlan(fencedMarkerPlan);
271
+ writeTask('task-01-auth.md', 'Task 01: auth', 'pending');
272
+ const taskBytes = fs.readFileSync(path.join(feature, 'task-01-auth.md'));
273
+ const result = runState();
274
+ assert.equal(result.status, 0, result.stderr);
275
+ assert.doesNotMatch(result.stdout, /Next unblocked/);
276
+ assert.match(result.stdout, /Migration required: add `Specs-Contract: process-first-ready-v1`/);
277
+ const cached = runState();
278
+ assert.equal(cached.status, 0, cached.stderr);
279
+ assert.doesNotMatch(cached.stdout, /Next unblocked/);
280
+ assert.match(cached.stdout, /Add `Specs-Contract: process-first-ready-v1`/);
281
+
282
+ writePlan(fencedMarkerPlan.replace('# Auth plan\n', '# Auth plan\nSpecs-Contract: process-first-ready-v1\n'));
283
+ const marked = runState();
284
+ assert.equal(marked.status, 0, marked.stderr);
285
+ assert.match(marked.stdout, /Spec state changed: `auth`/);
286
+ assert.match(marked.stdout, /Next unblocked: `task-01-auth\.md`/);
287
+ assert.deepEqual(fs.readFileSync(path.join(feature, 'task-01-auth.md')), taskBytes, 'marker-only transition must not change task bytes');
288
+ });
289
+ });
290
+
291
+ test('Codex resolver ignores fenced workflow annotations and rejects fenced-only Status', () => {
292
+ inInstalledProcessSpecStateFixture(({ feature, root, runState, writePlan, writeTask }) => {
293
+ const resolver = require(path.join(root, '.codex', 'scripts', 'spec-resolver.cjs'));
294
+ const trailingFence = resolver.annotatedMarkdownLines([
295
+ '```markdown', '``` trailing', 'Specs-Contract: process-first-ready-v1',
296
+ 'Status: done', '## Receipt', '```',
297
+ ].join('\n'));
298
+ assert.deepEqual(trailingFence.slice(2, 5).map(({ outsideFence }) => outsideFence), [false, false, false]);
299
+ const fourSpaceFence = resolver.annotatedMarkdownLines(' ```markdown\nStatus: pending');
300
+ assert.equal(fourSpaceFence[1].outsideFence, true);
301
+ writePlan('# Auth plan\nSpecs-Contract: process-first-ready-v1\n');
302
+ fs.writeFileSync(path.join(feature, 'task-01-fenced-only.md'), [
303
+ '# Task 01', '', '```markdown', 'Status: done', '```', '',
304
+ '## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: node --test', '',
305
+ ].join('\n'));
306
+ const malformed = resolver.resolveWorkflowCandidate({ projectRoot: root, runtime: {}, explicitFeature: 'auth' });
307
+ assert.equal(malformed.error, 'explicit_malformed');
308
+ assert.match(malformed.reason, /must contain exactly one supported Status field/);
309
+
310
+ fs.rmSync(path.join(feature, 'task-01-fenced-only.md'));
311
+ writeTask('task-01-dependent.md', 'Task 01: dependent', 'pending', 'task-02-predecessor.md');
312
+ fs.writeFileSync(path.join(feature, 'task-02-predecessor.md'), [
313
+ '# Task 02', '', 'Status: pending', '', '## Dependencies', '', '- none', '',
314
+ '## Verification Plan', '', '- Command: node --test', '',
315
+ '~~~~markdown', 'Status: done', '## Dependencies', '- task-01-dependent.md', '## Receipt',
316
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0', '```text', 'fake pass', '```', '~~~~', '',
317
+ ].join('\n'));
318
+ const result = runState();
319
+ assert.equal(result.status, 0, result.stderr);
320
+ assert.match(result.stdout, /Next unblocked: `task-02-predecessor\.md`/);
321
+ assert.doesNotMatch(result.stdout, /Next unblocked: `task-01-dependent\.md`/);
322
+ });
323
+ });
324
+
325
+ test('Codex resolver accepts unique legacy task numbers, rejects ambiguity, and rejects exact cycles', () => {
326
+ inInstalledProcessSpecStateFixture(({ feature, root, writePlan, writeTask }) => {
327
+ const resolver = require(path.join(root, '.codex', 'scripts', 'spec-resolver.cjs'));
328
+ writePlan('# Auth plan\n');
329
+ writeTask('task-01-a.md', 'Task 01: a', 'pending');
330
+ writeTask('task-02-b.md', 'Task 02: b', 'pending', '01');
331
+ fs.writeFileSync(path.join(feature, 'task-03-c.md'), '# Task 03: c\n\nStatus: pending\n');
332
+ const unique = resolver.resolveWorkflowCandidate({ projectRoot: root, runtime: {}, explicitFeature: 'auth' });
333
+ assert.deepEqual(unique.taskRegistry['task-02-b.md'].dependencies, ['task-01-a.md']);
334
+ assert.deepEqual(unique.taskRegistry['task-03-c.md'].dependencies, []);
335
+ assert.equal(unique.workflowContract, null);
336
+ assert.equal(unique.queueReady, false);
337
+
338
+ writeTask('task-01-second.md', 'Task 01: second', 'pending');
339
+ const ambiguous = resolver.resolveWorkflowCandidate({ projectRoot: root, runtime: {}, explicitFeature: 'auth' });
340
+ assert.equal(ambiguous.error, 'explicit_malformed');
341
+ assert.match(ambiguous.reason, /legacy dependency 01 maps to 2 task basenames/);
342
+
343
+ fs.rmSync(path.join(feature, 'task-01-second.md'));
344
+ fs.rmSync(path.join(feature, 'task-03-c.md'));
345
+ writePlan('# Auth plan\nSpecs-Contract: process-first-ready-v1\n');
346
+ writeTask('task-01-a.md', 'Task 01: a', 'pending', 'task-02-b.md');
347
+ writeTask('task-02-b.md', 'Task 02: b', 'pending', 'task-01-a.md');
348
+ const cycle = resolver.resolveWorkflowCandidate({ projectRoot: root, runtime: {}, explicitFeature: 'auth' });
349
+ assert.equal(cycle.error, 'explicit_malformed');
350
+ assert.match(cycle.reason, /dependency cycle detected: task-01-a\.md -> task-02-b\.md -> task-01-a\.md/);
351
+ });
352
+ });
353
+
131
354
  test('Codex privacy approval is exact, session-bound, and reusable within its window', () => {
132
355
  inHookFixture((root, hooks) => {
133
356
  const nested = path.join(root, 'packages', 'app');
@@ -414,6 +637,165 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
414
637
  });
415
638
  });
416
639
 
640
+ test('Codex completion gate validates explicit process-v3 inline Receipt', () => {
641
+ inHookFixture((root, hooks) => {
642
+ const nested = path.join(root, 'packages', 'app');
643
+ const feature = path.join(root, 'specs', 'auth');
644
+ const taskFile = path.join(feature, 'task-01-auth.md');
645
+ fs.mkdirSync(nested, { recursive: true });
646
+ fs.mkdirSync(feature, { recursive: true });
647
+ fs.writeFileSync(path.join(feature, 'plan.md'), '# Auth plan\n');
648
+ fs.writeFileSync(taskFile, [
649
+ '# Task 01: auth', '', 'Status: done', '',
650
+ '## Dependencies', '', '- none', '',
651
+ '## Verification Plan', '', '- Command: node --test', '',
652
+ ].join('\n'));
653
+
654
+ const gate = path.join(hooks, 'spec-gate.cjs');
655
+ const payload = {
656
+ cwd: nested,
657
+ featureName: 'auth',
658
+ session_id: 'session-a',
659
+ hook_event_name: 'Stop',
660
+ stop_hook_active: false,
661
+ };
662
+ const blocked = runHook(gate, nested, payload);
663
+ assert.equal(blocked.status, 0, blocked.stderr);
664
+ assert.match(JSON.parse(blocked.stdout).reason, /missing_receipt/);
665
+
666
+ const context = workflowRuntimeContext(root);
667
+ fs.writeFileSync(taskFile, [
668
+ '# Task 01: auth', '', 'Status: done', '',
669
+ '## Dependencies', '', '- none', '',
670
+ '## Verification Plan', '', '- Command: node --test', '',
671
+ '## Receipt', '', '```text', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
672
+ `Base: ${context.base}`, `Head: ${context.head}`, 'pass: 1', '```', '',
673
+ ].join('\n'));
674
+ const fencedFields = runHook(gate, nested, payload);
675
+ assert.equal(fencedFields.status, 0, fencedFields.stderr);
676
+ assert.match(JSON.parse(fencedFields.stdout).reason, /verification_state/);
677
+ assert.match(JSON.parse(fencedFields.stdout).reason, /command/);
678
+ assert.match(JSON.parse(fencedFields.stdout).reason, /provenance/);
679
+
680
+ fs.writeFileSync(taskFile, [
681
+ '# Task 01: auth', '', 'Status: done', '',
682
+ '## Dependencies', '', '- none', '',
683
+ '## Verification Plan', '', '- Command: node --test', '',
684
+ '## Receipt', '',
685
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0',
686
+ `Base: ${context.base}`, `Head: ${context.head}`,
687
+ '````text', '$ node --test', 'pass: 1', '```', '',
688
+ ].join('\n'));
689
+ const malformedOutputFence = runHook(gate, nested, payload);
690
+ assert.equal(malformedOutputFence.status, 0, malformedOutputFence.stderr);
691
+ assert.match(JSON.parse(malformedOutputFence.stdout).reason, /command_output/);
692
+
693
+ fs.writeFileSync(taskFile, [
694
+ '# Task 01: auth', '', 'Status: done', '',
695
+ '## Dependencies', '', '- none', '',
696
+ '## Verification Plan', '', '- Command: node --test', '',
697
+ '## Receipt', '',
698
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0',
699
+ `Base: ${context.base}`, `Head: ${context.head}`,
700
+ '```text', '$ node --test', 'pass: 1', '```', '',
701
+ ].join('\n'));
702
+ const passed = runHook(gate, nested, payload);
703
+ assert.equal(passed.status, 0, passed.stderr);
704
+ assert.equal(passed.stdout, '');
705
+ });
706
+ });
707
+
708
+ test('Codex process-v3 Receipt command must match the exact Verification Plan command', () => {
709
+ inHookFixture((root, hooks) => {
710
+ const feature = path.join(root, 'specs', 'auth');
711
+ fs.mkdirSync(feature, { recursive: true });
712
+ fs.writeFileSync(path.join(feature, 'plan.md'), '# Auth plan\n');
713
+ const context = workflowRuntimeContext(root);
714
+ fs.writeFileSync(path.join(feature, 'task-01-auth.md'), [
715
+ '# Task 01: auth', '', 'Status: done', '',
716
+ '## Dependencies', '', '- none', '',
717
+ '## Verification Plan', '', '- Command: pnpm test', '',
718
+ '## Receipt', '', 'Verification: PASS', 'Command: true', 'Exit: 0',
719
+ `Base: ${context.base}`, `Head: ${context.head}`,
720
+ '```text', '$ true', 'pass: 1', '```', '',
721
+ ].join('\n'));
722
+
723
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
724
+ cwd: root,
725
+ session_id: 'session-a',
726
+ hook_event_name: 'Stop',
727
+ stop_hook_active: false,
728
+ });
729
+ assert.equal(result.status, 0, result.stderr);
730
+ assert.notEqual(result.stdout, '', 'a substituted receipt command must block');
731
+ assert.match(JSON.parse(result.stdout).reason, /command_identity/);
732
+ });
733
+ });
734
+
735
+ test('Codex process-v3 Stop ignores a completed packet when one packet remains active', () => {
736
+ inHookFixture((root, hooks) => {
737
+ const completed = path.join(root, 'specs', 'auth');
738
+ const active = path.join(root, 'specs', 'active');
739
+ fs.mkdirSync(completed, { recursive: true });
740
+ fs.mkdirSync(active, { recursive: true });
741
+ fs.writeFileSync(path.join(completed, 'plan.md'), '# Auth plan\n');
742
+ fs.writeFileSync(path.join(active, 'plan.md'), '# Active plan\n');
743
+ const context = workflowRuntimeContext(root);
744
+ fs.writeFileSync(path.join(completed, 'task-01-auth.md'), [
745
+ '# Task 01: auth', '', 'Status: done', '',
746
+ '## Dependencies', '', '- none', '',
747
+ '## Verification Plan', '', '- Command: node --test', '',
748
+ '## Receipt', '',
749
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0',
750
+ `Base: ${context.base}`, `Head: ${context.head}`,
751
+ '```text', '$ node --test', 'pass: 1', '```', '',
752
+ ].join('\n'));
753
+ fs.writeFileSync(path.join(active, 'task-01-active.md'), [
754
+ '# Task 01: active', '', 'Status: pending', '',
755
+ '## Dependencies', '', '- none', '',
756
+ '## Verification Plan', '', '- Command: node --test', '',
757
+ ].join('\n'));
758
+
759
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
760
+ cwd: root,
761
+ session_id: 'session-a',
762
+ hook_event_name: 'Stop',
763
+ stop_hook_active: false,
764
+ });
765
+ assert.equal(result.status, 0, result.stderr);
766
+ assert.equal(result.stdout, '');
767
+ });
768
+ });
769
+
770
+ test('Codex process-v3 Stop accepts two completed packets with valid Receipts', () => {
771
+ inHookFixture((root, hooks) => {
772
+ for (const featureName of ['auth', 'other']) {
773
+ const feature = path.join(root, 'specs', featureName);
774
+ fs.mkdirSync(feature, { recursive: true });
775
+ fs.writeFileSync(path.join(feature, 'plan.md'), `# ${featureName} plan\n`);
776
+ const context = workflowRuntimeContext(root, featureName);
777
+ fs.writeFileSync(path.join(feature, `task-01-${featureName}.md`), [
778
+ `# Task 01: ${featureName}`, '', 'Status: done', '',
779
+ '## Dependencies', '', '- none', '',
780
+ '## Verification Plan', '', '- Command: node --test', '',
781
+ '## Receipt', '',
782
+ 'Verification: PASS', 'Command: node --test', 'Exit: 0',
783
+ `Base: ${context.base}`, `Head: ${context.head}`,
784
+ '```text', '$ node --test', 'pass: 1', '```', '',
785
+ ].join('\n'));
786
+ }
787
+
788
+ const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
789
+ cwd: root,
790
+ session_id: 'session-a',
791
+ hook_event_name: 'Stop',
792
+ stop_hook_active: false,
793
+ });
794
+ assert.equal(result.status, 0, result.stderr);
795
+ assert.equal(result.stdout, '');
796
+ });
797
+ });
798
+
417
799
  test('empty or forged Codex hook authority fails closed', () => {
418
800
  inHookFixture((root, hooks) => {
419
801
  const gate = path.join(hooks, 'spec-gate.cjs');