@haposoft/cafekit 0.16.0-rc.3 → 0.16.0-rc.5

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 (98) hide show
  1. package/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
  2. package/bin/__tests__/codex-hooks.test.js +531 -29
  3. package/bin/__tests__/codex-native.test.js +228 -7
  4. package/bin/__tests__/develop-contract.test.js +653 -185
  5. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
  6. package/bin/__tests__/installer-safety.test.js +39 -0
  7. package/bin/__tests__/opencode-spec-gate.test.js +24 -16
  8. package/bin/__tests__/package-inventory.test.js +705 -13
  9. package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
  10. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
  11. package/bin/__tests__/specs-v2-execution-closeout.test.js +468 -0
  12. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +665 -0
  13. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +1132 -0
  14. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  15. package/bin/__tests__/validator-grounding.test.js +2013 -221
  16. package/bin/lib/managed-writer.js +12 -3
  17. package/bin/phases/codex-runtime.js +17 -1
  18. package/package.json +5 -2
  19. package/src/claude/CLAUDE.md +6 -0
  20. package/src/claude/agents/code-auditor.md +20 -0
  21. package/src/claude/agents/spec-maker.md +143 -99
  22. package/src/claude/hooks/__tests__/completion-authority.test.js +321 -58
  23. package/src/claude/hooks/__tests__/privacy-block.test.js +254 -0
  24. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  25. package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
  26. package/src/claude/hooks/completion-authority-check.cjs +37 -288
  27. package/src/claude/hooks/completion-authority-state.cjs +10 -8
  28. package/src/claude/hooks/completion-authority.cjs +13 -7
  29. package/src/claude/hooks/docs-sync.cjs +5 -2
  30. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  31. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  32. package/src/claude/hooks/privacy-block.cjs +99 -28
  33. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  34. package/src/claude/hooks/spec-gate.cjs +47 -117
  35. package/src/claude/hooks/spec-state.cjs +8 -2
  36. package/src/claude/migration-manifest.json +9 -1
  37. package/src/claude/rules/state-sync.md +52 -21
  38. package/src/claude/rules/workflow.md +17 -9
  39. package/src/claude/scripts/change-firewall.cjs +1748 -0
  40. package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
  41. package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
  42. package/src/claude/scripts/spec-final-state.cjs +290 -0
  43. package/src/claude/scripts/spec-ground.cjs +571 -173
  44. package/src/claude/scripts/spec-readiness.cjs +425 -0
  45. package/src/claude/scripts/spec-receipt.cjs +162 -0
  46. package/src/claude/scripts/spec-resolver.cjs +67 -16
  47. package/src/claude/scripts/spec-scaffold.cjs +1594 -139
  48. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  49. package/src/claude/scripts/validate-spec-output.cjs +3110 -218
  50. package/src/claude/scripts/workflow-policy.cjs +501 -183
  51. package/src/claude/settings/settings.json +4 -0
  52. package/src/claude/skills/code-review/SKILL.md +12 -3
  53. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  54. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  55. package/src/claude/skills/develop/SKILL.md +47 -60
  56. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  57. package/src/claude/skills/develop/references/quality-gate.md +19 -12
  58. package/src/claude/skills/specs/SKILL.md +183 -177
  59. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  60. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  61. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  62. package/src/claude/skills/specs/references/review.md +214 -262
  63. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  64. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  65. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  66. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  67. package/src/claude/skills/specs/rules/design-review.md +42 -97
  68. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  69. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  70. package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
  71. package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
  72. package/src/claude/skills/specs/templates/design.md +49 -263
  73. package/src/claude/skills/specs/templates/requirements.md +26 -36
  74. package/src/claude/skills/specs/templates/research.md +9 -98
  75. package/src/claude/skills/specs/templates/spec-state.json +35 -85
  76. package/src/claude/skills/specs/templates/task.md +30 -102
  77. package/src/claude/skills/sync/SKILL.md +33 -10
  78. package/src/claude/skills/sync/references/sync-protocols.md +31 -13
  79. package/src/claude/skills/test/SKILL.md +17 -7
  80. package/src/codex/AGENTS.md +6 -0
  81. package/src/codex/hooks/completion-authority-check.cjs +43 -240
  82. package/src/codex/hooks/completion-authority-state.cjs +10 -8
  83. package/src/codex/hooks/completion-authority.cjs +13 -7
  84. package/src/codex/hooks/lib/hook-context.cjs +16 -2
  85. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  86. package/src/codex/hooks/lib/privacy-state.cjs +61 -38
  87. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  88. package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
  89. package/src/codex/hooks/lib/spec-utils.cjs +56 -5
  90. package/src/codex/hooks/privacy-approval.cjs +6 -2
  91. package/src/codex/hooks/privacy-block.cjs +94 -15
  92. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  93. package/src/codex/hooks/spec-gate.cjs +49 -34
  94. package/src/codex/hooks/spec-state.cjs +10 -3
  95. package/src/codex/hooks/state.cjs +2 -0
  96. package/src/codex/hooks.json +6 -1
  97. package/src/codex/rules/state-sync.md +47 -27
  98. package/src/common/AGENTS.md +1 -1
@@ -12,7 +12,16 @@ const PACKAGE_ROOT = path.join(__dirname, '../..');
12
12
  const POLICY_PATH = path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs');
13
13
  const POLICY = require(POLICY_PATH);
14
14
  const PROVENANCE_HELPER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'));
15
+ const FINAL_STATE = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-final-state.cjs'));
16
+ const VALIDATOR = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/validate-spec-output.cjs'));
17
+ const GROUNDER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-ground.cjs'));
18
+ const SEMANTIC_AUTHORITY = require(path.join(PACKAGE_ROOT, 'src/claude/hooks/semantic-review-authority.cjs'));
19
+ const READINESS = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-readiness.cjs'));
20
+ const AUTHORING_VALIDATOR = path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-authoring-validation.cjs');
21
+ const { copyClaudeTestRuntime } = require('./test-runtime-dependency-closure.cjs');
15
22
  const DEVELOP = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/SKILL.md');
23
+ const SPECS = path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md');
24
+ const SCAFFOLD = path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-scaffold.cjs');
16
25
  const GATE = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/references/quality-gate.md');
17
26
  const TEST_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/test/SKILL.md');
18
27
  const SYNC_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/sync/SKILL.md');
@@ -49,6 +58,193 @@ function initFixtureGit(root) {
49
58
  }
50
59
  }
51
60
 
61
+ function installClaudeRuntimeClosure(fixtureRoot, entry = 'hooks/spec-gate.cjs') {
62
+ const destinationRoot = path.join(fixtureRoot, '.claude');
63
+ copyClaudeTestRuntime(PACKAGE_ROOT, destinationRoot);
64
+ const hooksRoot = path.join(PACKAGE_ROOT, 'src', 'claude', 'hooks');
65
+ for (const hook of [
66
+ 'spec-gate.cjs',
67
+ 'completion-authority-check.cjs',
68
+ 'completion-authority-state.cjs',
69
+ 'semantic-review-authority.cjs',
70
+ ]) {
71
+ const target = path.join(destinationRoot, 'hooks', hook);
72
+ fs.mkdirSync(path.dirname(target), { recursive: true });
73
+ fs.copyFileSync(path.join(hooksRoot, hook), target);
74
+ }
75
+ return path.join(destinationRoot, entry);
76
+ }
77
+
78
+ function markdownSection(content, heading) {
79
+ const marker = `## ${heading}`;
80
+ const start = content.indexOf(marker);
81
+ assert.notEqual(start, -1, `missing section ${heading}`);
82
+ const rest = content.slice(start + marker.length);
83
+ const end = rest.search(/\n## /);
84
+ return end === -1 ? rest : rest.slice(0, end);
85
+ }
86
+
87
+ function markdownTable(section, firstHeader) {
88
+ const lines = section.split('\n').filter((line) => line.trim().startsWith('|'));
89
+ const cells = (line) => line.split('|').slice(1, -1).map((cell) => cell.trim());
90
+ const headerIndex = lines.findIndex((line) => cells(line)[0] === firstHeader);
91
+ assert.notEqual(headerIndex, -1, `missing table headed by ${firstHeader}`);
92
+ const headers = cells(lines[headerIndex]);
93
+ const rows = {};
94
+ for (const line of lines.slice(headerIndex + 2)) {
95
+ const values = cells(line);
96
+ if (values.length !== headers.length) break;
97
+ rows[values[0].replaceAll('`', '')] = Object.fromEntries(headers.map((header, index) => [header, values[index]]));
98
+ }
99
+ return rows;
100
+ }
101
+
102
+ function runNode(root, script, args) {
103
+ return spawnSync(process.execPath, [script, ...args], { cwd: root, encoding: 'utf8' });
104
+ }
105
+
106
+ function commandOutput(result) {
107
+ return `${result.stdout}\n${result.stderr}`;
108
+ }
109
+
110
+ function createCanonicalAuthoringFixture() {
111
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-develop-contract-'));
112
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
113
+ fs.mkdirSync(path.join(root, 'test'), { recursive: true });
114
+ fs.mkdirSync(path.join(root, 'artifacts'), { recursive: true });
115
+ fs.writeFileSync(path.join(root, 'src', 'entry.js'), 'module.exports = { enabled: true };\n');
116
+ fs.writeFileSync(path.join(root, 'test', 'service.test.js'), 'require("node:test")("service", () => {});\n');
117
+ const scaffold = runNode(root, SCAFFOLD, ['feature']);
118
+ assert.equal(scaffold.status, 0, commandOutput(scaffold));
119
+ const specDir = path.join(root, 'specs', 'feature');
120
+ const specPath = path.join(specDir, 'spec.json');
121
+ fs.writeFileSync(path.join(specDir, 'requirements.md'), `# Requirements
122
+
123
+ ## Requirements
124
+ ### Requirement 1: Observable service behavior
125
+ - **R1.1**: When valid input arrives, the service shall return enabled state; invalid input shall return rejected state.
126
+ - **R1.2**: The verifier shall produce separate observable proof for enabled and rejected states.
127
+ `);
128
+ fs.writeFileSync(path.join(specDir, 'design.md'), `# Design
129
+
130
+ ## Architecture
131
+ The entrypoint exposes the bounded service behavior and a separate proof artifact.
132
+
133
+ ## Typed Anchors
134
+ | ID | Type | Target | Role | Access | Action |
135
+ |---|---|---|---|---|---|
136
+ | A-D-01 | file | \`src/entry.js\` | service boundary | read | read |
137
+ | A-D-02 | artifact | \`artifacts/design-proof.json\` | proof evidence | write | create |
138
+
139
+ ## Canonical Contracts & Invariants
140
+ ### D1 — Dispatch decision
141
+ The entrypoint dispatches exactly once.
142
+ ### I1 — Invalid-state invariant
143
+ Invalid input never returns enabled state.
144
+ ### C1 — Result contract
145
+ The result contains one enabled boolean.
146
+
147
+ ## Verification Definitions
148
+ - **V1**: Subject criteria R1.1; Subject owner A-D-01; Proof criteria R1.2; Proof owner A-D-02; Evidence anchor A-D-02; Decision refs D1, I1, C1; Method command \`node --test test/service.test.js\`; Expected exit 0 with enabled state and separate proof evidence; Negative/failure invalid input returns rejected state without enabled output; Reachability/grounding entrypoint \`src/entry.js\` via A-D-01, A-D-02.
149
+ `);
150
+ return { root, specDir, specPath };
151
+ }
152
+
153
+ function createOrdinaryAuthoringFixture() {
154
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-ordinary-readiness-'));
155
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
156
+ fs.mkdirSync(path.join(root, 'test'), { recursive: true });
157
+ fs.writeFileSync(path.join(root, 'src', 'entry.js'), 'module.exports = { action: "delete" };\n');
158
+ fs.writeFileSync(path.join(root, 'test', 'entry.test.js'), 'require("node:test")("entry", () => {});\n');
159
+ const scaffold = runNode(root, SCAFFOLD, ['ordinary']);
160
+ assert.equal(scaffold.status, 0, commandOutput(scaffold));
161
+ const specDir = path.join(root, 'specs', 'ordinary');
162
+ const specPath = path.join(specDir, 'spec.json');
163
+ fs.writeFileSync(path.join(specDir, 'requirements.md'), `# Requirements
164
+
165
+ ## Requirements
166
+ ### Requirement 1: Delete behavior
167
+ - **R1.1**: When deletion is requested, the service shall delete the selected record and report deleted state.
168
+ `);
169
+ fs.writeFileSync(path.join(specDir, 'design.md'), `# Design
170
+
171
+ ## Typed Anchors
172
+ | ID | Type | Target | Role | Access | Action |
173
+ |---|---|---|---|---|---|
174
+ | A-D-01 | file | \`src/entry.js\` | behavior owner | read | read |
175
+
176
+ ## Canonical Contracts & Invariants
177
+ ### D1 — Delete decision
178
+ The selected record is deleted rather than archived.
179
+ ### I1 — State invariant
180
+ Deleted state is reported only after deletion succeeds.
181
+ ### C1 — Result contract
182
+ The result contains the literal deleted state.
183
+
184
+ ## Verification Definitions
185
+ - **V1**: Criteria R1.1; Owner A-D-01; Decision refs D1, I1, C1; Method command \`node --test test/entry.test.js\`; Expected exit 0 and the service reports deleted state; Negative/failure a missing record returns not-found without deleted state; Reachability/grounding entrypoint \`src/entry.js\` via A-D-01.
186
+ `);
187
+ const semantic = runNode(root, SCAFFOLD, ['ordinary', '--sync-semantic-model']);
188
+ assert.equal(semantic.status, 0, commandOutput(semantic));
189
+ const authoring = runNode(root, AUTHORING_VALIDATOR, [specDir]);
190
+ assert.equal(authoring.status, 0, commandOutput(authoring));
191
+ initFixtureGit(root);
192
+ return { root, specDir, specPath };
193
+ }
194
+
195
+ function ordinaryReview() {
196
+ return canonicalPassReview(['R1.1'], [{
197
+ criterion: 'R1.1', case_kind: 'failure',
198
+ scenario: 'Deletion targets a record that does not exist in the current store.',
199
+ expected: 'The service returns not-found and never reports the record as deleted.',
200
+ decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
201
+ }]);
202
+ }
203
+
204
+ function canonicalPassReview(reviewedCriteria, counterexamples) {
205
+ return {
206
+ verdict: 'PASS',
207
+ findings: [],
208
+ unresolved_decisions: [],
209
+ graph_coverage: [
210
+ 'criterion_local', 'cross_criterion', 'runtime_path',
211
+ 'assumption_provenance', 'compatibility_migration',
212
+ ].map((surface) => ({
213
+ surface, covered: true,
214
+ notes: 'The review covers this semantic surface against the canonical model.',
215
+ })),
216
+ reviewed_criteria: reviewedCriteria,
217
+ counterexamples,
218
+ reviewer_evidence: null,
219
+ };
220
+ }
221
+
222
+ function promoteCanonicalModel(fixture) {
223
+ const result = runNode(fixture.root, SCAFFOLD, ['feature', '--sync-semantic-model']);
224
+ assert.equal(result.status, 0, commandOutput(result));
225
+ }
226
+
227
+ function makeCanonicalReady(fixture) {
228
+ const authoring = runNode(fixture.root, AUTHORING_VALIDATOR, [fixture.specDir]);
229
+ assert.equal(authoring.status, 0, commandOutput(authoring));
230
+ const result = READINESS.finalizeReadiness({
231
+ specDir: fixture.specDir,
232
+ projectRoot: fixture.root,
233
+ reviewResult: canonicalPassReview(['R1.1', 'R1.2'], [{
234
+ criterion: 'R1.1', case_kind: 'failure',
235
+ scenario: 'The service receives invalid input without its required discriminator.',
236
+ expected: 'The service returns rejected state and never emits enabled true.',
237
+ decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
238
+ }, {
239
+ criterion: 'R1.2', case_kind: 'adversarial',
240
+ scenario: 'The service reports enabled state without producing separate proof evidence.',
241
+ expected: 'Verification fails because the independent proof artifact is absent.',
242
+ decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
243
+ }]),
244
+ });
245
+ return result.spec;
246
+ }
247
+
52
248
  function bindFixtureReceipt(root, value, feature = 'demo', session = 'test') {
53
249
  const context = PROVENANCE_HELPER.deriveRuntimeContext({
54
250
  projectRoot: root,
@@ -121,7 +317,7 @@ test('delegation plan consumes legacy tiers as obligations without an agent chai
121
317
  const standard = POLICY.delegationPlan({ tier: 'Standard', mode: 'specific-task' });
122
318
  const deep = POLICY.delegationPlan({ tier: 'Deep', mode: 'full-spec', taskCount: 2 });
123
319
  assert.deepEqual(light.proof_obligations, ['needsExecutionProof']);
124
- assert.deepEqual(standard.proof_obligations, ['needsInspection', 'needsExecutionProof']);
320
+ assert.deepEqual(standard.proof_obligations, ['needsExecutionProof']);
125
321
  assert.deepEqual(deep.proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit']);
126
322
  for (const plan of [light, standard, deep]) {
127
323
  assert.equal(Object.hasOwn(plan, 'delegated'), false);
@@ -144,31 +340,53 @@ test('lane classifier selects Direct for explicit reversible low-risk work', ()
144
340
  assert.equal(policy.proof_obligations.includes('needsDurableTaskState'), false);
145
341
  });
146
342
 
147
- test('lane classifier forces Critical for named high-risk signals', () => {
343
+ test('lane classifier raises named risks to Elevated without inventing independent-audit authority', () => {
148
344
  const result = POLICY.classifyLane({
149
345
  reversible: true,
150
346
  riskSignals: { auth: true, migration: true, publicContract: true },
151
347
  });
152
- assert.equal(result.lane, 'Critical');
348
+ assert.equal(result.lane, 'Standard');
349
+ assert.equal(result.assuranceLevel, 'Elevated');
350
+ assert.equal(result.automaticAssuranceLevel, 'Elevated');
153
351
  assert.deepEqual(result.risks, ['auth', 'migration', 'publicContract']);
154
- assert.deepEqual(POLICY.delegationPlan({ lane: 'Critical' }).proof_obligations, [
155
- 'needsInspection', 'needsExecutionProof', 'needsIndependentAudit',
352
+ assert.deepEqual(POLICY.lanePolicy(result).proof_obligations, [
353
+ 'needsInspection', 'needsExecutionProof',
156
354
  ]);
355
+
356
+ const strict = POLICY.classifyLane({
357
+ riskSignals: { auth: true },
358
+ assurance_level: 'Strict',
359
+ });
360
+ assert.equal(strict.lane, 'Critical');
361
+ assert.equal(strict.automaticLane, 'Standard');
362
+ assert.equal(strict.assuranceLevel, 'Strict');
363
+ assert.equal(strict.automaticAssuranceLevel, 'Elevated');
364
+ assert.ok(POLICY.lanePolicy(strict).proof_obligations.includes('needsIndependentAudit'));
157
365
  });
158
366
 
159
- test('P1 persists one strict workflow-policy snapshot and rejects malformed snapshots', () => {
367
+ test('P1 persists the minimal v2.1 policy and derives compatibility views', () => {
160
368
  const initial = POLICY.persistWorkflowPolicySnapshot(
161
- { feature_name: 'bounded', override_receipt: 'legacy-duplicate' },
369
+ { feature_name: 'bounded' },
162
370
  { riskSignals: {} },
163
371
  );
164
372
  const snapshot = initial.workflow_policy;
165
- assert.deepEqual(Object.keys(snapshot).sort(), [...POLICY.WORKFLOW_POLICY_FIELDS].sort());
166
- assert.equal(snapshot.version, '1');
167
- assert.equal(snapshot.lane, 'Standard');
168
- assert.equal(snapshot.automatic_lane, 'Standard');
169
- assert.equal(snapshot.artifact_profile, 'bounded');
170
- assert.deepEqual(snapshot.proof_obligations, ['needsInspection', 'needsExecutionProof']);
171
- assert.equal(Object.hasOwn(initial, 'override_receipt'), false);
373
+ assert.deepEqual(Object.keys(snapshot).sort(), [...POLICY.CANONICAL_WORKFLOW_POLICY_FIELDS].sort());
374
+ assert.equal(snapshot.version, POLICY.CANONICAL_WORKFLOW_POLICY_VERSION);
375
+ assert.equal(snapshot.planning_depth, 'Compact');
376
+ assert.equal(snapshot.assurance_level, 'Routine');
377
+ assert.deepEqual(snapshot.classified_minimum, {
378
+ planning_depth: 'Compact',
379
+ assurance_level: 'Routine',
380
+ });
381
+ for (const derivedField of ['lane', 'automatic_lane', 'artifact_profile', 'proof_obligations', 'actor_needs']) {
382
+ assert.equal(Object.hasOwn(snapshot, derivedField), false, `${derivedField} must not be persisted`);
383
+ }
384
+ const view = POLICY.readWorkflowPolicySnapshot(initial);
385
+ assert.equal(view.lane, 'Standard');
386
+ assert.equal(view.automatic_lane, 'Standard');
387
+ assert.equal(view.artifact_profile, 'bounded');
388
+ assert.deepEqual(view.proof_obligations, ['needsExecutionProof']);
389
+ assert.equal(Object.hasOwn(initial, 'override_' + 'receipt'), false);
172
390
  assert.equal(POLICY.validateWorkflowPolicySnapshot(snapshot).valid, true);
173
391
 
174
392
  const persistedAgain = POLICY.persistWorkflowPolicySnapshot(initial, {
@@ -177,16 +395,16 @@ test('P1 persists one strict workflow-policy snapshot and rejects malformed snap
177
395
  });
178
396
  assert.deepEqual(persistedAgain.workflow_policy, snapshot, 'persist-once must not reclassify an existing snapshot');
179
397
 
180
- const malformed = { ...snapshot, artifact_profile: 'strict', extra: true };
398
+ const malformed = { ...snapshot, lane: 'Critical' };
181
399
  const validation = POLICY.validateWorkflowPolicySnapshot(malformed);
182
400
  assert.equal(validation.valid, false);
183
- assert.match(validation.errors.join('; '), /fields must be exactly|artifact_profile/);
401
+ assert.match(validation.errors.join('; '), /v2\.1 fields must be exactly/);
184
402
  assert.throws(() => POLICY.readWorkflowPolicySnapshot({ workflow_policy: malformed }), /Invalid workflow policy snapshot/);
185
403
 
186
- const malformedShape = { ...snapshot, proof_obligations: { needsExecutionProof: true } };
404
+ const malformedShape = { ...snapshot, classified_minimum: { planning_depth: 'Compact' } };
187
405
  const shapeValidation = POLICY.validateWorkflowPolicySnapshot(malformedShape);
188
406
  assert.equal(shapeValidation.valid, false);
189
- assert.match(shapeValidation.errors.join('; '), /proof_obligations must be an array/);
407
+ assert.match(shapeValidation.errors.join('; '), /classified_minimum must contain exactly/);
190
408
 
191
409
  const malformedRisks = { ...snapshot, risks: { auth: true } };
192
410
  const risksValidation = POLICY.validateWorkflowPolicySnapshot(malformedRisks);
@@ -197,26 +415,34 @@ test('P1 persists one strict workflow-policy snapshot and rejects malformed snap
197
415
  test('P1 workflow-policy validation is semantic and explicit workflow_policy values never reclassify', () => {
198
416
  const direct = POLICY.workflowPolicySnapshot({ reversible: true, lowRisk: true, isolated: true });
199
417
  const standardUnknown = POLICY.escalateWorkflowPolicy(direct, { risks: ['unclassified-risk'] });
200
- const criticalAuth = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
201
- const escalatedCritical = POLICY.escalateWorkflowPolicy(direct, { risks: ['auth'] });
418
+ const elevatedAuth = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
419
+ const escalatedElevated = POLICY.escalateWorkflowPolicy(direct, { risks: ['auth'] });
202
420
 
203
- for (const legitimate of [direct, standardUnknown, criticalAuth, escalatedCritical]) {
421
+ for (const legitimate of [direct, standardUnknown, elevatedAuth, escalatedElevated]) {
204
422
  assert.equal(POLICY.validateWorkflowPolicySnapshot(legitimate).valid, true);
205
423
  }
206
- assert.equal(escalatedCritical.lane, 'Critical');
207
- assert.equal(escalatedCritical.automatic_lane, 'Direct');
424
+ assert.equal(escalatedElevated.lane, 'Standard');
425
+ assert.equal(escalatedElevated.automatic_lane, 'Standard');
426
+ assert.equal(escalatedElevated.planning_depth, 'None');
427
+ assert.equal(escalatedElevated.assurance_level, 'Elevated');
208
428
 
209
429
  const forgedDirectAuth = { ...direct, risks: ['auth'] };
210
430
  const forgedDirectUnknown = { ...direct, risks: ['unclassified-risk'] };
211
431
  const forgedStandardAuth = { ...standardUnknown, risks: ['auth'] };
212
- for (const forged of [forgedDirectAuth, forgedDirectUnknown, forgedStandardAuth]) {
432
+ for (const forged of [forgedDirectAuth, forgedDirectUnknown]) {
213
433
  const result = POLICY.validateWorkflowPolicySnapshot(forged);
214
434
  assert.equal(result.valid, false);
215
- assert.match(result.errors.join('; '), /at least (Standard|Critical)/);
435
+ assert.match(result.errors.join('; '), /at least (Elevated|Strict)/);
216
436
  }
437
+ assert.equal(
438
+ POLICY.validateWorkflowPolicySnapshot(forgedStandardAuth).valid,
439
+ true,
440
+ 'changing one normalized risk to another does not invent a higher assurance minimum',
441
+ );
217
442
  for (const riskAlias of ['Auth', 'authentication', 'public_contract']) {
218
443
  const escalated = POLICY.escalateWorkflowPolicy(direct, { risks: [riskAlias] });
219
- assert.equal(escalated.lane, 'Critical', `${riskAlias} must classify as Critical`);
444
+ assert.equal(escalated.lane, 'Standard', `${riskAlias} must classify as Elevated`);
445
+ assert.equal(escalated.assurance_level, 'Elevated');
220
446
  assert.equal(POLICY.validateWorkflowPolicySnapshot({ ...direct, risks: [riskAlias] }).valid, false);
221
447
  }
222
448
  assert.throws(() => POLICY.assertWorkflowPolicySnapshot(forgedDirectAuth), /Invalid workflow policy snapshot/);
@@ -278,23 +504,33 @@ test('P1 workflow policy escalation is monotonic across all newly classified ris
278
504
 
279
505
  const standard = POLICY.escalateWorkflowPolicy(direct, { risks: ['new-standard-risk'], riskLevel: 'standard' });
280
506
  assert.equal(standard.lane, 'Standard');
281
- assert.equal(standard.artifact_profile, 'bounded');
507
+ assert.equal(standard.artifact_profile, 'targeted');
508
+ assert.equal(standard.planning_depth, 'None');
509
+ assert.equal(standard.assurance_level, 'Elevated');
282
510
  assert.equal(standard.risks.includes('new-standard-risk'), true);
283
511
  assert.deepEqual(standard.proof_obligations, ['needsInspection', 'needsExecutionProof']);
284
512
 
285
- const ambiguityCritical = POLICY.escalateWorkflowPolicy(direct, { riskSignals: { ambiguity: true } });
286
- assert.equal(ambiguityCritical.lane, 'Critical', 'every classified Critical risk must escalate to Critical');
513
+ const ambiguityElevated = POLICY.escalateWorkflowPolicy(direct, { riskSignals: { ambiguity: true } });
514
+ assert.equal(ambiguityElevated.lane, 'Standard', 'risk discovery must require inspection without inventing an audit');
515
+ assert.equal(ambiguityElevated.assurance_level, 'Elevated');
287
516
 
288
- const critical = POLICY.escalateWorkflowPolicy(standard, { risks: ['auth'] });
517
+ const criticalSeverity = POLICY.escalateWorkflowPolicy(direct, { risks: ['payment'], riskLevel: 'critical' });
518
+ assert.equal(criticalSeverity.assurance_level, 'Elevated', 'severity labels must not create independent-audit authority');
519
+ assert.equal(criticalSeverity.proof_obligations.includes('needsIndependentAudit'), false);
520
+
521
+ const critical = POLICY.escalateWorkflowPolicy(standard, { risks: ['auth'], assurance_level: 'Strict' });
289
522
  assert.equal(critical.lane, 'Critical');
290
- assert.equal(critical.artifact_profile, 'strict');
523
+ assert.equal(critical.artifact_profile, 'targeted');
524
+ assert.equal(critical.planning_depth, 'None');
525
+ assert.equal(critical.assurance_level, 'Strict');
526
+ assert.equal(critical.automatic_assurance_level, 'Elevated');
291
527
  assert.equal(critical.proof_obligations.includes('needsIndependentAudit'), true);
292
- assert.equal(critical.proof_obligations.includes('needsResearchGrounding'), true);
528
+ assert.equal(critical.proof_obligations.includes('needsResearchGrounding'), false);
293
529
 
294
530
  const noDowngrade = POLICY.escalateWorkflowPolicy(critical, { risks: ['another-standard-risk'], riskLevel: 'standard' });
295
531
  assert.equal(noDowngrade.lane, 'Critical');
296
532
  assert.equal(noDowngrade.proof_obligations.includes('needsIndependentAudit'), true);
297
- assert.equal(noDowngrade.proof_obligations.includes('needsResearchGrounding'), true);
533
+ assert.equal(noDowngrade.proof_obligations.includes('needsResearchGrounding'), false);
298
534
 
299
535
  const criticalAgain = POLICY.escalateWorkflowPolicy(critical, { risks: ['auth'] });
300
536
  assert.deepEqual(criticalAgain, critical, 'repeating known risks must not create a new escalation');
@@ -316,41 +552,57 @@ test('P1 legacy execution tier is read-compatible without becoming emitted polic
316
552
  assert.equal(POLICY.workflowPolicySnapshot({ design_context: { execution_tier: 'standard' } }).lane, 'Standard');
317
553
  });
318
554
 
319
- test('lane override keeps automatic result and surfaces downgrade warning', () => {
320
- const validReceipt = {
321
- automaticLane: 'Critical',
322
- requestedLane: 'Direct',
323
- risks: ['privacy'],
324
- source: 'runtime',
325
- timestamp: '2026-08-11T00:00:00.000Z',
326
- sessionId: 'test-session-123',
327
- };
328
- // P0.4 fail-closed: even shape-correct runtime receipt without verified issuer is rejected
329
- assert.throws(() => POLICY.classifyLane({
330
- reversible: true,
331
- riskSignals: { privacy: true },
332
- override: 'Direct',
333
- overrideReceipt: validReceipt,
334
- }), /is blocked.*no trusted runtime issuer/);
335
- // Also verify that a receipt with verified flag still fails if not properly bound? For now fail-closed.
336
- const forgedVerified = { ...validReceipt, verifiedByRuntime: true };
337
- // Still blocked because we require additional binding beyond shape; keep blocked until trusted issuer lands
338
- // (The verified flag makes isValidOverrideReceipt check pass lane binding, but we keep fail-closed by requiring internal issuer proof that tests don't have)
339
- // For P0 we keep downgrade blocked regardless — this assert documents fail-closed.
340
- assert.throws(() => POLICY.classifyLane({
341
- reversible: true,
342
- riskSignals: { privacy: true },
343
- override: 'Direct',
344
- overrideReceipt: forgedVerified,
345
- }), /is blocked.*no trusted runtime issuer/);
555
+ test('legacy v1 policy is read-compatible, immutable on read, and explicitly migrates to v2.1', () => {
556
+ const cases = [
557
+ { lane: 'Direct', risks: [], obligations: ['needsExecutionProof'] },
558
+ { lane: 'Standard', risks: [], obligations: ['needsInspection', 'needsExecutionProof'] },
559
+ { lane: 'Critical', risks: ['auth'], obligations: ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit', 'needsResearchGrounding'] },
560
+ ];
561
+ for (const fixture of cases) {
562
+ const v1 = {
563
+ version: '1',
564
+ lane: fixture.lane,
565
+ automatic_lane: fixture.lane,
566
+ risks: fixture.risks,
567
+ artifact_profile: { Direct: 'targeted', Standard: 'bounded', Critical: 'strict' }[fixture.lane],
568
+ proof_obligations: fixture.obligations,
569
+ actor_needs: POLICY.actorNeedsFor(fixture.obligations),
570
+ override_receipt: null,
571
+ };
572
+ assert.equal(POLICY.validateWorkflowPolicySnapshot(v1).valid, true, fixture.lane);
573
+ const source = { feature_name: `legacy-${fixture.lane}`, workflow_policy: v1 };
574
+ const before = JSON.stringify(source);
575
+ const adapted = POLICY.readWorkflowPolicySnapshot(source);
576
+ assert.equal(adapted.version, '2');
577
+ assert.deepEqual(adapted.proof_obligations, fixture.obligations, fixture.lane);
578
+ assert.deepEqual(adapted.actor_needs, v1.actor_needs, fixture.lane);
579
+ assert.equal(POLICY.validateWorkflowPolicySnapshot(adapted).valid, true, fixture.lane);
580
+ assert.equal(JSON.stringify(source), before, 'read adapter must not mutate persisted v1 state');
581
+ const migrated = POLICY.persistWorkflowPolicySnapshot(source).workflow_policy;
582
+ assert.equal(migrated.version, POLICY.CANONICAL_WORKFLOW_POLICY_VERSION);
583
+ assert.deepEqual(Object.keys(migrated).sort(), [...POLICY.CANONICAL_WORKFLOW_POLICY_FIELDS].sort());
584
+ const migratedView = POLICY.readWorkflowPolicySnapshot({ workflow_policy: migrated });
585
+ assert.equal(migratedView.lane, adapted.lane, fixture.lane);
586
+ assert.equal(migratedView.artifact_profile, adapted.artifact_profile, fixture.lane);
587
+ assert.deepEqual(migratedView.proof_obligations, POLICY.obligationsForAssurance(
588
+ migrated.assurance_level,
589
+ migrated.risks,
590
+ ), fixture.lane);
591
+ }
346
592
  });
347
593
 
348
- test('unsafe downgrade without user authorization is blocked', () => {
349
- assert.throws(() => POLICY.classifyLane({
350
- reversible: true,
351
- riskSignals: { privacy: true },
352
- override: 'Direct',
353
- }), /is blocked.*no trusted runtime issuer/);
594
+ test('post-classification downgrade is unsupported', () => {
595
+ assert.throws(
596
+ () => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct' }),
597
+ (error) => /planning_depth downgrade/.test(error.message) && /not permitted/.test(error.message),
598
+ );
599
+ });
600
+
601
+ test('caller-requested downgrade is blocked before persistence', () => {
602
+ assert.throws(
603
+ () => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct' }),
604
+ (error) => /planning_depth downgrade/.test(error.message) && /not permitted/.test(error.message),
605
+ );
354
606
  // also via CLI
355
607
  const cli = spawnSync(process.execPath, [
356
608
  POLICY_PATH,
@@ -360,7 +612,7 @@ test('unsafe downgrade without user authorization is blocked', () => {
360
612
  '--json',
361
613
  ], { encoding: 'utf8' });
362
614
  assert.equal(cli.status, 2);
363
- assert.match(cli.stderr, /is blocked.*no trusted runtime issuer/);
615
+ assert.match(cli.stderr, /planning_depth downgrade.*not permitted/i);
364
616
  });
365
617
 
366
618
  test('forged approval via legacy approved field is rejected', () => {
@@ -380,14 +632,14 @@ test('forged approval via legacy approved field is rejected', () => {
380
632
  assert.match(legacyCheck.error, /Legacy/);
381
633
  });
382
634
 
383
- test('approval state never infers user approval from generated or agent validation', () => {
635
+ test('technical approval readiness ignores legacy user_approved', () => {
384
636
  const state = POLICY.approvalState({ generated: true, agent_validated: true });
385
637
  assert.equal(state.generated, true);
386
638
  assert.equal(state.agent_validated, true);
387
- assert.equal(state.user_approved, false);
388
- assert.equal(state.ready, false);
639
+ assert.equal(state.ready, true);
389
640
  assert.equal(state.schema_version, '2.0');
390
641
  assert.equal(POLICY.approvalState({ generated: true, agent_validated: true, user_approved: true }).ready, true);
642
+ assert.equal(POLICY.approvalState({ generated: true, agent_validated: true, user_approved: false }).ready, true);
391
643
  const cli = spawnSync(process.execPath, [
392
644
  POLICY_PATH,
393
645
  '--approval-state',
@@ -396,7 +648,7 @@ test('approval state never infers user approval from generated or agent validati
396
648
  '--json',
397
649
  ], { encoding: 'utf8' });
398
650
  assert.equal(cli.status, 0, `${cli.stdout}\n${cli.stderr}`);
399
- assert.equal(JSON.parse(cli.stdout).state.user_approved, false);
651
+ assert.equal(JSON.parse(cli.stdout).state.ready, true);
400
652
  });
401
653
 
402
654
  test('approval schema fails closed for explicit null, array, empty, and malformed states', () => {
@@ -422,7 +674,7 @@ test('approval schema fails closed for explicit null, array, empty, and malforme
422
674
  assert.throws(() => POLICY.approvalState({ schema_version: '1.0' }), /schema_version/);
423
675
  });
424
676
 
425
- test('CLI exposes JSON lane classification and develop has no universal spec hard gate', () => {
677
+ test('CLI exposes a derived lane and Develop consumes independent canonical policy axes', () => {
426
678
  const cli = spawnSync(process.execPath, [
427
679
  POLICY_PATH,
428
680
  '--classify-lane',
@@ -436,65 +688,59 @@ test('CLI exposes JSON lane classification and develop has no universal spec har
436
688
  assert.equal(payload.policy.requiresSpec, false);
437
689
  const develop = read(DEVELOP);
438
690
  assert.doesNotMatch(develop, /DO NOT write implementation code until an approved spec exists/i);
439
- assert.match(develop, /Direct.*Skip spec\/state/i);
440
- assert.match(develop, /Standard.*bounded.*spec profile/i);
441
- assert.match(develop, /workflow-policy snapshot/i);
442
- assert.match(develop, /user_approved[\s\S]*explicit user[\s\S]*approval/i);
443
- assert.match(develop, /Critical.*strict profile/i);
691
+ const policySection = develop.split('## Policy authority')[1]?.split('\n## ')[0] || '';
692
+ for (const authorityTerm of ['planning_depth', 'assurance_level', 'Risk', 'derived']) {
693
+ assert.ok(policySection.includes(authorityTerm), `policy authority missing ${authorityTerm}`);
694
+ }
695
+ for (const compatibilityView of ['**Direct**', '**Standard**', '**Critical**']) {
696
+ assert.ok(policySection.includes(compatibilityView), `missing derived view ${compatibilityView}`);
697
+ }
698
+ assert.ok(policySection.includes('derived Strict view'));
699
+ assert.ok(policySection.includes('Legacy approval fields'));
700
+ assert.ok(policySection.replace(/\s+/g, ' ').includes('zero authority'));
444
701
  assert.match(develop, /Classify lane[\s\S]*test receipt[\s\S]*docs-impact sync/i);
445
702
  assert.doesNotMatch(develop, /D2\[Step 3/);
446
703
  assert.match(develop, /--notes.*opt-in/i);
447
704
  assert.doesNotMatch(develop, /--no-notes/);
448
705
  assert.doesNotMatch(develop, /planner|implementer|test-runner|code-auditor|docs-keeper/i);
449
- const specsSkill = read(path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md'));
450
- assert.match(specsSkill, /Standard[\s\S]*bounded[\s\S]*exactly `spec\.json`, `requirements\.md`, `design\.md`, and one `feature-receipt\.md`/i);
451
- assert.match(specsSkill, /persisted.*spec\.json\.workflow_policy/i);
452
- assert.match(specsSkill, /explicit user approval/i);
706
+ const fullRoutine = POLICY.readWorkflowPolicySnapshot({
707
+ workflow_policy: POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Full', assurance_level: 'Routine' }),
708
+ });
709
+ const compactStrict = POLICY.readWorkflowPolicySnapshot({
710
+ workflow_policy: POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Strict' }),
711
+ });
712
+ assert.equal(fullRoutine.planning_depth, 'Full');
713
+ assert.equal(fullRoutine.assurance_level, 'Routine');
714
+ assert.equal(compactStrict.planning_depth, 'Compact');
715
+ assert.equal(compactStrict.assurance_level, 'Strict');
716
+ assert.notEqual(fullRoutine.artifact_profile, compactStrict.artifact_profile);
717
+ assert.notDeepEqual(fullRoutine.proof_obligations, compactStrict.proof_obligations);
453
718
  });
454
719
 
455
- test('P2 reduces ceremony without weakening lane and proof contracts', () => {
456
- const specs = read(path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md'));
457
- const develop = read(DEVELOP);
458
- const testSkill = read(TEST_SKILL);
459
- const review = read(CODE_REVIEW_SKILL);
460
- const gate = read(GATE);
461
- const specMaker = read(path.join(PACKAGE_ROOT, 'src/claude/agents/spec-maker.md'));
462
-
463
- assert.ok(specs.trimEnd().split('\n').length >= 150 && specs.trimEnd().split('\n').length <= 220);
464
- assert.ok(develop.trimEnd().split('\n').length >= 140 && develop.trimEnd().split('\n').length <= 200);
465
- assert.match(specs, /Direct[\s\S]*Do not create a spec/i);
466
- assert.ok(specs.includes('Do not require') && specs.includes('task_registry'));
467
- assert.match(specs, /Critical[\s\S]*registry\/DAG[\s\S]*research/i);
468
- assert.match(specs, /Load\s+`rules\/ears-format\.md` when/);
469
- assert.match(specs, /Task scoring is optional advisory/);
470
- assert.match(specs, /Cynefin is advisory/);
471
- assert.match(specs, /execution_tier[\s\S]*legacy read adapter/i);
472
- assert.match(specs, /spec-ready[\s\S]*PENDING/i);
473
- assert.match(specs, /canonical execution receipt/i);
474
- assert.match(specs, /Audit: PASS.*not.*evidence/i);
475
- assert.match(specs, /Set `ready_for_implementation = true` only[\s\S]*deterministic spec validation[\s\S]*grounded requirement\/task mappings and contracts/i);
476
- assert.match(specs, /neither a[\s\S]*canonical execution receipt[\s\S]*independent closeout audit is required yet/i);
477
- assert.match(specs, /After implementation, feature\/task closeout is a separate gate[\s\S]*canonical execution receipt/i);
478
-
479
- assert.match(develop, /specific-task mode never selects or chains/i);
480
- assert.match(develop, /exactly one closeout owner/i);
481
- assert.match(develop, /test owner[\s\S]*canonical execution receipt/i);
482
- assert.match(develop, /review owner[\s\S]*never creates or claims execution proof/i);
483
- assert.match(develop, /docs impact/i);
484
- assert.match(gate, /no fixed Light\/Standard\/Deep agent sequence/i);
485
- assert.match(gate, /no\s+blocking Medium finding/i);
486
- assert.match(gate, /Finding count never selects review depth/i);
487
- assert.doesNotMatch(gate, /at most one Medium/i);
488
- assert.match(testSkill, /sole owner of canonical execution proof/i);
489
- assert.match(review, /does not[\s\S]*canonical execution[\s\S]*receipt/i);
490
- assert.match(review, /lane, risk, and blast radius/i);
491
- assert.match(review, /no\s+blocking Medium finding/i);
492
- assert.match(review, /Finding count never selects depth/i);
493
- assert.match(specMaker, /no canonical execution receipt[\s\S]*independent closeout audit is required[\s\S]*yet/i);
494
- assert.match(specMaker, /passing deterministic spec validation/i);
495
- for (const content of [develop, gate, review]) {
496
- assert.doesNotMatch(content, /planner|implementer|test-runner|code-auditor|docs-keeper/i);
720
+ test('artifact router keeps optional expansion bound to uncertainty and typed topology', () => {
721
+ const router = markdownSection(read(SPECS), 'Artifact router');
722
+ const rows = markdownTable(router, 'Depth');
723
+ assert.deepEqual(Object.keys(rows), ['None', 'Compact', 'Full']);
724
+ assert.equal(rows.None['Durable core'], 'none');
725
+ for (const depth of ['Compact', 'Full']) {
726
+ assert.deepEqual(
727
+ rows[depth]['Durable core'].match(/`[^`]+`/g),
728
+ ['`spec.json`', '`requirements.md`', '`design.md`'],
729
+ `${depth} must share the canonical three-file core`,
730
+ );
731
+ assert.equal(rows[depth]['Research route'], 'only on a research trigger');
732
+ assert.equal(rows[depth]['Task route'], 'only on a typed topology trigger');
733
+ }
734
+ assert.notEqual(rows.Compact['Design expansion'], rows.Full['Design expansion']);
735
+ for (const falseTrigger of ['Requirement count', 'size', 'planning depth', 'assurance level', 'risk label', 'architectural layers']) {
736
+ assert.ok(router.includes(falseTrigger), `router must name inert trigger ${falseTrigger}`);
497
737
  }
738
+ for (const boundary of ['ownership', 'dependency', 'transition', 'proof', 'parallel']) {
739
+ assert.ok(router.includes(boundary), `router must name typed topology ${boundary}`);
740
+ }
741
+ assert.ok(router.includes('unresolved material uncertainty'));
742
+ assert.ok(router.includes('external-current fact'));
743
+ assert.ok(router.includes('explicit user request'));
498
744
  });
499
745
 
500
746
  test('canonical verdict adapter handles completion and unfinished decisions', () => {
@@ -525,7 +771,7 @@ test('canonical verdict adapter handles completion and unfinished decisions', ()
525
771
 
526
772
  test('completion decision requires canonical execution receipt and every workflow obligation', () => {
527
773
  const receipt = canonicalReceipt();
528
- const critical = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
774
+ const critical = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' });
529
775
  const audit = {
530
776
  schema_version: '1',
531
777
  reviewer_session_id: 'review-session-1',
@@ -619,16 +865,228 @@ test('completion decision requires canonical execution receipt and every workflo
619
865
  assert.ok(artifactBlocked.missingProof.some((item) => item.includes('artifact_hash')));
620
866
  });
621
867
 
622
- test('P1 auto generation pauses without forging approval or readiness', () => {
623
- const specsSkill = read(path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md'));
624
- const specMaker = read(path.join(PACKAGE_ROOT, 'src/claude/agents/spec-maker.md'));
625
- assert.match(specsSkill, /--auto/i);
626
- assert.match(specsSkill, /explicit user approval/i);
627
- assert.match(specsSkill, /paused.*not-ready/i);
628
- assert.match(specMaker, /--auto.*never sets `user_approved`/s);
629
- assert.match(specMaker, /paused\/not-ready/i);
630
- assert.match(specMaker, /never sets `user_approved`/i);
631
- assert.doesNotMatch(specMaker, /auto-approval/i);
868
+ test('auto authoring reaches readiness only after promotion, validation, grounding, and current review', () => {
869
+ const fixture = createCanonicalAuthoringFixture();
870
+ try {
871
+ let spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
872
+ assert.equal(spec.semantic_model, null);
873
+ spec.ready_for_implementation = true;
874
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
875
+ let validation = VALIDATOR.validateSpec(fixture.specDir);
876
+ assert.ok(validation.errors.some((error) => error.includes('requires explicit promoted machine semantic authority')));
877
+
878
+ spec.ready_for_implementation = false;
879
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
880
+ promoteCanonicalModel(fixture);
881
+ spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
882
+ assert.ok(spec.semantic_model && spec.semantic_model.criteria.length === 2);
883
+ spec.ready_for_implementation = true;
884
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
885
+ validation = VALIDATOR.validateSpec(fixture.specDir);
886
+ assert.ok(validation.errors.some((error) => error.includes('readiness requires validated')));
887
+ assert.ok(validation.errors.some((error) => error.includes('requires completed semantic review')));
888
+
889
+ spec.ready_for_implementation = false;
890
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
891
+ spec = makeCanonicalReady(fixture);
892
+ validation = VALIDATOR.validateSpec(fixture.specDir);
893
+ assert.deepEqual(validation.errors, [], validation.errors.join('\n'));
894
+ assert.equal(GROUNDER.groundSpec({ specDir: fixture.specDir, root: fixture.root, spec }).errors.length, 0);
895
+
896
+ const specs = read(SPECS);
897
+ const executableBlocks = [...specs.matchAll(/```bash\n([\s\S]*?)```/g)].map((match) => match[1]);
898
+ assert.ok(executableBlocks.every((block) => !/hapo:?develop/i.test(block)), 'Specs must never execute Develop');
899
+ const handoff = markdownSection(specs, 'Platform acceptance and handoff');
900
+ assert.ok(handoff.includes('After readiness'));
901
+ assert.ok(handoff.includes('/hapo:develop <feature>'));
902
+ assert.ok(handoff.includes('$hapo-develop <feature>'));
903
+ assert.ok(handoff.includes('must stop in Specs'));
904
+ } finally {
905
+ fs.rmSync(fixture.root, { recursive: true, force: true });
906
+ }
907
+ });
908
+
909
+ test('atomic readiness supports one-criterion Routine specs and binds semantic text', () => {
910
+ const fixture = createOrdinaryAuthoringFixture();
911
+ try {
912
+ const authored = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
913
+ authored.decisions = [
914
+ { id: 'Q1', classification: 'repository_fact', statement: 'The runtime entrypoint is src/entry.js.', status: 'grounded', evidence: 'Repository anchor A-D-01 resolves to src/entry.js.' },
915
+ { id: 'Q2', classification: 'reversible_assumption', statement: 'Use the existing direct module export for this bounded change.', status: 'recorded', evidence: 'Reversal is limited to src/entry.js and does not alter product data.' },
916
+ ];
917
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(authored, null, 2)}\n`);
918
+ const result = READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() });
919
+ assert.equal(result.spec.ready_for_implementation, true);
920
+ assert.equal(result.spec.semantic_model.criteria.length, 1);
921
+ assert.match(result.spec.semantic_model.criteria[0].text, /delete the selected record/);
922
+ assert.deepEqual(VALIDATOR.validateSpec(fixture.specDir).errors, []);
923
+
924
+ const beforeDigest = result.semantic_digest;
925
+ const design = path.join(fixture.specDir, 'design.md');
926
+ const originalDesign = fs.readFileSync(design, 'utf8');
927
+ fs.writeFileSync(design, originalDesign.replace('deleted rather than archived', 'archived rather than deleted'));
928
+ const decisionProjection = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-semantic-model.cjs')).modelFromMarkdown(fixture.specDir, result.spec);
929
+ assert.notDeepEqual(decisionProjection.model.design_records, result.spec.semantic_model.design_records);
930
+ const decisionDigest = VALIDATOR.computeSemanticDigest21(fixture.specDir, { ...result.spec, semantic_model: decisionProjection.model });
931
+ assert.equal(decisionDigest.errors.length, 0, decisionDigest.errors.join('\n'));
932
+ assert.notEqual(decisionDigest.digest, beforeDigest);
933
+ fs.writeFileSync(design, originalDesign);
934
+
935
+ const requirements = path.join(fixture.specDir, 'requirements.md');
936
+ fs.writeFileSync(requirements, fs.readFileSync(requirements, 'utf8').replace('delete the selected record', 'archive the selected record'));
937
+ const projection = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-semantic-model.cjs')).modelFromMarkdown(fixture.specDir, result.spec);
938
+ assert.notEqual(projection.model.criteria[0].text, result.spec.semantic_model.criteria[0].text);
939
+ const changed = VALIDATOR.computeSemanticDigest21(fixture.specDir, { ...result.spec, semantic_model: projection.model });
940
+ assert.equal(changed.errors.length, 0, changed.errors.join('\n'));
941
+ assert.notEqual(changed.digest, beforeDigest);
942
+ assert.ok(VALIDATOR.validateSpec(fixture.specDir).errors.some((error) => /projection|stale/.test(error)));
943
+ } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
944
+ });
945
+
946
+ test('atomic readiness preserves exact bytes on review, grounding, decision, and Strict authority failures', () => {
947
+ const fixture = createOrdinaryAuthoringFixture();
948
+ try {
949
+ const original = fs.readFileSync(fixture.specPath);
950
+ for (const mutate of [
951
+ (review) => ({ ...review, reviewed_criteria: [] }),
952
+ (review) => ({ ...review, counterexamples: [] }),
953
+ ]) {
954
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: mutate(ordinaryReview()) }), /readiness validation/);
955
+ assert.equal(fs.readFileSync(fixture.specPath).equals(original), true);
956
+ }
957
+ fs.unlinkSync(path.join(fixture.root, 'src', 'entry.js'));
958
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /grounding|validation/);
959
+ assert.equal(fs.readFileSync(fixture.specPath).equals(original), true);
960
+ fs.writeFileSync(path.join(fixture.root, 'src', 'entry.js'), 'module.exports = { action: "delete" };\n');
961
+
962
+ process.env.CAFEKIT_FINALIZE_FAIL_BEFORE_RENAME = '1';
963
+ try {
964
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /injected finalization failure/);
965
+ assert.equal(fs.readFileSync(fixture.specPath).equals(original), true);
966
+ } finally { delete process.env.CAFEKIT_FINALIZE_FAIL_BEFORE_RENAME; }
967
+
968
+ const blocked = JSON.parse(original);
969
+ blocked.decisions = [{ id: 'Q1', classification: 'user_owned', statement: 'Choose permanent deletion or retention archive behavior.', status: 'unresolved', evidence: null }];
970
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(blocked, null, 2)}\n`);
971
+ const blockedBytes = fs.readFileSync(fixture.specPath);
972
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /unresolved material user-owned decision/);
973
+ assert.equal(fs.readFileSync(fixture.specPath).equals(blockedBytes), true);
974
+
975
+ delete blocked.decisions;
976
+ blocked.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Strict' });
977
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(blocked, null, 2)}\n`);
978
+ const strictBytes = fs.readFileSync(fixture.specPath);
979
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /Strict readiness/);
980
+ assert.equal(fs.readFileSync(fixture.specPath).equals(strictBytes), true);
981
+ } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
982
+ });
983
+
984
+ test('Strict atomic readiness succeeds only after current allowlisted host observation', () => {
985
+ const fixture = createOrdinaryAuthoringFixture();
986
+ try {
987
+ const spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
988
+ spec.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Strict' });
989
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
990
+ const before = fs.readFileSync(fixture.specPath);
991
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /Strict readiness/);
992
+ assert.equal(fs.readFileSync(fixture.specPath).equals(before), true);
993
+
994
+ const digest = VALIDATOR.computeSemanticDigest21(fixture.specDir, spec);
995
+ assert.equal(digest.errors.length, 0, digest.errors.join('\n'));
996
+ const authorityHome = path.join(fixture.root, 'authority-home');
997
+ const claim = `CAFEKIT_SEMANTIC_REVIEW_ATTESTATION ${JSON.stringify({ feature_name: 'ordinary', spec_file: 'specs/ordinary/spec.json', semantic_digest: digest.digest, verdict: 'PASS' })}`;
998
+ const observed = spawnSync(process.execPath, [path.join(PACKAGE_ROOT, 'src/claude/hooks/semantic-review-authority.cjs')], {
999
+ cwd: fixture.root,
1000
+ env: { ...process.env, HOME: authorityHome, USERPROFILE: authorityHome, PROJECT_ROOT: fixture.root },
1001
+ input: JSON.stringify({ hook_event_name: 'SubagentStop', session_id: 'host-session', agent_id: 'reviewer-1', agent_type: 'code_auditor', last_assistant_message: claim, cwd: fixture.root }),
1002
+ encoding: 'utf8',
1003
+ });
1004
+ assert.equal(observed.status, 0, observed.stderr);
1005
+ const reviewFile = path.join(fixture.root, 'review.json');
1006
+ fs.writeFileSync(reviewFile, `${JSON.stringify(ordinaryReview())}\n`);
1007
+ const finalized = spawnSync(process.execPath, [path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-readiness.cjs'), fixture.specDir, '--review-result', reviewFile], {
1008
+ cwd: fixture.root,
1009
+ env: { ...process.env, HOME: authorityHome, USERPROFILE: authorityHome, PROJECT_ROOT: fixture.root },
1010
+ encoding: 'utf8',
1011
+ });
1012
+ assert.equal(finalized.status, 0, `${finalized.stdout}\n${finalized.stderr}`);
1013
+ assert.equal(JSON.parse(fs.readFileSync(fixture.specPath, 'utf8')).ready_for_implementation, true);
1014
+ } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
1015
+ });
1016
+
1017
+ test('done is a final-state request bound to current semantic model and digest', () => {
1018
+ const fixture = createCanonicalAuthoringFixture();
1019
+ try {
1020
+ promoteCanonicalModel(fixture);
1021
+ const spec = makeCanonicalReady(fixture);
1022
+ spec.status = 'done';
1023
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
1024
+ const candidate = {
1025
+ featureDir: fixture.specDir,
1026
+ featureName: 'feature',
1027
+ specFile: fixture.specPath,
1028
+ spec,
1029
+ };
1030
+ const dependencies = () => ({
1031
+ validator: VALIDATOR,
1032
+ grounder: GROUNDER,
1033
+ semanticAuthority: SEMANTIC_AUTHORITY,
1034
+ });
1035
+ const current = FINAL_STATE.validateCanonicalFinalState({
1036
+ policy: POLICY, projectRoot: fixture.root, candidate, dependencies,
1037
+ });
1038
+ assert.equal(current.ok, true, current.reason);
1039
+ assert.equal(current.semanticDigest, spec.validation.semantic_review.semantic_digest);
1040
+
1041
+ const failedReceipt = structuredClone(spec);
1042
+ failedReceipt.validation.semantic_review.verdict = 'FAIL';
1043
+ const receiptMutation = FINAL_STATE.validateCanonicalFinalState({
1044
+ policy: POLICY,
1045
+ projectRoot: fixture.root,
1046
+ candidate: { ...candidate, spec: failedReceipt },
1047
+ dependencies,
1048
+ });
1049
+ assert.equal(receiptMutation.ok, false);
1050
+ assert.match(receiptMutation.reason, /PASS CONTINUE/);
1051
+
1052
+ const mismatchedHistory = structuredClone(spec);
1053
+ const latest = mismatchedHistory.validation.semantic_review_history.entries[
1054
+ mismatchedHistory.validation.semantic_review_history.entries.length - 1
1055
+ ];
1056
+ latest.review_receipt_digest = `sha256:${'0'.repeat(64)}`;
1057
+ const digestMismatch = FINAL_STATE.validateCanonicalFinalState({
1058
+ policy: POLICY,
1059
+ projectRoot: fixture.root,
1060
+ candidate: { ...candidate, spec: mismatchedHistory },
1061
+ dependencies,
1062
+ });
1063
+ assert.equal(digestMismatch.ok, false);
1064
+ assert.match(digestMismatch.reason, /review_receipt_digest|canonical receipt/);
1065
+
1066
+ const requirementsPath = path.join(fixture.specDir, 'requirements.md');
1067
+ const requirements = fs.readFileSync(requirementsPath, 'utf8');
1068
+ fs.writeFileSync(requirementsPath, requirements.replace('invalid input shall return rejected state', 'invalid input shall return a deterministic rejected state'));
1069
+ const staleDigest = FINAL_STATE.validateCanonicalFinalState({
1070
+ policy: POLICY, projectRoot: fixture.root, candidate, dependencies,
1071
+ });
1072
+ assert.equal(staleDigest.ok, false);
1073
+ assert.match(staleDigest.reason, /stale|differs from Markdown projection/i);
1074
+
1075
+ fs.writeFileSync(requirementsPath, requirements);
1076
+ const staleModel = structuredClone(spec);
1077
+ staleModel.semantic_model.criteria[0].owner = 'A-D-02';
1078
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(staleModel, null, 2)}\n`);
1079
+ const invalidModel = FINAL_STATE.validateCanonicalFinalState({
1080
+ policy: POLICY,
1081
+ projectRoot: fixture.root,
1082
+ candidate: { ...candidate, spec: staleModel },
1083
+ dependencies,
1084
+ });
1085
+ assert.equal(invalidModel.ok, false);
1086
+ assert.match(invalidModel.reason, /semantic projection|semantic_model|validation failed/i);
1087
+ } finally {
1088
+ fs.rmSync(fixture.root, { recursive: true, force: true });
1089
+ }
632
1090
  });
633
1091
 
634
1092
  test('flash PASS promotes proof; only trusted sync-finalize completes', () => {
@@ -665,22 +1123,19 @@ test('flash PASS promotes proof; only trusted sync-finalize completes', () => {
665
1123
 
666
1124
  test('spec-gate rejects stale FLASH_UNVERIFIED done state', () => {
667
1125
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-spec-gate-'));
668
- const claude = path.join(root, '.claude');
669
- fs.mkdirSync(path.join(claude, 'hooks'), { recursive: true });
670
- fs.mkdirSync(path.join(claude, 'scripts'), { recursive: true });
671
- fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs'), path.join(claude, 'hooks/spec-gate.cjs'));
672
- fs.copyFileSync(POLICY_PATH, path.join(claude, 'scripts/workflow-policy.cjs'));
673
- fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), path.join(claude, 'scripts/provenance.cjs'));
674
- fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'), path.join(claude, 'scripts/spec-resolver.cjs'));
1126
+ const specGate = installClaudeRuntimeClosure(root);
1127
+ assert.equal(fs.existsSync(path.join(root, '.claude', 'scripts', 'spec-final-state.cjs')), true);
1128
+ assert.equal(fs.existsSync(path.join(root, '.claude', 'scripts', 'validate-spec-output.cjs')), true);
675
1129
  fs.mkdirSync(path.join(root, 'specs', 'demo', 'tasks'), { recursive: true });
676
1130
  fs.writeFileSync(path.join(root, 'specs', 'demo', 'spec.json'), JSON.stringify({
677
1131
  status: 'in_progress',
1132
+ current_phase: 'closeout',
678
1133
  feature_name: 'demo',
679
1134
  task_registry: { 'tasks/task.md': { status: 'done', receipt: 'FLASH_UNVERIFIED' } },
680
1135
  }));
681
1136
  initFixtureGit(root);
682
1137
  try {
683
- const result = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
1138
+ const result = spawnSync(process.execPath, [specGate], {
684
1139
  cwd: root,
685
1140
  env: { ...process.env, PROJECT_ROOT: root },
686
1141
  input: JSON.stringify({ cwd: root, session_id: 'test' }),
@@ -698,24 +1153,21 @@ test('spec-gate rejects stale FLASH_UNVERIFIED done state', () => {
698
1153
  test('first-run cache bypass is blocked - canonical receipt required even without cache', () => {
699
1154
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-first-run-'));
700
1155
  const claude = path.join(root, '.claude');
701
- fs.mkdirSync(path.join(claude, 'hooks'), { recursive: true });
702
- fs.mkdirSync(path.join(claude, 'scripts'), { recursive: true });
703
- fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs'), path.join(claude, 'hooks/spec-gate.cjs'));
704
- fs.copyFileSync(POLICY_PATH, path.join(claude, 'scripts/workflow-policy.cjs'));
705
- fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), path.join(claude, 'scripts/provenance.cjs'));
706
- fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'), path.join(claude, 'scripts/spec-resolver.cjs'));
1156
+ const specGate = installClaudeRuntimeClosure(root);
707
1157
  const specDir = path.join(root, 'specs', 'demo', 'tasks');
708
1158
  fs.mkdirSync(specDir, { recursive: true });
709
1159
  // Done task without canonical receipt (missing Command, provenance)
710
1160
  fs.writeFileSync(path.join(root, 'specs', 'demo', 'spec.json'), JSON.stringify({
711
- status: 'in_progress',
1161
+ status: 'done',
1162
+ current_phase: 'closeout',
712
1163
  feature_name: 'demo',
713
- task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } },
1164
+ task_registry: { 'tasks/task-R1-01-one.md': { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } },
714
1165
  }));
715
- fs.writeFileSync(path.join(specDir, 'task.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\n```\nnpm test\n```\n');
1166
+ fs.writeFileSync(path.join(specDir, 'task-R1-01-one.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\n```\nnpm test\n```\n');
716
1167
  initFixtureGit(root);
717
1168
  try {
718
- const result = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
1169
+ assert.equal(fs.existsSync(path.join(claude, 'hooks', '.logs', 'spec-gate-last.json')), false);
1170
+ const result = spawnSync(process.execPath, [specGate], {
719
1171
  cwd: root,
720
1172
  env: { ...process.env, PROJECT_ROOT: root },
721
1173
  input: JSON.stringify({ cwd: root, session_id: 'test' }),
@@ -724,15 +1176,25 @@ test('first-run cache bypass is blocked - canonical receipt required even withou
724
1176
  assert.equal(result.status, 0);
725
1177
  const payload = JSON.parse(result.stdout);
726
1178
  assert.equal(payload.decision, 'block', 'first-run without canonical receipt must block');
727
- assert.match(payload.reason, /tasks\/task\.md/);
1179
+ assert.match(payload.reason, /tasks\/task-R1-01-one\.md/);
1180
+ assert.match(payload.reason, /receipt|command|provenance/i);
728
1181
  // Also test that with canonical receipt it passes
729
- fs.writeFileSync(path.join(specDir, 'task.md'), bindFixtureReceipt(root, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nResult: PASS\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n```\nnpm test\nPASS\n```\n'));
1182
+ fs.writeFileSync(path.join(specDir, 'task-R1-01-one.md'), '# Task R1-01: One\n\n**Status:** done\n\n## Outcome\n\nVerified result.\n\n## Scope and Typed Anchors\n\n- **In scope:** focused result\n- **Out of scope:** none\n\n## Changes\n\n- [x] Verify result.\n\n## Acceptance\n\n- Result is verified.\n\n## Dependencies\n\n- none\n\n## Verification Plan\n\n- **Command:** `pnpm test`\n- **Expected:** focused tests pass\n- **Negative path:** not relevant for fixture\n- **Reachability:** fixture test entrypoint\n');
1183
+ fs.mkdirSync(path.join(root, 'specs', 'demo', 'receipts'), { recursive: true });
1184
+ fs.writeFileSync(
1185
+ path.join(root, 'specs', 'demo', 'receipts', 'task-R1-01-one.md'),
1186
+ bindFixtureReceipt(root, '# Task Receipt\n\nTask: task-R1-01-one.md\nTask path: tasks/task-R1-01-one.md\nVerification: PASS\nCommand: pnpm test\nExit: 0\nResult: PASS\nExpected: focused tests pass\nObserved: focused tests passed\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n```\nnpm test\nPASS\n```\n'),
1187
+ );
1188
+ fs.writeFileSync(
1189
+ path.join(root, 'specs', 'demo', 'feature-receipt.md'),
1190
+ bindFixtureReceipt(root, '# Feature Receipt\n\nFeature: demo\nVerification: PASS\nCommand: pnpm test\nExit: 0\nResult: PASS\nExpected: integrated feature passes\nObserved: integrated feature passed\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n'),
1191
+ );
730
1192
  // Clear cache to simulate fresh first-run with valid receipt
731
1193
  try { fs.unlinkSync(path.join(claude, 'hooks', '.logs', 'spec-gate-last.json')); } catch {}
732
1194
  try { fs.unlinkSync(path.join(__dirname, '..', '.logs', 'spec-gate-last.json')); } catch {}
733
1195
  // Need to clear the cache that the hook uses (under claude hooks .logs) - we already cleared above
734
1196
  // Re-run with valid receipt - should not block
735
- const result2 = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
1197
+ const result2 = spawnSync(process.execPath, [specGate], {
736
1198
  cwd: root,
737
1199
  env: { ...process.env, PROJECT_ROOT: root },
738
1200
  input: JSON.stringify({ cwd: root, session_id: 'test' }),
@@ -986,7 +1448,7 @@ test('runtime provenance derives exact Git evidence, CLI context, and stale/forg
986
1448
  }
987
1449
  });
988
1450
 
989
- test('lane traces - Direct, Standard, Critical classification, override, state mutation and completion', () => {
1451
+ test('lane traces - Direct, Standard, explicit Strict classification, override, state mutation and completion', () => {
990
1452
  // Direct: isolated reversible low-risk
991
1453
  const direct = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true, taskCount: 1 });
992
1454
  assert.equal(direct.lane, 'Direct');
@@ -997,29 +1459,30 @@ test('lane traces - Direct, Standard, Critical classification, override, state m
997
1459
  // Standard: default
998
1460
  const standard = POLICY.classifyLane({ title: 'add pagination to list', taskCount: 1 });
999
1461
  assert.equal(standard.lane, 'Standard');
1000
- assert.deepEqual(POLICY.lanePolicy(standard).proof_obligations, ['needsInspection', 'needsExecutionProof']);
1462
+ assert.deepEqual(POLICY.lanePolicy(standard).proof_obligations, ['needsExecutionProof']);
1001
1463
  assert.equal(POLICY.lanePolicy(standard).shipPoint, 'feature');
1002
- // Critical: auth signal
1003
- const critical = POLICY.classifyLane({ riskSignals: { auth: true }, taskCount: 1 });
1464
+ // Risk alone is Elevated; Critical requires an explicit Strict assurance choice.
1465
+ const elevated = POLICY.classifyLane({ riskSignals: { auth: true }, taskCount: 1 });
1466
+ assert.equal(elevated.lane, 'Standard');
1467
+ assert.deepEqual(POLICY.lanePolicy(elevated).proof_obligations, ['needsInspection', 'needsExecutionProof']);
1468
+ const critical = POLICY.classifyLane({ riskSignals: { auth: true }, assurance_level: 'Strict', taskCount: 1 });
1004
1469
  assert.equal(critical.lane, 'Critical');
1005
1470
  assert.ok(critical.risks.includes('auth'));
1006
- assert.deepEqual(POLICY.lanePolicy(critical).proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit', 'needsResearchGrounding']);
1471
+ assert.deepEqual(POLICY.lanePolicy(critical).proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit']);
1007
1472
  // Override: Direct requesting Critical is allowed (upgrade) without extra auth
1008
1473
  const upgrade = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true, taskCount: 1, override: 'Critical' });
1009
1474
  assert.equal(upgrade.lane, 'Critical');
1010
1475
  assert.equal(upgrade.automaticLane, 'Direct');
1011
1476
  // Downgrade without auth is blocked
1012
- assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard' }), /is blocked.*no trusted runtime issuer/);
1013
- // Downgrade with legacy boolean flags is still blocked (P0.4 fail-closed, no trusted issuer)
1014
- assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', userAuthorized: true }), /is blocked.*no trusted runtime issuer/);
1015
- assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', user_approved: true }), /is blocked.*no trusted runtime issuer/);
1016
- // Even structured receipt without verified issuer is blocked fail-closed
1017
- assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', overrideReceipt: { automaticLane: 'Critical', requestedLane: 'Standard', risks: ['payment'], source: 'runtime', timestamp: '2026-08-11T00:00:00.000Z', sessionId: 's-123' } }), /is blocked.*no trusted runtime issuer/);
1477
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard' }), /assurance_level downgrade.*blocked/i);
1478
+ // Caller-owned booleans cannot weaken the classified minimum.
1479
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', userAuthorized: true }), /assurance_level downgrade.*blocked/i);
1480
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', user_approved: true }), /assurance_level downgrade.*blocked/i);
1018
1481
  // State mutation: approvalState
1019
1482
  const pendingApproval = POLICY.approvalState({ generated: true, agent_validated: false, user_approved: false });
1020
1483
  assert.equal(pendingApproval.ready, false);
1021
1484
  const agentValidated = POLICY.approvalState({ generated: true, agent_validated: true, user_approved: false });
1022
- assert.equal(agentValidated.ready, false, 'agent_validated alone must not imply ready');
1485
+ assert.equal(agentValidated.ready, true, 'generated plus agent validation is technical readiness');
1023
1486
  const approved = POLICY.approvalState({ generated: true, agent_validated: true, user_approved: true });
1024
1487
  assert.equal(approved.ready, true);
1025
1488
  // Completion: flash work remains in_progress and does not unblock
@@ -1946,24 +2409,29 @@ test('P0 Claude and Codex reject configured specs roots outside the project', ()
1946
2409
  }
1947
2410
  });
1948
2411
 
1949
- test('P0 forged approvals: user_approved and boolean userAuthorized do not downgrade', () => {
1950
- assert.throws(() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', user_approved: true }), /is blocked.*no trusted runtime issuer/);
1951
- assert.throws(() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', userApproved: true }), /is blocked.*no trusted runtime issuer/);
1952
- assert.throws(() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', userAuthorized: true }), /is blocked.*no trusted runtime issuer/);
1953
- assert.throws(() => POLICY.classifyLane({ riskSignals: { auth: true }, override: 'Standard', confirmDowngrade: true }), /is blocked.*no trusted runtime issuer/);
1954
- // Direct check of isUserAuthorizedForDowngrade returns false for booleans
1955
- assert.equal(POLICY.isUserAuthorizedForDowngrade({ user_approved: true }), false);
1956
- assert.equal(POLICY.isUserAuthorizedForDowngrade({ userAuthorized: true }), false);
1957
- assert.equal(POLICY.isUserAuthorizedForDowngrade({ userAuthorized: true }, 'Critical', 'Direct', ['privacy']), false);
2412
+ test('P0 caller-owned downgrade flags have no canonical capability', () => {
2413
+ for (const callerClaim of [{ user_approved: true }, { userApproved: true }, { userAuthorized: true }]) {
2414
+ assert.throws(
2415
+ () => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', ...callerClaim }),
2416
+ (error) => /downgrade/.test(error.message) && /not permitted/.test(error.message),
2417
+ );
2418
+ }
2419
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { auth: true }, override: 'Standard', confirmDowngrade: true }), /assurance_level downgrade.*blocked/i);
2420
+ assert.equal(POLICY.isUserAuthorizedForDowngrade, undefined);
1958
2421
  });
1959
2422
 
1960
- test('P0 downgrade fail-closed when no verified issuer', () => {
1961
- const receipt = { automaticLane: 'Critical', requestedLane: 'Direct', risks: ['privacy'], source: 'runtime', timestamp: '2026-08-11T00:00:00.000Z', sessionId: 'sess-1' };
1962
- assert.equal(POLICY.isValidOverrideReceipt(receipt, 'Critical', 'Direct', ['privacy']), false, 'shape-correct runtime receipt must still be invalid without verified issuer');
1963
- assert.throws(() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', overrideReceipt: receipt }), /is blocked.*no trusted runtime issuer/);
1964
- const withVerified = { ...receipt, verifiedByRuntime: true };
1965
- // Even with verified flag, P0 stays fail-closed (always false) — documents future issuer need
1966
- assert.equal(POLICY.isValidOverrideReceipt(withVerified, 'Critical', 'Direct', ['privacy']), false);
2423
+ test('P0 canonical policy exposes no caller-owned downgrade authority', () => {
2424
+ assert.equal(POLICY.isUserAuthorizedForDowngrade, undefined);
2425
+ assert.equal(POLICY.isValidOverrideReceipt, undefined);
2426
+ assert.equal(POLICY.CANONICAL_WORKFLOW_POLICY_FIELDS.includes('override_' + 'receipt'), false);
2427
+ assert.throws(
2428
+ () => POLICY.classifyLane({
2429
+ riskSignals: { privacy: true },
2430
+ override: 'Direct',
2431
+ overrideReceipt: { verifiedByRuntime: true },
2432
+ }),
2433
+ (error) => /downgrade/.test(error.message) && /not permitted/.test(error.message),
2434
+ );
1967
2435
  });
1968
2436
 
1969
2437
  test('P0 active spec deterministic: multiple active ambiguity and explicit target/path containment', () => {
@@ -2056,7 +2524,7 @@ test('P0 regression: placeholder, explicit failure, artifact and lanePolicy forg
2056
2524
  const POLICY_PATH = path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs');
2057
2525
  const forged = spawnSync(process.execPath, [POLICY_PATH, '--lane-policy', '--task-json', JSON.stringify({ lane: 'Direct', automaticLane: 'Direct', riskSignals: { auth: true } }), '--json'], { encoding: 'utf8' });
2058
2526
  assert.equal(forged.status, 2, 'forged Direct with auth should be blocked via lanePolicy');
2059
- assert.match(forged.stderr, /is blocked.*no trusted runtime issuer/);
2527
+ assert.match(forged.stderr, /downgrade.*not permitted/i);
2060
2528
  const forged2 = spawnSync(process.execPath, [POLICY_PATH, '--lane-policy', '--task-json', JSON.stringify({ lane: 'Direct', automaticLane: 'Direct' }), '--json'], { encoding: 'utf8' });
2061
2529
  assert.equal(forged2.status, 2, 'plain Direct without justification should be blocked');
2062
2530
 
@@ -2067,7 +2535,7 @@ test('P0 regression: placeholder, explicit failure, artifact and lanePolicy forg
2067
2535
  assert.throws(() => { 'use strict'; trusted.lane = 'Direct'; }, /read only|Cannot assign|TypeError/);
2068
2536
  const paymentTrusted = POLICY.classifyLane({ riskSignals: { payment: true } });
2069
2537
  const forgedCopy = { ...paymentTrusted, lane: 'Direct' };
2070
- assert.throws(() => POLICY.lanePolicy(forgedCopy), /is blocked/);
2538
+ assert.equal(POLICY.lanePolicy(forgedCopy).lane, 'Standard', 'compatibility lane is derived from authoritative axes');
2071
2539
  });
2072
2540
 
2073
2541
  test('P0 regression: symlink spec/task containment and malformed spec handling', () => {