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

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 (91) hide show
  1. package/bin/__tests__/codex-hooks.test.js +444 -14
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +615 -185
  4. package/bin/__tests__/installer-safety.test.js +39 -0
  5. package/bin/__tests__/opencode-spec-gate.test.js +24 -16
  6. package/bin/__tests__/package-inventory.test.js +687 -13
  7. package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
  8. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
  9. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
  10. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  11. package/bin/__tests__/validator-grounding.test.js +1914 -221
  12. package/bin/lib/managed-writer.js +12 -3
  13. package/bin/phases/codex-runtime.js +17 -1
  14. package/package.json +1 -1
  15. package/src/claude/CLAUDE.md +6 -0
  16. package/src/claude/agents/code-auditor.md +20 -0
  17. package/src/claude/agents/spec-maker.md +143 -99
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +291 -58
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
  20. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  21. package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
  22. package/src/claude/hooks/completion-authority-check.cjs +37 -288
  23. package/src/claude/hooks/completion-authority-state.cjs +10 -8
  24. package/src/claude/hooks/completion-authority.cjs +13 -7
  25. package/src/claude/hooks/docs-sync.cjs +5 -2
  26. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  27. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  28. package/src/claude/hooks/privacy-block.cjs +77 -30
  29. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  30. package/src/claude/hooks/spec-gate.cjs +47 -117
  31. package/src/claude/hooks/spec-state.cjs +8 -2
  32. package/src/claude/migration-manifest.json +9 -1
  33. package/src/claude/rules/state-sync.md +52 -21
  34. package/src/claude/rules/workflow.md +17 -9
  35. package/src/claude/scripts/spec-final-state.cjs +244 -0
  36. package/src/claude/scripts/spec-ground.cjs +535 -173
  37. package/src/claude/scripts/spec-readiness.cjs +118 -0
  38. package/src/claude/scripts/spec-receipt.cjs +162 -0
  39. package/src/claude/scripts/spec-resolver.cjs +67 -16
  40. package/src/claude/scripts/spec-scaffold.cjs +1354 -139
  41. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  42. package/src/claude/scripts/validate-spec-output.cjs +2782 -142
  43. package/src/claude/scripts/workflow-policy.cjs +501 -183
  44. package/src/claude/settings/settings.json +4 -0
  45. package/src/claude/skills/code-review/SKILL.md +12 -3
  46. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  47. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  48. package/src/claude/skills/develop/SKILL.md +47 -60
  49. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  50. package/src/claude/skills/develop/references/quality-gate.md +19 -12
  51. package/src/claude/skills/specs/SKILL.md +183 -177
  52. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  53. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  54. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  55. package/src/claude/skills/specs/references/review.md +200 -260
  56. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  57. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  58. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  59. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  60. package/src/claude/skills/specs/rules/design-review.md +42 -97
  61. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  62. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  63. package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
  64. package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
  65. package/src/claude/skills/specs/templates/design.md +49 -263
  66. package/src/claude/skills/specs/templates/requirements.md +26 -36
  67. package/src/claude/skills/specs/templates/research.md +9 -98
  68. package/src/claude/skills/specs/templates/spec-state.json +23 -85
  69. package/src/claude/skills/specs/templates/task.md +30 -102
  70. package/src/claude/skills/sync/SKILL.md +33 -10
  71. package/src/claude/skills/sync/references/sync-protocols.md +31 -13
  72. package/src/claude/skills/test/SKILL.md +17 -7
  73. package/src/codex/AGENTS.md +6 -0
  74. package/src/codex/hooks/completion-authority-check.cjs +43 -240
  75. package/src/codex/hooks/completion-authority-state.cjs +10 -8
  76. package/src/codex/hooks/completion-authority.cjs +13 -7
  77. package/src/codex/hooks/lib/hook-context.cjs +16 -2
  78. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  79. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  80. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  81. package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
  82. package/src/codex/hooks/lib/spec-utils.cjs +56 -5
  83. package/src/codex/hooks/privacy-approval.cjs +1 -1
  84. package/src/codex/hooks/privacy-block.cjs +67 -11
  85. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  86. package/src/codex/hooks/spec-gate.cjs +49 -34
  87. package/src/codex/hooks/spec-state.cjs +10 -3
  88. package/src/codex/hooks/state.cjs +2 -0
  89. package/src/codex/hooks.json +6 -1
  90. package/src/codex/rules/state-sync.md +47 -27
  91. package/src/common/AGENTS.md +1 -1
@@ -12,7 +12,15 @@ 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 { copyClaudeTestRuntime } = require('./test-runtime-dependency-closure.cjs');
15
21
  const DEVELOP = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/SKILL.md');
22
+ const SPECS = path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md');
23
+ const SCAFFOLD = path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-scaffold.cjs');
16
24
  const GATE = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/references/quality-gate.md');
17
25
  const TEST_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/test/SKILL.md');
18
26
  const SYNC_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/sync/SKILL.md');
@@ -49,6 +57,183 @@ function initFixtureGit(root) {
49
57
  }
50
58
  }
51
59
 
60
+ function installClaudeRuntimeClosure(fixtureRoot, entry = 'hooks/spec-gate.cjs') {
61
+ const destinationRoot = path.join(fixtureRoot, '.claude');
62
+ copyClaudeTestRuntime(PACKAGE_ROOT, destinationRoot);
63
+ const hooksRoot = path.join(PACKAGE_ROOT, 'src', 'claude', 'hooks');
64
+ for (const hook of [
65
+ 'spec-gate.cjs',
66
+ 'completion-authority-check.cjs',
67
+ 'completion-authority-state.cjs',
68
+ 'semantic-review-authority.cjs',
69
+ ]) {
70
+ const target = path.join(destinationRoot, 'hooks', hook);
71
+ fs.mkdirSync(path.dirname(target), { recursive: true });
72
+ fs.copyFileSync(path.join(hooksRoot, hook), target);
73
+ }
74
+ return path.join(destinationRoot, entry);
75
+ }
76
+
77
+ function markdownSection(content, heading) {
78
+ const marker = `## ${heading}`;
79
+ const start = content.indexOf(marker);
80
+ assert.notEqual(start, -1, `missing section ${heading}`);
81
+ const rest = content.slice(start + marker.length);
82
+ const end = rest.search(/\n## /);
83
+ return end === -1 ? rest : rest.slice(0, end);
84
+ }
85
+
86
+ function markdownTable(section, firstHeader) {
87
+ const lines = section.split('\n').filter((line) => line.trim().startsWith('|'));
88
+ const cells = (line) => line.split('|').slice(1, -1).map((cell) => cell.trim());
89
+ const headerIndex = lines.findIndex((line) => cells(line)[0] === firstHeader);
90
+ assert.notEqual(headerIndex, -1, `missing table headed by ${firstHeader}`);
91
+ const headers = cells(lines[headerIndex]);
92
+ const rows = {};
93
+ for (const line of lines.slice(headerIndex + 2)) {
94
+ const values = cells(line);
95
+ if (values.length !== headers.length) break;
96
+ rows[values[0].replaceAll('`', '')] = Object.fromEntries(headers.map((header, index) => [header, values[index]]));
97
+ }
98
+ return rows;
99
+ }
100
+
101
+ function runNode(root, script, args) {
102
+ return spawnSync(process.execPath, [script, ...args], { cwd: root, encoding: 'utf8' });
103
+ }
104
+
105
+ function commandOutput(result) {
106
+ return `${result.stdout}\n${result.stderr}`;
107
+ }
108
+
109
+ function createCanonicalAuthoringFixture() {
110
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-develop-contract-'));
111
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
112
+ fs.mkdirSync(path.join(root, 'test'), { recursive: true });
113
+ fs.mkdirSync(path.join(root, 'artifacts'), { recursive: true });
114
+ fs.writeFileSync(path.join(root, 'src', 'entry.js'), 'module.exports = { enabled: true };\n');
115
+ fs.writeFileSync(path.join(root, 'test', 'service.test.js'), 'require("node:test")("service", () => {});\n');
116
+ const scaffold = runNode(root, SCAFFOLD, ['feature']);
117
+ assert.equal(scaffold.status, 0, commandOutput(scaffold));
118
+ const specDir = path.join(root, 'specs', 'feature');
119
+ const specPath = path.join(specDir, 'spec.json');
120
+ fs.writeFileSync(path.join(specDir, 'requirements.md'), `# Requirements
121
+
122
+ ## Requirements
123
+ ### Requirement 1: Observable service behavior
124
+ - **R1.1**: When valid input arrives, the service shall return enabled state; invalid input shall return rejected state.
125
+ - **R1.2**: The verifier shall produce separate observable proof for enabled and rejected states.
126
+ `);
127
+ fs.writeFileSync(path.join(specDir, 'design.md'), `# Design
128
+
129
+ ## Architecture
130
+ The entrypoint exposes the bounded service behavior and a separate proof artifact.
131
+
132
+ ## Typed Anchors
133
+ | ID | Type | Target | Role | Access | Action |
134
+ |---|---|---|---|---|---|
135
+ | A-D-01 | file | \`src/entry.js\` | service boundary | read | read |
136
+ | A-D-02 | artifact | \`artifacts/design-proof.json\` | proof evidence | write | create |
137
+
138
+ ## Canonical Contracts & Invariants
139
+ ### D1 — Dispatch decision
140
+ The entrypoint dispatches exactly once.
141
+ ### I1 — Invalid-state invariant
142
+ Invalid input never returns enabled state.
143
+ ### C1 — Result contract
144
+ The result contains one enabled boolean.
145
+
146
+ ## Verification Definitions
147
+ - **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.
148
+ `);
149
+ return { root, specDir, specPath };
150
+ }
151
+
152
+ function createOrdinaryAuthoringFixture() {
153
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-ordinary-readiness-'));
154
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
155
+ fs.mkdirSync(path.join(root, 'test'), { recursive: true });
156
+ fs.writeFileSync(path.join(root, 'src', 'entry.js'), 'module.exports = { action: "delete" };\n');
157
+ fs.writeFileSync(path.join(root, 'test', 'entry.test.js'), 'require("node:test")("entry", () => {});\n');
158
+ const scaffold = runNode(root, SCAFFOLD, ['ordinary']);
159
+ assert.equal(scaffold.status, 0, commandOutput(scaffold));
160
+ const specDir = path.join(root, 'specs', 'ordinary');
161
+ const specPath = path.join(specDir, 'spec.json');
162
+ fs.writeFileSync(path.join(specDir, 'requirements.md'), `# Requirements
163
+
164
+ ## Requirements
165
+ ### Requirement 1: Delete behavior
166
+ - **R1.1**: When deletion is requested, the service shall delete the selected record and report deleted state.
167
+ `);
168
+ fs.writeFileSync(path.join(specDir, 'design.md'), `# Design
169
+
170
+ ## Typed Anchors
171
+ | ID | Type | Target | Role | Access | Action |
172
+ |---|---|---|---|---|---|
173
+ | A-D-01 | file | \`src/entry.js\` | behavior owner | read | read |
174
+
175
+ ## Canonical Contracts & Invariants
176
+ ### D1 — Delete decision
177
+ The selected record is deleted rather than archived.
178
+ ### I1 — State invariant
179
+ Deleted state is reported only after deletion succeeds.
180
+ ### C1 — Result contract
181
+ The result contains the literal deleted state.
182
+
183
+ ## Verification Definitions
184
+ - **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.
185
+ `);
186
+ const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
187
+ spec.authoring.requirements = 'validated';
188
+ spec.authoring.design = 'validated';
189
+ fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
190
+ initFixtureGit(root);
191
+ return { root, specDir, specPath };
192
+ }
193
+
194
+ function ordinaryReview() {
195
+ return {
196
+ reviewed_criteria: ['R1.1'],
197
+ counterexamples: [{
198
+ criterion: 'R1.1', case_kind: 'failure',
199
+ scenario: 'Deletion targets a record that does not exist in the current store.',
200
+ expected: 'The service returns not-found and never reports the record as deleted.',
201
+ decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
202
+ }],
203
+ };
204
+ }
205
+
206
+ function promoteCanonicalModel(fixture) {
207
+ const result = runNode(fixture.root, SCAFFOLD, ['feature', '--sync-semantic-model']);
208
+ assert.equal(result.status, 0, commandOutput(result));
209
+ }
210
+
211
+ function makeCanonicalReady(fixture) {
212
+ const spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
213
+ spec.authoring.requirements = 'validated';
214
+ spec.authoring.design = 'validated';
215
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
216
+ const result = READINESS.finalizeReadiness({
217
+ specDir: fixture.specDir,
218
+ projectRoot: fixture.root,
219
+ reviewResult: {
220
+ reviewed_criteria: ['R1.1', 'R1.2'],
221
+ counterexamples: [{
222
+ criterion: 'R1.1', case_kind: 'failure',
223
+ scenario: 'The service receives invalid input without its required discriminator.',
224
+ expected: 'The service returns rejected state and never emits enabled true.',
225
+ decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
226
+ }, {
227
+ criterion: 'R1.2', case_kind: 'adversarial',
228
+ scenario: 'The service reports enabled state without producing separate proof evidence.',
229
+ expected: 'Verification fails because the independent proof artifact is absent.',
230
+ decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
231
+ }],
232
+ },
233
+ });
234
+ return result.spec;
235
+ }
236
+
52
237
  function bindFixtureReceipt(root, value, feature = 'demo', session = 'test') {
53
238
  const context = PROVENANCE_HELPER.deriveRuntimeContext({
54
239
  projectRoot: root,
@@ -121,7 +306,7 @@ test('delegation plan consumes legacy tiers as obligations without an agent chai
121
306
  const standard = POLICY.delegationPlan({ tier: 'Standard', mode: 'specific-task' });
122
307
  const deep = POLICY.delegationPlan({ tier: 'Deep', mode: 'full-spec', taskCount: 2 });
123
308
  assert.deepEqual(light.proof_obligations, ['needsExecutionProof']);
124
- assert.deepEqual(standard.proof_obligations, ['needsInspection', 'needsExecutionProof']);
309
+ assert.deepEqual(standard.proof_obligations, ['needsExecutionProof']);
125
310
  assert.deepEqual(deep.proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit']);
126
311
  for (const plan of [light, standard, deep]) {
127
312
  assert.equal(Object.hasOwn(plan, 'delegated'), false);
@@ -144,31 +329,53 @@ test('lane classifier selects Direct for explicit reversible low-risk work', ()
144
329
  assert.equal(policy.proof_obligations.includes('needsDurableTaskState'), false);
145
330
  });
146
331
 
147
- test('lane classifier forces Critical for named high-risk signals', () => {
332
+ test('lane classifier raises named risks to Elevated without inventing independent-audit authority', () => {
148
333
  const result = POLICY.classifyLane({
149
334
  reversible: true,
150
335
  riskSignals: { auth: true, migration: true, publicContract: true },
151
336
  });
152
- assert.equal(result.lane, 'Critical');
337
+ assert.equal(result.lane, 'Standard');
338
+ assert.equal(result.assuranceLevel, 'Elevated');
339
+ assert.equal(result.automaticAssuranceLevel, 'Elevated');
153
340
  assert.deepEqual(result.risks, ['auth', 'migration', 'publicContract']);
154
- assert.deepEqual(POLICY.delegationPlan({ lane: 'Critical' }).proof_obligations, [
155
- 'needsInspection', 'needsExecutionProof', 'needsIndependentAudit',
341
+ assert.deepEqual(POLICY.lanePolicy(result).proof_obligations, [
342
+ 'needsInspection', 'needsExecutionProof',
156
343
  ]);
344
+
345
+ const strict = POLICY.classifyLane({
346
+ riskSignals: { auth: true },
347
+ assurance_level: 'Strict',
348
+ });
349
+ assert.equal(strict.lane, 'Critical');
350
+ assert.equal(strict.automaticLane, 'Standard');
351
+ assert.equal(strict.assuranceLevel, 'Strict');
352
+ assert.equal(strict.automaticAssuranceLevel, 'Elevated');
353
+ assert.ok(POLICY.lanePolicy(strict).proof_obligations.includes('needsIndependentAudit'));
157
354
  });
158
355
 
159
- test('P1 persists one strict workflow-policy snapshot and rejects malformed snapshots', () => {
356
+ test('P1 persists the minimal v2.1 policy and derives compatibility views', () => {
160
357
  const initial = POLICY.persistWorkflowPolicySnapshot(
161
- { feature_name: 'bounded', override_receipt: 'legacy-duplicate' },
358
+ { feature_name: 'bounded' },
162
359
  { riskSignals: {} },
163
360
  );
164
361
  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);
362
+ assert.deepEqual(Object.keys(snapshot).sort(), [...POLICY.CANONICAL_WORKFLOW_POLICY_FIELDS].sort());
363
+ assert.equal(snapshot.version, POLICY.CANONICAL_WORKFLOW_POLICY_VERSION);
364
+ assert.equal(snapshot.planning_depth, 'Compact');
365
+ assert.equal(snapshot.assurance_level, 'Routine');
366
+ assert.deepEqual(snapshot.classified_minimum, {
367
+ planning_depth: 'Compact',
368
+ assurance_level: 'Routine',
369
+ });
370
+ for (const derivedField of ['lane', 'automatic_lane', 'artifact_profile', 'proof_obligations', 'actor_needs']) {
371
+ assert.equal(Object.hasOwn(snapshot, derivedField), false, `${derivedField} must not be persisted`);
372
+ }
373
+ const view = POLICY.readWorkflowPolicySnapshot(initial);
374
+ assert.equal(view.lane, 'Standard');
375
+ assert.equal(view.automatic_lane, 'Standard');
376
+ assert.equal(view.artifact_profile, 'bounded');
377
+ assert.deepEqual(view.proof_obligations, ['needsExecutionProof']);
378
+ assert.equal(Object.hasOwn(initial, 'override_' + 'receipt'), false);
172
379
  assert.equal(POLICY.validateWorkflowPolicySnapshot(snapshot).valid, true);
173
380
 
174
381
  const persistedAgain = POLICY.persistWorkflowPolicySnapshot(initial, {
@@ -177,16 +384,16 @@ test('P1 persists one strict workflow-policy snapshot and rejects malformed snap
177
384
  });
178
385
  assert.deepEqual(persistedAgain.workflow_policy, snapshot, 'persist-once must not reclassify an existing snapshot');
179
386
 
180
- const malformed = { ...snapshot, artifact_profile: 'strict', extra: true };
387
+ const malformed = { ...snapshot, lane: 'Critical' };
181
388
  const validation = POLICY.validateWorkflowPolicySnapshot(malformed);
182
389
  assert.equal(validation.valid, false);
183
- assert.match(validation.errors.join('; '), /fields must be exactly|artifact_profile/);
390
+ assert.match(validation.errors.join('; '), /v2\.1 fields must be exactly/);
184
391
  assert.throws(() => POLICY.readWorkflowPolicySnapshot({ workflow_policy: malformed }), /Invalid workflow policy snapshot/);
185
392
 
186
- const malformedShape = { ...snapshot, proof_obligations: { needsExecutionProof: true } };
393
+ const malformedShape = { ...snapshot, classified_minimum: { planning_depth: 'Compact' } };
187
394
  const shapeValidation = POLICY.validateWorkflowPolicySnapshot(malformedShape);
188
395
  assert.equal(shapeValidation.valid, false);
189
- assert.match(shapeValidation.errors.join('; '), /proof_obligations must be an array/);
396
+ assert.match(shapeValidation.errors.join('; '), /classified_minimum must contain exactly/);
190
397
 
191
398
  const malformedRisks = { ...snapshot, risks: { auth: true } };
192
399
  const risksValidation = POLICY.validateWorkflowPolicySnapshot(malformedRisks);
@@ -197,26 +404,34 @@ test('P1 persists one strict workflow-policy snapshot and rejects malformed snap
197
404
  test('P1 workflow-policy validation is semantic and explicit workflow_policy values never reclassify', () => {
198
405
  const direct = POLICY.workflowPolicySnapshot({ reversible: true, lowRisk: true, isolated: true });
199
406
  const standardUnknown = POLICY.escalateWorkflowPolicy(direct, { risks: ['unclassified-risk'] });
200
- const criticalAuth = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
201
- const escalatedCritical = POLICY.escalateWorkflowPolicy(direct, { risks: ['auth'] });
407
+ const elevatedAuth = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
408
+ const escalatedElevated = POLICY.escalateWorkflowPolicy(direct, { risks: ['auth'] });
202
409
 
203
- for (const legitimate of [direct, standardUnknown, criticalAuth, escalatedCritical]) {
410
+ for (const legitimate of [direct, standardUnknown, elevatedAuth, escalatedElevated]) {
204
411
  assert.equal(POLICY.validateWorkflowPolicySnapshot(legitimate).valid, true);
205
412
  }
206
- assert.equal(escalatedCritical.lane, 'Critical');
207
- assert.equal(escalatedCritical.automatic_lane, 'Direct');
413
+ assert.equal(escalatedElevated.lane, 'Standard');
414
+ assert.equal(escalatedElevated.automatic_lane, 'Standard');
415
+ assert.equal(escalatedElevated.planning_depth, 'None');
416
+ assert.equal(escalatedElevated.assurance_level, 'Elevated');
208
417
 
209
418
  const forgedDirectAuth = { ...direct, risks: ['auth'] };
210
419
  const forgedDirectUnknown = { ...direct, risks: ['unclassified-risk'] };
211
420
  const forgedStandardAuth = { ...standardUnknown, risks: ['auth'] };
212
- for (const forged of [forgedDirectAuth, forgedDirectUnknown, forgedStandardAuth]) {
421
+ for (const forged of [forgedDirectAuth, forgedDirectUnknown]) {
213
422
  const result = POLICY.validateWorkflowPolicySnapshot(forged);
214
423
  assert.equal(result.valid, false);
215
- assert.match(result.errors.join('; '), /at least (Standard|Critical)/);
424
+ assert.match(result.errors.join('; '), /at least (Elevated|Strict)/);
216
425
  }
426
+ assert.equal(
427
+ POLICY.validateWorkflowPolicySnapshot(forgedStandardAuth).valid,
428
+ true,
429
+ 'changing one normalized risk to another does not invent a higher assurance minimum',
430
+ );
217
431
  for (const riskAlias of ['Auth', 'authentication', 'public_contract']) {
218
432
  const escalated = POLICY.escalateWorkflowPolicy(direct, { risks: [riskAlias] });
219
- assert.equal(escalated.lane, 'Critical', `${riskAlias} must classify as Critical`);
433
+ assert.equal(escalated.lane, 'Standard', `${riskAlias} must classify as Elevated`);
434
+ assert.equal(escalated.assurance_level, 'Elevated');
220
435
  assert.equal(POLICY.validateWorkflowPolicySnapshot({ ...direct, risks: [riskAlias] }).valid, false);
221
436
  }
222
437
  assert.throws(() => POLICY.assertWorkflowPolicySnapshot(forgedDirectAuth), /Invalid workflow policy snapshot/);
@@ -278,23 +493,33 @@ test('P1 workflow policy escalation is monotonic across all newly classified ris
278
493
 
279
494
  const standard = POLICY.escalateWorkflowPolicy(direct, { risks: ['new-standard-risk'], riskLevel: 'standard' });
280
495
  assert.equal(standard.lane, 'Standard');
281
- assert.equal(standard.artifact_profile, 'bounded');
496
+ assert.equal(standard.artifact_profile, 'targeted');
497
+ assert.equal(standard.planning_depth, 'None');
498
+ assert.equal(standard.assurance_level, 'Elevated');
282
499
  assert.equal(standard.risks.includes('new-standard-risk'), true);
283
500
  assert.deepEqual(standard.proof_obligations, ['needsInspection', 'needsExecutionProof']);
284
501
 
285
- const ambiguityCritical = POLICY.escalateWorkflowPolicy(direct, { riskSignals: { ambiguity: true } });
286
- assert.equal(ambiguityCritical.lane, 'Critical', 'every classified Critical risk must escalate to Critical');
502
+ const ambiguityElevated = POLICY.escalateWorkflowPolicy(direct, { riskSignals: { ambiguity: true } });
503
+ assert.equal(ambiguityElevated.lane, 'Standard', 'risk discovery must require inspection without inventing an audit');
504
+ assert.equal(ambiguityElevated.assurance_level, 'Elevated');
287
505
 
288
- const critical = POLICY.escalateWorkflowPolicy(standard, { risks: ['auth'] });
506
+ const criticalSeverity = POLICY.escalateWorkflowPolicy(direct, { risks: ['payment'], riskLevel: 'critical' });
507
+ assert.equal(criticalSeverity.assurance_level, 'Elevated', 'severity labels must not create independent-audit authority');
508
+ assert.equal(criticalSeverity.proof_obligations.includes('needsIndependentAudit'), false);
509
+
510
+ const critical = POLICY.escalateWorkflowPolicy(standard, { risks: ['auth'], assurance_level: 'Strict' });
289
511
  assert.equal(critical.lane, 'Critical');
290
- assert.equal(critical.artifact_profile, 'strict');
512
+ assert.equal(critical.artifact_profile, 'targeted');
513
+ assert.equal(critical.planning_depth, 'None');
514
+ assert.equal(critical.assurance_level, 'Strict');
515
+ assert.equal(critical.automatic_assurance_level, 'Elevated');
291
516
  assert.equal(critical.proof_obligations.includes('needsIndependentAudit'), true);
292
- assert.equal(critical.proof_obligations.includes('needsResearchGrounding'), true);
517
+ assert.equal(critical.proof_obligations.includes('needsResearchGrounding'), false);
293
518
 
294
519
  const noDowngrade = POLICY.escalateWorkflowPolicy(critical, { risks: ['another-standard-risk'], riskLevel: 'standard' });
295
520
  assert.equal(noDowngrade.lane, 'Critical');
296
521
  assert.equal(noDowngrade.proof_obligations.includes('needsIndependentAudit'), true);
297
- assert.equal(noDowngrade.proof_obligations.includes('needsResearchGrounding'), true);
522
+ assert.equal(noDowngrade.proof_obligations.includes('needsResearchGrounding'), false);
298
523
 
299
524
  const criticalAgain = POLICY.escalateWorkflowPolicy(critical, { risks: ['auth'] });
300
525
  assert.deepEqual(criticalAgain, critical, 'repeating known risks must not create a new escalation');
@@ -316,41 +541,57 @@ test('P1 legacy execution tier is read-compatible without becoming emitted polic
316
541
  assert.equal(POLICY.workflowPolicySnapshot({ design_context: { execution_tier: 'standard' } }).lane, 'Standard');
317
542
  });
318
543
 
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/);
544
+ test('legacy v1 policy is read-compatible, immutable on read, and explicitly migrates to v2.1', () => {
545
+ const cases = [
546
+ { lane: 'Direct', risks: [], obligations: ['needsExecutionProof'] },
547
+ { lane: 'Standard', risks: [], obligations: ['needsInspection', 'needsExecutionProof'] },
548
+ { lane: 'Critical', risks: ['auth'], obligations: ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit', 'needsResearchGrounding'] },
549
+ ];
550
+ for (const fixture of cases) {
551
+ const v1 = {
552
+ version: '1',
553
+ lane: fixture.lane,
554
+ automatic_lane: fixture.lane,
555
+ risks: fixture.risks,
556
+ artifact_profile: { Direct: 'targeted', Standard: 'bounded', Critical: 'strict' }[fixture.lane],
557
+ proof_obligations: fixture.obligations,
558
+ actor_needs: POLICY.actorNeedsFor(fixture.obligations),
559
+ override_receipt: null,
560
+ };
561
+ assert.equal(POLICY.validateWorkflowPolicySnapshot(v1).valid, true, fixture.lane);
562
+ const source = { feature_name: `legacy-${fixture.lane}`, workflow_policy: v1 };
563
+ const before = JSON.stringify(source);
564
+ const adapted = POLICY.readWorkflowPolicySnapshot(source);
565
+ assert.equal(adapted.version, '2');
566
+ assert.deepEqual(adapted.proof_obligations, fixture.obligations, fixture.lane);
567
+ assert.deepEqual(adapted.actor_needs, v1.actor_needs, fixture.lane);
568
+ assert.equal(POLICY.validateWorkflowPolicySnapshot(adapted).valid, true, fixture.lane);
569
+ assert.equal(JSON.stringify(source), before, 'read adapter must not mutate persisted v1 state');
570
+ const migrated = POLICY.persistWorkflowPolicySnapshot(source).workflow_policy;
571
+ assert.equal(migrated.version, POLICY.CANONICAL_WORKFLOW_POLICY_VERSION);
572
+ assert.deepEqual(Object.keys(migrated).sort(), [...POLICY.CANONICAL_WORKFLOW_POLICY_FIELDS].sort());
573
+ const migratedView = POLICY.readWorkflowPolicySnapshot({ workflow_policy: migrated });
574
+ assert.equal(migratedView.lane, adapted.lane, fixture.lane);
575
+ assert.equal(migratedView.artifact_profile, adapted.artifact_profile, fixture.lane);
576
+ assert.deepEqual(migratedView.proof_obligations, POLICY.obligationsForAssurance(
577
+ migrated.assurance_level,
578
+ migrated.risks,
579
+ ), fixture.lane);
580
+ }
346
581
  });
347
582
 
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/);
583
+ test('post-classification downgrade is unsupported', () => {
584
+ assert.throws(
585
+ () => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct' }),
586
+ (error) => /planning_depth downgrade/.test(error.message) && /not permitted/.test(error.message),
587
+ );
588
+ });
589
+
590
+ test('caller-requested downgrade is blocked before persistence', () => {
591
+ assert.throws(
592
+ () => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct' }),
593
+ (error) => /planning_depth downgrade/.test(error.message) && /not permitted/.test(error.message),
594
+ );
354
595
  // also via CLI
355
596
  const cli = spawnSync(process.execPath, [
356
597
  POLICY_PATH,
@@ -360,7 +601,7 @@ test('unsafe downgrade without user authorization is blocked', () => {
360
601
  '--json',
361
602
  ], { encoding: 'utf8' });
362
603
  assert.equal(cli.status, 2);
363
- assert.match(cli.stderr, /is blocked.*no trusted runtime issuer/);
604
+ assert.match(cli.stderr, /planning_depth downgrade.*not permitted/i);
364
605
  });
365
606
 
366
607
  test('forged approval via legacy approved field is rejected', () => {
@@ -380,14 +621,14 @@ test('forged approval via legacy approved field is rejected', () => {
380
621
  assert.match(legacyCheck.error, /Legacy/);
381
622
  });
382
623
 
383
- test('approval state never infers user approval from generated or agent validation', () => {
624
+ test('technical approval readiness ignores legacy user_approved', () => {
384
625
  const state = POLICY.approvalState({ generated: true, agent_validated: true });
385
626
  assert.equal(state.generated, true);
386
627
  assert.equal(state.agent_validated, true);
387
- assert.equal(state.user_approved, false);
388
- assert.equal(state.ready, false);
628
+ assert.equal(state.ready, true);
389
629
  assert.equal(state.schema_version, '2.0');
390
630
  assert.equal(POLICY.approvalState({ generated: true, agent_validated: true, user_approved: true }).ready, true);
631
+ assert.equal(POLICY.approvalState({ generated: true, agent_validated: true, user_approved: false }).ready, true);
391
632
  const cli = spawnSync(process.execPath, [
392
633
  POLICY_PATH,
393
634
  '--approval-state',
@@ -396,7 +637,7 @@ test('approval state never infers user approval from generated or agent validati
396
637
  '--json',
397
638
  ], { encoding: 'utf8' });
398
639
  assert.equal(cli.status, 0, `${cli.stdout}\n${cli.stderr}`);
399
- assert.equal(JSON.parse(cli.stdout).state.user_approved, false);
640
+ assert.equal(JSON.parse(cli.stdout).state.ready, true);
400
641
  });
401
642
 
402
643
  test('approval schema fails closed for explicit null, array, empty, and malformed states', () => {
@@ -422,7 +663,7 @@ test('approval schema fails closed for explicit null, array, empty, and malforme
422
663
  assert.throws(() => POLICY.approvalState({ schema_version: '1.0' }), /schema_version/);
423
664
  });
424
665
 
425
- test('CLI exposes JSON lane classification and develop has no universal spec hard gate', () => {
666
+ test('CLI exposes a derived lane and Develop consumes independent canonical policy axes', () => {
426
667
  const cli = spawnSync(process.execPath, [
427
668
  POLICY_PATH,
428
669
  '--classify-lane',
@@ -436,65 +677,59 @@ test('CLI exposes JSON lane classification and develop has no universal spec har
436
677
  assert.equal(payload.policy.requiresSpec, false);
437
678
  const develop = read(DEVELOP);
438
679
  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);
680
+ const policySection = develop.split('## Policy authority')[1]?.split('\n## ')[0] || '';
681
+ for (const authorityTerm of ['planning_depth', 'assurance_level', 'Risk', 'derived']) {
682
+ assert.ok(policySection.includes(authorityTerm), `policy authority missing ${authorityTerm}`);
683
+ }
684
+ for (const compatibilityView of ['**Direct**', '**Standard**', '**Critical**']) {
685
+ assert.ok(policySection.includes(compatibilityView), `missing derived view ${compatibilityView}`);
686
+ }
687
+ assert.ok(policySection.includes('derived Strict view'));
688
+ assert.ok(policySection.includes('Legacy approval fields'));
689
+ assert.ok(policySection.replace(/\s+/g, ' ').includes('zero authority'));
444
690
  assert.match(develop, /Classify lane[\s\S]*test receipt[\s\S]*docs-impact sync/i);
445
691
  assert.doesNotMatch(develop, /D2\[Step 3/);
446
692
  assert.match(develop, /--notes.*opt-in/i);
447
693
  assert.doesNotMatch(develop, /--no-notes/);
448
694
  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);
695
+ const fullRoutine = POLICY.readWorkflowPolicySnapshot({
696
+ workflow_policy: POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Full', assurance_level: 'Routine' }),
697
+ });
698
+ const compactStrict = POLICY.readWorkflowPolicySnapshot({
699
+ workflow_policy: POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Strict' }),
700
+ });
701
+ assert.equal(fullRoutine.planning_depth, 'Full');
702
+ assert.equal(fullRoutine.assurance_level, 'Routine');
703
+ assert.equal(compactStrict.planning_depth, 'Compact');
704
+ assert.equal(compactStrict.assurance_level, 'Strict');
705
+ assert.notEqual(fullRoutine.artifact_profile, compactStrict.artifact_profile);
706
+ assert.notDeepEqual(fullRoutine.proof_obligations, compactStrict.proof_obligations);
453
707
  });
454
708
 
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);
709
+ test('artifact router keeps optional expansion bound to uncertainty and typed topology', () => {
710
+ const router = markdownSection(read(SPECS), 'Artifact router');
711
+ const rows = markdownTable(router, 'Depth');
712
+ assert.deepEqual(Object.keys(rows), ['None', 'Compact', 'Full']);
713
+ assert.equal(rows.None['Durable core'], 'none');
714
+ for (const depth of ['Compact', 'Full']) {
715
+ assert.deepEqual(
716
+ rows[depth]['Durable core'].match(/`[^`]+`/g),
717
+ ['`spec.json`', '`requirements.md`', '`design.md`'],
718
+ `${depth} must share the canonical three-file core`,
719
+ );
720
+ assert.equal(rows[depth]['Research route'], 'only on a research trigger');
721
+ assert.equal(rows[depth]['Task route'], 'only on a typed topology trigger');
722
+ }
723
+ assert.notEqual(rows.Compact['Design expansion'], rows.Full['Design expansion']);
724
+ for (const falseTrigger of ['Requirement count', 'size', 'planning depth', 'assurance level', 'risk label', 'architectural layers']) {
725
+ assert.ok(router.includes(falseTrigger), `router must name inert trigger ${falseTrigger}`);
726
+ }
727
+ for (const boundary of ['ownership', 'dependency', 'transition', 'proof', 'parallel']) {
728
+ assert.ok(router.includes(boundary), `router must name typed topology ${boundary}`);
497
729
  }
730
+ assert.ok(router.includes('unresolved material uncertainty'));
731
+ assert.ok(router.includes('external-current fact'));
732
+ assert.ok(router.includes('explicit user request'));
498
733
  });
499
734
 
500
735
  test('canonical verdict adapter handles completion and unfinished decisions', () => {
@@ -525,7 +760,7 @@ test('canonical verdict adapter handles completion and unfinished decisions', ()
525
760
 
526
761
  test('completion decision requires canonical execution receipt and every workflow obligation', () => {
527
762
  const receipt = canonicalReceipt();
528
- const critical = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
763
+ const critical = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' });
529
764
  const audit = {
530
765
  schema_version: '1',
531
766
  reviewer_session_id: 'review-session-1',
@@ -619,16 +854,201 @@ test('completion decision requires canonical execution receipt and every workflo
619
854
  assert.ok(artifactBlocked.missingProof.some((item) => item.includes('artifact_hash')));
620
855
  });
621
856
 
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);
857
+ test('auto authoring reaches readiness only after promotion, validation, grounding, and current review', () => {
858
+ const fixture = createCanonicalAuthoringFixture();
859
+ try {
860
+ let spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
861
+ assert.equal(spec.semantic_model, null);
862
+ spec.ready_for_implementation = true;
863
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
864
+ let validation = VALIDATOR.validateSpec(fixture.specDir);
865
+ assert.ok(validation.errors.some((error) => error.includes('requires explicit promoted machine semantic authority')));
866
+
867
+ spec.ready_for_implementation = false;
868
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
869
+ promoteCanonicalModel(fixture);
870
+ spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
871
+ assert.ok(spec.semantic_model && spec.semantic_model.criteria.length === 2);
872
+ spec.ready_for_implementation = true;
873
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
874
+ validation = VALIDATOR.validateSpec(fixture.specDir);
875
+ assert.ok(validation.errors.some((error) => error.includes('readiness requires validated')));
876
+ assert.ok(validation.errors.some((error) => error.includes('requires completed semantic review')));
877
+
878
+ spec = makeCanonicalReady(fixture);
879
+ validation = VALIDATOR.validateSpec(fixture.specDir);
880
+ assert.deepEqual(validation.errors, [], validation.errors.join('\n'));
881
+ assert.equal(GROUNDER.groundSpec({ specDir: fixture.specDir, root: fixture.root, spec }).errors.length, 0);
882
+
883
+ const specs = read(SPECS);
884
+ const executableBlocks = [...specs.matchAll(/```bash\n([\s\S]*?)```/g)].map((match) => match[1]);
885
+ assert.ok(executableBlocks.every((block) => !/hapo:?develop/i.test(block)), 'Specs must never execute Develop');
886
+ const handoff = markdownSection(specs, 'Platform acceptance and handoff');
887
+ assert.ok(handoff.includes('After readiness'));
888
+ assert.ok(handoff.includes('/hapo:develop <feature>'));
889
+ assert.ok(handoff.includes('$hapo-develop <feature>'));
890
+ assert.ok(handoff.includes('must stop in Specs'));
891
+ } finally {
892
+ fs.rmSync(fixture.root, { recursive: true, force: true });
893
+ }
894
+ });
895
+
896
+ test('atomic readiness supports one-criterion Routine specs and binds semantic text', () => {
897
+ const fixture = createOrdinaryAuthoringFixture();
898
+ try {
899
+ const authored = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
900
+ authored.decisions = [
901
+ { 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.' },
902
+ { 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.' },
903
+ ];
904
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(authored, null, 2)}\n`);
905
+ const result = READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() });
906
+ assert.equal(result.spec.ready_for_implementation, true);
907
+ assert.equal(result.spec.semantic_model.criteria.length, 1);
908
+ assert.match(result.spec.semantic_model.criteria[0].text, /delete the selected record/);
909
+ assert.deepEqual(VALIDATOR.validateSpec(fixture.specDir).errors, []);
910
+
911
+ const beforeDigest = result.semantic_digest;
912
+ const design = path.join(fixture.specDir, 'design.md');
913
+ const originalDesign = fs.readFileSync(design, 'utf8');
914
+ fs.writeFileSync(design, originalDesign.replace('deleted rather than archived', 'archived rather than deleted'));
915
+ const decisionProjection = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-semantic-model.cjs')).modelFromMarkdown(fixture.specDir, result.spec);
916
+ assert.notDeepEqual(decisionProjection.model.design_records, result.spec.semantic_model.design_records);
917
+ const decisionDigest = VALIDATOR.computeSemanticDigest21(fixture.specDir, { ...result.spec, semantic_model: decisionProjection.model });
918
+ assert.equal(decisionDigest.errors.length, 0, decisionDigest.errors.join('\n'));
919
+ assert.notEqual(decisionDigest.digest, beforeDigest);
920
+ fs.writeFileSync(design, originalDesign);
921
+
922
+ const requirements = path.join(fixture.specDir, 'requirements.md');
923
+ fs.writeFileSync(requirements, fs.readFileSync(requirements, 'utf8').replace('delete the selected record', 'archive the selected record'));
924
+ const projection = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-semantic-model.cjs')).modelFromMarkdown(fixture.specDir, result.spec);
925
+ assert.notEqual(projection.model.criteria[0].text, result.spec.semantic_model.criteria[0].text);
926
+ const changed = VALIDATOR.computeSemanticDigest21(fixture.specDir, { ...result.spec, semantic_model: projection.model });
927
+ assert.equal(changed.errors.length, 0, changed.errors.join('\n'));
928
+ assert.notEqual(changed.digest, beforeDigest);
929
+ assert.ok(VALIDATOR.validateSpec(fixture.specDir).errors.some((error) => /projection|stale/.test(error)));
930
+ } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
931
+ });
932
+
933
+ test('atomic readiness preserves exact bytes on review, grounding, decision, and Strict authority failures', () => {
934
+ const fixture = createOrdinaryAuthoringFixture();
935
+ try {
936
+ const original = fs.readFileSync(fixture.specPath);
937
+ for (const mutate of [
938
+ (review) => ({ ...review, reviewed_criteria: [] }),
939
+ (review) => ({ ...review, counterexamples: [] }),
940
+ ]) {
941
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: mutate(ordinaryReview()) }), /readiness validation/);
942
+ assert.equal(fs.readFileSync(fixture.specPath).equals(original), true);
943
+ }
944
+ fs.unlinkSync(path.join(fixture.root, 'src', 'entry.js'));
945
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /grounding|validation/);
946
+ assert.equal(fs.readFileSync(fixture.specPath).equals(original), true);
947
+ fs.writeFileSync(path.join(fixture.root, 'src', 'entry.js'), 'module.exports = { action: "delete" };\n');
948
+
949
+ process.env.CAFEKIT_FINALIZE_FAIL_BEFORE_RENAME = '1';
950
+ try {
951
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /injected finalization failure/);
952
+ assert.equal(fs.readFileSync(fixture.specPath).equals(original), true);
953
+ } finally { delete process.env.CAFEKIT_FINALIZE_FAIL_BEFORE_RENAME; }
954
+
955
+ const blocked = JSON.parse(original);
956
+ blocked.decisions = [{ id: 'Q1', classification: 'user_owned', statement: 'Choose permanent deletion or retention archive behavior.', status: 'unresolved', evidence: null }];
957
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(blocked, null, 2)}\n`);
958
+ const blockedBytes = fs.readFileSync(fixture.specPath);
959
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /unresolved material user-owned decision/);
960
+ assert.equal(fs.readFileSync(fixture.specPath).equals(blockedBytes), true);
961
+
962
+ delete blocked.decisions;
963
+ blocked.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Strict' });
964
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(blocked, null, 2)}\n`);
965
+ const strictBytes = fs.readFileSync(fixture.specPath);
966
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /Strict readiness/);
967
+ assert.equal(fs.readFileSync(fixture.specPath).equals(strictBytes), true);
968
+ } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
969
+ });
970
+
971
+ test('Strict atomic readiness succeeds only after current allowlisted host observation', () => {
972
+ const fixture = createOrdinaryAuthoringFixture();
973
+ try {
974
+ const spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
975
+ spec.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Strict' });
976
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
977
+ const before = fs.readFileSync(fixture.specPath);
978
+ assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /Strict readiness/);
979
+ assert.equal(fs.readFileSync(fixture.specPath).equals(before), true);
980
+
981
+ const digest = VALIDATOR.computeSemanticDigest21(fixture.specDir, spec);
982
+ assert.equal(digest.errors.length, 0, digest.errors.join('\n'));
983
+ const authorityHome = path.join(fixture.root, 'authority-home');
984
+ const claim = `CAFEKIT_SEMANTIC_REVIEW_ATTESTATION ${JSON.stringify({ feature_name: 'ordinary', spec_file: 'specs/ordinary/spec.json', semantic_digest: digest.digest, verdict: 'PASS' })}`;
985
+ const observed = spawnSync(process.execPath, [path.join(PACKAGE_ROOT, 'src/claude/hooks/semantic-review-authority.cjs')], {
986
+ cwd: fixture.root,
987
+ env: { ...process.env, HOME: authorityHome, USERPROFILE: authorityHome, PROJECT_ROOT: fixture.root },
988
+ 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 }),
989
+ encoding: 'utf8',
990
+ });
991
+ assert.equal(observed.status, 0, observed.stderr);
992
+ const reviewFile = path.join(fixture.root, 'review.json');
993
+ fs.writeFileSync(reviewFile, `${JSON.stringify(ordinaryReview())}\n`);
994
+ const finalized = spawnSync(process.execPath, [path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-readiness.cjs'), fixture.specDir, '--review-result', reviewFile], {
995
+ cwd: fixture.root,
996
+ env: { ...process.env, HOME: authorityHome, USERPROFILE: authorityHome, PROJECT_ROOT: fixture.root },
997
+ encoding: 'utf8',
998
+ });
999
+ assert.equal(finalized.status, 0, `${finalized.stdout}\n${finalized.stderr}`);
1000
+ assert.equal(JSON.parse(fs.readFileSync(fixture.specPath, 'utf8')).ready_for_implementation, true);
1001
+ } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
1002
+ });
1003
+
1004
+ test('done is a final-state request bound to current semantic model and digest', () => {
1005
+ const fixture = createCanonicalAuthoringFixture();
1006
+ try {
1007
+ promoteCanonicalModel(fixture);
1008
+ const spec = makeCanonicalReady(fixture);
1009
+ spec.status = 'done';
1010
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
1011
+ const candidate = {
1012
+ featureDir: fixture.specDir,
1013
+ featureName: 'feature',
1014
+ specFile: fixture.specPath,
1015
+ spec,
1016
+ };
1017
+ const dependencies = () => ({
1018
+ validator: VALIDATOR,
1019
+ grounder: GROUNDER,
1020
+ semanticAuthority: SEMANTIC_AUTHORITY,
1021
+ });
1022
+ const current = FINAL_STATE.validateCanonicalFinalState({
1023
+ policy: POLICY, projectRoot: fixture.root, candidate, dependencies,
1024
+ });
1025
+ assert.equal(current.ok, true, current.reason);
1026
+ assert.equal(current.semanticDigest, spec.validation.semantic_review.semantic_digest);
1027
+
1028
+ const requirementsPath = path.join(fixture.specDir, 'requirements.md');
1029
+ const requirements = fs.readFileSync(requirementsPath, 'utf8');
1030
+ fs.writeFileSync(requirementsPath, requirements.replace('invalid input shall return rejected state', 'invalid input shall return a deterministic rejected state'));
1031
+ const staleDigest = FINAL_STATE.validateCanonicalFinalState({
1032
+ policy: POLICY, projectRoot: fixture.root, candidate, dependencies,
1033
+ });
1034
+ assert.equal(staleDigest.ok, false);
1035
+ assert.match(staleDigest.reason, /stale|differs from Markdown projection/i);
1036
+
1037
+ fs.writeFileSync(requirementsPath, requirements);
1038
+ const staleModel = structuredClone(spec);
1039
+ staleModel.semantic_model.criteria[0].owner = 'A-D-02';
1040
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(staleModel, null, 2)}\n`);
1041
+ const invalidModel = FINAL_STATE.validateCanonicalFinalState({
1042
+ policy: POLICY,
1043
+ projectRoot: fixture.root,
1044
+ candidate: { ...candidate, spec: staleModel },
1045
+ dependencies,
1046
+ });
1047
+ assert.equal(invalidModel.ok, false);
1048
+ assert.match(invalidModel.reason, /semantic projection|semantic_model|validation failed/i);
1049
+ } finally {
1050
+ fs.rmSync(fixture.root, { recursive: true, force: true });
1051
+ }
632
1052
  });
633
1053
 
634
1054
  test('flash PASS promotes proof; only trusted sync-finalize completes', () => {
@@ -665,22 +1085,19 @@ test('flash PASS promotes proof; only trusted sync-finalize completes', () => {
665
1085
 
666
1086
  test('spec-gate rejects stale FLASH_UNVERIFIED done state', () => {
667
1087
  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'));
1088
+ const specGate = installClaudeRuntimeClosure(root);
1089
+ assert.equal(fs.existsSync(path.join(root, '.claude', 'scripts', 'spec-final-state.cjs')), true);
1090
+ assert.equal(fs.existsSync(path.join(root, '.claude', 'scripts', 'validate-spec-output.cjs')), true);
675
1091
  fs.mkdirSync(path.join(root, 'specs', 'demo', 'tasks'), { recursive: true });
676
1092
  fs.writeFileSync(path.join(root, 'specs', 'demo', 'spec.json'), JSON.stringify({
677
1093
  status: 'in_progress',
1094
+ current_phase: 'closeout',
678
1095
  feature_name: 'demo',
679
1096
  task_registry: { 'tasks/task.md': { status: 'done', receipt: 'FLASH_UNVERIFIED' } },
680
1097
  }));
681
1098
  initFixtureGit(root);
682
1099
  try {
683
- const result = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
1100
+ const result = spawnSync(process.execPath, [specGate], {
684
1101
  cwd: root,
685
1102
  env: { ...process.env, PROJECT_ROOT: root },
686
1103
  input: JSON.stringify({ cwd: root, session_id: 'test' }),
@@ -698,24 +1115,21 @@ test('spec-gate rejects stale FLASH_UNVERIFIED done state', () => {
698
1115
  test('first-run cache bypass is blocked - canonical receipt required even without cache', () => {
699
1116
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-first-run-'));
700
1117
  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'));
1118
+ const specGate = installClaudeRuntimeClosure(root);
707
1119
  const specDir = path.join(root, 'specs', 'demo', 'tasks');
708
1120
  fs.mkdirSync(specDir, { recursive: true });
709
1121
  // Done task without canonical receipt (missing Command, provenance)
710
1122
  fs.writeFileSync(path.join(root, 'specs', 'demo', 'spec.json'), JSON.stringify({
711
- status: 'in_progress',
1123
+ status: 'done',
1124
+ current_phase: 'closeout',
712
1125
  feature_name: 'demo',
713
- task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } },
1126
+ task_registry: { 'tasks/task-R1-01-one.md': { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } },
714
1127
  }));
715
- fs.writeFileSync(path.join(specDir, 'task.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\n```\nnpm test\n```\n');
1128
+ 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
1129
  initFixtureGit(root);
717
1130
  try {
718
- const result = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
1131
+ assert.equal(fs.existsSync(path.join(claude, 'hooks', '.logs', 'spec-gate-last.json')), false);
1132
+ const result = spawnSync(process.execPath, [specGate], {
719
1133
  cwd: root,
720
1134
  env: { ...process.env, PROJECT_ROOT: root },
721
1135
  input: JSON.stringify({ cwd: root, session_id: 'test' }),
@@ -724,15 +1138,25 @@ test('first-run cache bypass is blocked - canonical receipt required even withou
724
1138
  assert.equal(result.status, 0);
725
1139
  const payload = JSON.parse(result.stdout);
726
1140
  assert.equal(payload.decision, 'block', 'first-run without canonical receipt must block');
727
- assert.match(payload.reason, /tasks\/task\.md/);
1141
+ assert.match(payload.reason, /tasks\/task-R1-01-one\.md/);
1142
+ assert.match(payload.reason, /receipt|command|provenance/i);
728
1143
  // 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'));
1144
+ 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');
1145
+ fs.mkdirSync(path.join(root, 'specs', 'demo', 'receipts'), { recursive: true });
1146
+ fs.writeFileSync(
1147
+ path.join(root, 'specs', 'demo', 'receipts', 'task-R1-01-one.md'),
1148
+ 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'),
1149
+ );
1150
+ fs.writeFileSync(
1151
+ path.join(root, 'specs', 'demo', 'feature-receipt.md'),
1152
+ 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'),
1153
+ );
730
1154
  // Clear cache to simulate fresh first-run with valid receipt
731
1155
  try { fs.unlinkSync(path.join(claude, 'hooks', '.logs', 'spec-gate-last.json')); } catch {}
732
1156
  try { fs.unlinkSync(path.join(__dirname, '..', '.logs', 'spec-gate-last.json')); } catch {}
733
1157
  // Need to clear the cache that the hook uses (under claude hooks .logs) - we already cleared above
734
1158
  // Re-run with valid receipt - should not block
735
- const result2 = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
1159
+ const result2 = spawnSync(process.execPath, [specGate], {
736
1160
  cwd: root,
737
1161
  env: { ...process.env, PROJECT_ROOT: root },
738
1162
  input: JSON.stringify({ cwd: root, session_id: 'test' }),
@@ -986,7 +1410,7 @@ test('runtime provenance derives exact Git evidence, CLI context, and stale/forg
986
1410
  }
987
1411
  });
988
1412
 
989
- test('lane traces - Direct, Standard, Critical classification, override, state mutation and completion', () => {
1413
+ test('lane traces - Direct, Standard, explicit Strict classification, override, state mutation and completion', () => {
990
1414
  // Direct: isolated reversible low-risk
991
1415
  const direct = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true, taskCount: 1 });
992
1416
  assert.equal(direct.lane, 'Direct');
@@ -997,29 +1421,30 @@ test('lane traces - Direct, Standard, Critical classification, override, state m
997
1421
  // Standard: default
998
1422
  const standard = POLICY.classifyLane({ title: 'add pagination to list', taskCount: 1 });
999
1423
  assert.equal(standard.lane, 'Standard');
1000
- assert.deepEqual(POLICY.lanePolicy(standard).proof_obligations, ['needsInspection', 'needsExecutionProof']);
1424
+ assert.deepEqual(POLICY.lanePolicy(standard).proof_obligations, ['needsExecutionProof']);
1001
1425
  assert.equal(POLICY.lanePolicy(standard).shipPoint, 'feature');
1002
- // Critical: auth signal
1003
- const critical = POLICY.classifyLane({ riskSignals: { auth: true }, taskCount: 1 });
1426
+ // Risk alone is Elevated; Critical requires an explicit Strict assurance choice.
1427
+ const elevated = POLICY.classifyLane({ riskSignals: { auth: true }, taskCount: 1 });
1428
+ assert.equal(elevated.lane, 'Standard');
1429
+ assert.deepEqual(POLICY.lanePolicy(elevated).proof_obligations, ['needsInspection', 'needsExecutionProof']);
1430
+ const critical = POLICY.classifyLane({ riskSignals: { auth: true }, assurance_level: 'Strict', taskCount: 1 });
1004
1431
  assert.equal(critical.lane, 'Critical');
1005
1432
  assert.ok(critical.risks.includes('auth'));
1006
- assert.deepEqual(POLICY.lanePolicy(critical).proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit', 'needsResearchGrounding']);
1433
+ assert.deepEqual(POLICY.lanePolicy(critical).proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit']);
1007
1434
  // Override: Direct requesting Critical is allowed (upgrade) without extra auth
1008
1435
  const upgrade = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true, taskCount: 1, override: 'Critical' });
1009
1436
  assert.equal(upgrade.lane, 'Critical');
1010
1437
  assert.equal(upgrade.automaticLane, 'Direct');
1011
1438
  // 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/);
1439
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard' }), /assurance_level downgrade.*blocked/i);
1440
+ // Caller-owned booleans cannot weaken the classified minimum.
1441
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', userAuthorized: true }), /assurance_level downgrade.*blocked/i);
1442
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', user_approved: true }), /assurance_level downgrade.*blocked/i);
1018
1443
  // State mutation: approvalState
1019
1444
  const pendingApproval = POLICY.approvalState({ generated: true, agent_validated: false, user_approved: false });
1020
1445
  assert.equal(pendingApproval.ready, false);
1021
1446
  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');
1447
+ assert.equal(agentValidated.ready, true, 'generated plus agent validation is technical readiness');
1023
1448
  const approved = POLICY.approvalState({ generated: true, agent_validated: true, user_approved: true });
1024
1449
  assert.equal(approved.ready, true);
1025
1450
  // Completion: flash work remains in_progress and does not unblock
@@ -1946,24 +2371,29 @@ test('P0 Claude and Codex reject configured specs roots outside the project', ()
1946
2371
  }
1947
2372
  });
1948
2373
 
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);
2374
+ test('P0 caller-owned downgrade flags have no canonical capability', () => {
2375
+ for (const callerClaim of [{ user_approved: true }, { userApproved: true }, { userAuthorized: true }]) {
2376
+ assert.throws(
2377
+ () => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', ...callerClaim }),
2378
+ (error) => /downgrade/.test(error.message) && /not permitted/.test(error.message),
2379
+ );
2380
+ }
2381
+ assert.throws(() => POLICY.classifyLane({ riskSignals: { auth: true }, override: 'Standard', confirmDowngrade: true }), /assurance_level downgrade.*blocked/i);
2382
+ assert.equal(POLICY.isUserAuthorizedForDowngrade, undefined);
1958
2383
  });
1959
2384
 
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);
2385
+ test('P0 canonical policy exposes no caller-owned downgrade authority', () => {
2386
+ assert.equal(POLICY.isUserAuthorizedForDowngrade, undefined);
2387
+ assert.equal(POLICY.isValidOverrideReceipt, undefined);
2388
+ assert.equal(POLICY.CANONICAL_WORKFLOW_POLICY_FIELDS.includes('override_' + 'receipt'), false);
2389
+ assert.throws(
2390
+ () => POLICY.classifyLane({
2391
+ riskSignals: { privacy: true },
2392
+ override: 'Direct',
2393
+ overrideReceipt: { verifiedByRuntime: true },
2394
+ }),
2395
+ (error) => /downgrade/.test(error.message) && /not permitted/.test(error.message),
2396
+ );
1967
2397
  });
1968
2398
 
1969
2399
  test('P0 active spec deterministic: multiple active ambiguity and explicit target/path containment', () => {
@@ -2056,7 +2486,7 @@ test('P0 regression: placeholder, explicit failure, artifact and lanePolicy forg
2056
2486
  const POLICY_PATH = path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs');
2057
2487
  const forged = spawnSync(process.execPath, [POLICY_PATH, '--lane-policy', '--task-json', JSON.stringify({ lane: 'Direct', automaticLane: 'Direct', riskSignals: { auth: true } }), '--json'], { encoding: 'utf8' });
2058
2488
  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/);
2489
+ assert.match(forged.stderr, /downgrade.*not permitted/i);
2060
2490
  const forged2 = spawnSync(process.execPath, [POLICY_PATH, '--lane-policy', '--task-json', JSON.stringify({ lane: 'Direct', automaticLane: 'Direct' }), '--json'], { encoding: 'utf8' });
2061
2491
  assert.equal(forged2.status, 2, 'plain Direct without justification should be blocked');
2062
2492
 
@@ -2067,7 +2497,7 @@ test('P0 regression: placeholder, explicit failure, artifact and lanePolicy forg
2067
2497
  assert.throws(() => { 'use strict'; trusted.lane = 'Direct'; }, /read only|Cannot assign|TypeError/);
2068
2498
  const paymentTrusted = POLICY.classifyLane({ riskSignals: { payment: true } });
2069
2499
  const forgedCopy = { ...paymentTrusted, lane: 'Direct' };
2070
- assert.throws(() => POLICY.lanePolicy(forgedCopy), /is blocked/);
2500
+ assert.equal(POLICY.lanePolicy(forgedCopy).lane, 'Standard', 'compatibility lane is derived from authoritative axes');
2071
2501
  });
2072
2502
 
2073
2503
  test('P0 regression: symlink spec/task containment and malformed spec handling', () => {