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

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 (49) hide show
  1. package/bin/__tests__/codex-hooks.test.js +355 -12
  2. package/bin/__tests__/codex-native.test.js +1 -1
  3. package/bin/__tests__/develop-contract.test.js +1374 -78
  4. package/bin/__tests__/installer-safety.test.js +43 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +791 -0
  6. package/bin/__tests__/package-inventory.test.js +157 -0
  7. package/bin/__tests__/validator-grounding.test.js +707 -2
  8. package/package.json +1 -1
  9. package/src/claude/agents/spec-maker.md +107 -200
  10. package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
  11. package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
  12. package/src/claude/hooks/completion-authority-check.cjs +307 -0
  13. package/src/claude/hooks/completion-authority-state.cjs +525 -0
  14. package/src/claude/hooks/completion-authority.cjs +148 -0
  15. package/src/claude/hooks/session.cjs +33 -19
  16. package/src/claude/hooks/spec-gate.cjs +173 -61
  17. package/src/claude/hooks/spec-state.cjs +104 -45
  18. package/src/claude/migration-manifest.json +6 -1
  19. package/src/claude/scripts/provenance.cjs +315 -0
  20. package/src/claude/scripts/spec-resolver.cjs +450 -0
  21. package/src/claude/scripts/spec-scaffold.cjs +716 -41
  22. package/src/claude/scripts/validate-spec-output.cjs +355 -53
  23. package/src/claude/scripts/workflow-policy.cjs +1529 -162
  24. package/src/claude/settings/settings.json +8 -0
  25. package/src/claude/skills/code-review/SKILL.md +85 -66
  26. package/src/claude/skills/develop/SKILL.md +153 -285
  27. package/src/claude/skills/develop/references/quality-gate.md +116 -148
  28. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  29. package/src/claude/skills/specs/SKILL.md +170 -381
  30. package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
  31. package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
  32. package/src/claude/skills/specs/templates/spec-state.json +23 -2
  33. package/src/claude/skills/specs/templates/task.md +20 -0
  34. package/src/claude/skills/sync/SKILL.md +15 -8
  35. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  36. package/src/claude/skills/test/SKILL.md +141 -167
  37. package/src/codex/hooks/completion-authority-check.cjs +260 -0
  38. package/src/codex/hooks/completion-authority-state.cjs +525 -0
  39. package/src/codex/hooks/completion-authority.cjs +100 -0
  40. package/src/codex/hooks/lib/hook-context.cjs +42 -2
  41. package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
  42. package/src/codex/hooks/lib/spec-utils.cjs +53 -27
  43. package/src/codex/hooks/session.cjs +2 -0
  44. package/src/codex/hooks/spec-gate.cjs +132 -20
  45. package/src/codex/hooks/spec-state.cjs +56 -10
  46. package/src/codex/hooks.json +10 -0
  47. package/src/opencode/AGENTS.md +4 -2
  48. package/src/opencode/plugins/spec-gate.ts +296 -122
  49. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -1,15 +1,70 @@
1
1
  'use strict';
2
2
 
3
- const REVIEW_VERDICTS = Object.freeze(['PASS', 'FAIL', 'BLOCKED']);
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const PROVENANCE = require('./provenance.cjs');
7
+
8
+ const CANONICAL_VERDICTS = Object.freeze(['PASS', 'PASS_WITH_WARNINGS', 'FAIL', 'BLOCKED']);
9
+ const LEGACY_DIAGNOSTIC_VERDICTS = Object.freeze(['PARTIAL', 'NO_TESTS']);
10
+ const REVIEW_VERDICTS = CANONICAL_VERDICTS;
4
11
  const EXECUTION_TIERS = Object.freeze(['Light', 'Standard', 'Deep']);
5
12
  const LANES = Object.freeze(['Direct', 'Standard', 'Critical']);
6
- const DEEP_TASK_SEQUENCE = Object.freeze(['inspector', 'implementer', 'test-runner', 'code-auditor']);
7
- const CRITICAL_LANE_SEQUENCE = Object.freeze(['inspector', 'implementer', 'test-runner', 'code-auditor']);
8
- const LANE_DELEGATION = Object.freeze({
9
- Direct: Object.freeze([]),
10
- Standard: Object.freeze(['code-auditor']),
11
- Critical: CRITICAL_LANE_SEQUENCE,
13
+ const ARTIFACT_PROFILES = Object.freeze(['targeted', 'bounded', 'strict']);
14
+ const PROOF_OBLIGATIONS = Object.freeze([
15
+ 'needsInspection',
16
+ 'needsExecutionProof',
17
+ 'needsIndependentAudit',
18
+ 'needsDurableTaskState',
19
+ 'needsResearchGrounding',
20
+ ]);
21
+ const WORKFLOW_POLICY_VERSION = '1';
22
+ const WORKFLOW_POLICY_FIELDS = Object.freeze([
23
+ 'version',
24
+ 'lane',
25
+ 'automatic_lane',
26
+ 'risks',
27
+ 'artifact_profile',
28
+ 'proof_obligations',
29
+ 'actor_needs',
30
+ 'override_receipt',
31
+ ]);
32
+ const RECEIPT_BINDING_FIELDS = Object.freeze(['expectedProvenance', 'requireProvenanceBinding']);
33
+ const INDEPENDENT_AUDIT_SCHEMA_VERSION = '1';
34
+ const INDEPENDENT_AUDIT_FIELDS = Object.freeze([
35
+ 'schema_version',
36
+ 'reviewer_session_id',
37
+ 'implementation_session_id',
38
+ 'expected_provenance',
39
+ 'evidence',
40
+ 'verdict',
41
+ ]);
42
+ const FLASH_PROMOTION_FIELDS = Object.freeze(['readyForSync', 'flashTransition', 'promotionReceipt']);
43
+ const LEGACY_TIER_LANES = Object.freeze({ Light: 'Direct', Standard: 'Standard', Deep: 'Critical' });
44
+ const ARTIFACT_PROFILE_BY_LANE = Object.freeze({ Direct: 'targeted', Standard: 'bounded', Critical: 'strict' });
45
+ const ACTOR_NEEDS_BY_OBLIGATION = Object.freeze({
46
+ needsInspection: Object.freeze({ capability: 'inspection', independence: 'same-session' }),
47
+ needsExecutionProof: Object.freeze({ capability: 'execution-proof', independence: 'same-session' }),
48
+ needsIndependentAudit: Object.freeze({ capability: 'audit', independence: 'independent' }),
49
+ needsDurableTaskState: Object.freeze({ capability: 'durable-task-state', independence: 'same-session' }),
50
+ needsResearchGrounding: Object.freeze({ capability: 'research-grounding', independence: 'same-session' }),
12
51
  });
52
+ const ACTOR_CAPABILITIES = new Set(Object.values(ACTOR_NEEDS_BY_OBLIGATION).map((need) => need.capability));
53
+ const ACTOR_INDEPENDENCE = new Set(['same-session', 'independent']);
54
+ const CRITICAL_RISKS = new Set([
55
+ 'reversibility',
56
+ 'destructive',
57
+ 'auth',
58
+ 'payment',
59
+ 'privacy',
60
+ 'data',
61
+ 'schema',
62
+ 'migration',
63
+ 'publicContract',
64
+ 'crossRuntime',
65
+ 'ambiguity',
66
+ 'rollback',
67
+ ]);
13
68
  const LANE_RISK_KEYS = Object.freeze({
14
69
  reversibility: ['reversible', 'reversibility', 'irreversible', 'non-reversible'],
15
70
  destructive: ['destructive', 'deletion', 'delete', 'destroy'],
@@ -25,6 +80,342 @@ const LANE_RISK_KEYS = Object.freeze({
25
80
  rollback: ['rollback', 'rollback difficulty', 'hard to rollback', 'cannot rollback', 'no rollback'],
26
81
  });
27
82
 
83
+ function hasOwn(value, key) {
84
+ return value !== null
85
+ && (typeof value === 'object' || typeof value === 'function')
86
+ && Object.prototype.hasOwnProperty.call(value, key);
87
+ }
88
+
89
+ const PLACEHOLDER_TOKENS = new Set(['TBD','TODO','N/A','NA','NONE','UNKNOWN','PENDING','PLACEHOLDER','REPLACE_ME','-','?']);
90
+ const TAP_METADATA_HEADING_RE = /^\s*#\s*(?:(?:tests?|suites?|pass|fail|cancel(?:l)?ed|skipped|todo)\s+\d+|duration(?:_ms)?\s+\d+(?:\.\d+)?)\s*$/i;
91
+ const ARTIFACT_DECLARATION_KEY = 'artifacts';
92
+ const TASK_ARTIFACT_KEYS = Object.freeze(['artifact', 'artifact_ref', 'artifact_path']);
93
+ const PROVENANCE_VALUE_RE = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
94
+ const PROSE_RECEIPT_FIELD_RE = /^\s*(?:Notes?|Description|Command(?:\(s\))?)\s*:/i;
95
+ function isPlaceholderToken(value) {
96
+ if (typeof value !== 'string') return true;
97
+ const v = value.trim();
98
+ if (v === '') return true;
99
+ if (/^\{\{.*\}\}$/.test(v)) return true;
100
+ if (/^<.*>$/.test(v)) return true;
101
+ const upper = v.toUpperCase();
102
+ if (PLACEHOLDER_TOKENS.has(upper)) return true;
103
+ return false;
104
+ }
105
+ function isTapMetadataHeading(line) {
106
+ return typeof line === 'string' && TAP_METADATA_HEADING_RE.test(line);
107
+ }
108
+
109
+ function hasConcreteArtifactDeclaration(value) {
110
+ if (typeof value === 'string') return !isPlaceholderToken(value);
111
+ if (Array.isArray(value)) return value.some(hasConcreteArtifactDeclaration);
112
+ return Boolean(value && typeof value === 'object');
113
+ }
114
+
115
+ function isSafeArtifactPath(value) {
116
+ if (typeof value !== 'string' || value !== value.trim() || isPlaceholderToken(value)) return false;
117
+ if (/^(?:[a-z]:[\\/]|[\\/]|https?:\/\/)/i.test(value)) return false;
118
+ return !value.split(/[\\/]+/).includes('..');
119
+ }
120
+
121
+ function isPathInside(root, target) {
122
+ const relative = path.relative(path.resolve(root), path.resolve(target));
123
+ return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
124
+ }
125
+
126
+ function stableStat(stat) {
127
+ return [stat.dev, stat.ino, stat.size, stat.mtimeNs?.toString(), stat.ctimeNs?.toString()].join(':');
128
+ }
129
+
130
+ function hashValidatedArtifact(root, relativePath, expectedHash) {
131
+ if (!isSafeArtifactPath(relativePath) || typeof root !== 'string' || root.trim() === '') return false;
132
+ const rootPath = path.resolve(root);
133
+ const target = path.resolve(rootPath, relativePath);
134
+ if (!isPathInside(rootPath, target)) return false;
135
+
136
+ const segments = path.relative(rootPath, target).split(path.sep).filter(Boolean);
137
+ let current = rootPath;
138
+ try {
139
+ for (let index = 0; index < segments.length; index += 1) {
140
+ current = path.join(current, segments[index]);
141
+ const stat = fs.lstatSync(current);
142
+ if (stat.isSymbolicLink()) return false;
143
+ if (index < segments.length - 1 && !stat.isDirectory()) return false;
144
+ }
145
+ const rootReal = fs.realpathSync(rootPath);
146
+ const targetReal = fs.realpathSync(target);
147
+ if (!isPathInside(rootReal, targetReal)) return false;
148
+ const before = fs.statSync(target);
149
+ if (!before.isFile()) return false;
150
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
151
+ const fd = fs.openSync(target, flags);
152
+ try {
153
+ const opened = fs.fstatSync(fd);
154
+ if (!opened.isFile() || stableStat(opened) !== stableStat(before)) return false;
155
+ const bytes = fs.readFileSync(fd);
156
+ const after = fs.fstatSync(fd);
157
+ if (stableStat(after) !== stableStat(opened)) return false;
158
+ const actual = crypto.createHash('sha256').update(bytes).digest('hex');
159
+ return actual.toLowerCase() === String(expectedHash).toLowerCase();
160
+ } finally {
161
+ fs.closeSync(fd);
162
+ }
163
+ } catch (_) {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ function artifactDeclaration(task = {}) {
169
+ if (Object.prototype.hasOwnProperty.call(task, ARTIFACT_DECLARATION_KEY)) {
170
+ const artifacts = task[ARTIFACT_DECLARATION_KEY];
171
+ const unique = Array.isArray(artifacts) ? new Set(artifacts).size === artifacts.length : false;
172
+ return {
173
+ declared: true,
174
+ valid: Array.isArray(artifacts)
175
+ && artifacts.length > 0
176
+ && unique
177
+ && artifacts.every(isSafeArtifactPath),
178
+ paths: Array.isArray(artifacts) ? [...artifacts] : [],
179
+ };
180
+ }
181
+
182
+ // Read legacy aliases only for compatibility with pre-P0 task registries.
183
+ const legacyKey = TASK_ARTIFACT_KEYS.find((key) => hasConcreteArtifactDeclaration(task?.[key]));
184
+ const legacyValue = legacyKey ? task[legacyKey] : null;
185
+ const legacyPaths = typeof legacyValue === 'string'
186
+ ? [legacyValue]
187
+ : Array.isArray(legacyValue) && legacyValue.every((value) => typeof value === 'string')
188
+ ? [...legacyValue]
189
+ : [];
190
+ return {
191
+ declared: Boolean(legacyKey),
192
+ valid: !legacyKey || (legacyPaths.length > 0 && legacyPaths.every(isSafeArtifactPath)),
193
+ legacy: true,
194
+ paths: legacyPaths,
195
+ };
196
+ }
197
+
198
+ function normalizeProvenanceValue(value) {
199
+ if (typeof value !== 'string') return null;
200
+ const normalized = value.trim().replace(/^sha256:/i, '');
201
+ return normalized || null;
202
+ }
203
+
204
+ function isConcreteProvenanceValue(value) {
205
+ return PROVENANCE_VALUE_RE.test(normalizeProvenanceValue(value) || '');
206
+ }
207
+
208
+ function runtimeContextFromContext(context) {
209
+ const candidates = [
210
+ context?.runtime_context,
211
+ context?.runtimeContext,
212
+ context?.provenance_context,
213
+ context?.provenanceContext,
214
+ context?.receipt_binding?.runtimeContext,
215
+ context?.receiptBinding?.runtimeContext,
216
+ context?.provenance_binding?.runtimeContext,
217
+ context?.provenanceBinding?.runtimeContext,
218
+ ];
219
+ const candidate = candidates.find((value) => PROVENANCE.isTrustedRuntimeContext(value));
220
+ if (!candidate) return null;
221
+ try { return PROVENANCE.recomputeRuntimeContext(candidate); } catch (_) { return null; }
222
+ }
223
+
224
+ function firstDefined(source, keys) {
225
+ if (!source || typeof source !== 'object') return undefined;
226
+ for (const key of keys) {
227
+ if (Object.prototype.hasOwnProperty.call(source, key)) return source[key];
228
+ }
229
+ return undefined;
230
+ }
231
+
232
+ function expectedProvenanceFromSource(source) {
233
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
234
+ const nested = firstDefined(source, [
235
+ 'expectedProvenance',
236
+ 'expected_provenance',
237
+ ]);
238
+ const candidate = nested && typeof nested === 'object' && !Array.isArray(nested)
239
+ ? nested
240
+ : source;
241
+ const base = firstDefined(candidate, [
242
+ 'base', 'Base', 'base_sha', 'baseSha', 'expectedBase', 'expected_base',
243
+ 'expectedBaseSha', 'expected_base_sha',
244
+ ]);
245
+ const head = firstDefined(candidate, [
246
+ 'head', 'Head', 'head_sha', 'headSha', 'expectedHead', 'expected_head',
247
+ 'expectedHeadSha', 'expected_head_sha',
248
+ ]);
249
+ if (base === undefined && head === undefined) return null;
250
+ return { base, head };
251
+ }
252
+
253
+ function createReceiptBinding(runtimeContext) {
254
+ if (!PROVENANCE.isTrustedRuntimeContext(runtimeContext)) {
255
+ throw new TypeError('Receipt binding requires a runtime-derived provenance context');
256
+ }
257
+ try { return PROVENANCE.createReceiptBinding(runtimeContext); } catch (error) {
258
+ throw new TypeError(error.message);
259
+ }
260
+ }
261
+
262
+ function receiptValidatorOptions(task = {}, {
263
+ requireProvenanceBinding = false,
264
+ requireExplicitBinding = false,
265
+ runtimeContext,
266
+ } = {}) {
267
+ const declaration = artifactDeclaration(task);
268
+ let trusted = null;
269
+ if (runtimeContext !== undefined && PROVENANCE.isTrustedRuntimeContext(runtimeContext)) {
270
+ try { trusted = PROVENANCE.recomputeRuntimeContext(runtimeContext); } catch (_) { trusted = null; }
271
+ }
272
+ return {
273
+ requireArtifactHash: declaration.declared,
274
+ artifactDeclarationValid: declaration.valid,
275
+ artifactPaths: declaration.paths,
276
+ artifactRoot: trusted?.project_root || null,
277
+ verifyArtifactBytes: Boolean(trusted?.project_root),
278
+ expectedProvenance: trusted ? { base: trusted.base, head: trusted.head } : null,
279
+ requireProvenanceBinding: requireProvenanceBinding || (runtimeContext !== undefined && runtimeContext !== null) || requireExplicitBinding,
280
+ };
281
+ }
282
+
283
+ function sha256ValuesFromLine(line) {
284
+ const values = [];
285
+ const labels = [...line.matchAll(/\b(?:artifact[_-])?sha-?256\s*:/gi)];
286
+ for (const label of labels) {
287
+ const remainder = line.slice(label.index + label[0].length);
288
+ const token = remainder.match(/^\s*([^\s)\],;`]+)/);
289
+ values.push(token ? token[1].trim() : '');
290
+ }
291
+ return values;
292
+ }
293
+
294
+ function artifactPathText(line) {
295
+ return line
296
+ .replace(/^\s*Artifacts?\s*:\s*/i, '')
297
+ .replace(/^\s*Artifact\s+produced\s*:?\s*/i, '')
298
+ .replace(/\b(?:artifact[_-])?sha-?256\s*:\s*[^\s)\],;`]+/gi, '')
299
+ .replace(/[()[\]{}]/g, ' ')
300
+ .replace(/\s+/g, ' ')
301
+ .trim()
302
+ .replace(/(?:\s*[+,;]\s*)+$/, '')
303
+ .trim();
304
+ }
305
+
306
+ function parseArtifactDeclarations(body) {
307
+ const declarations = [];
308
+ let previous = null;
309
+ for (const raw of body.split('\n')) {
310
+ if (PROSE_RECEIPT_FIELD_RE.test(raw)) {
311
+ previous = null;
312
+ continue;
313
+ }
314
+ const isDeclaration = /^\s*Artifacts?\s*:/i.test(raw)
315
+ || /^\s*Artifact\s+produced\b/i.test(raw)
316
+ || /^\s*artifact[_-]?sha-?256\s*:/i.test(raw);
317
+ if (isDeclaration) {
318
+ previous = {
319
+ raw,
320
+ pathText: artifactPathText(raw),
321
+ hashes: sha256ValuesFromLine(raw),
322
+ };
323
+ declarations.push(previous);
324
+ continue;
325
+ }
326
+ if (previous && /^\s*sha-?256\s*:/i.test(raw)) {
327
+ previous.hashes.push(...sha256ValuesFromLine(raw));
328
+ continue;
329
+ }
330
+ if (raw.trim() !== '') previous = null;
331
+ }
332
+ return declarations;
333
+ }
334
+
335
+ function artifactSha256Values(body) {
336
+ return parseArtifactDeclarations(body).flatMap((declaration) => declaration.hashes);
337
+ }
338
+
339
+ function hasCanonicalArtifactHash(body) {
340
+ const hashes = artifactSha256Values(body);
341
+ return hashes.length > 0 && hashes.every((value) => /^[a-f0-9]{64}$/i.test(value));
342
+ }
343
+
344
+ function hasConcreteArtifactHash(body) {
345
+ const hashes = artifactSha256Values(body);
346
+ return hashes.length > 0 && hashes.every((value) => value !== '' && !isPlaceholderToken(value));
347
+ }
348
+
349
+ function hasExplicitFailure(body) {
350
+ if (typeof body !== 'string') return false;
351
+ const lines = body.split('\n');
352
+ for (const raw of lines) {
353
+ if (PROSE_RECEIPT_FIELD_RE.test(raw)) continue;
354
+ const trimmed = raw.trim();
355
+ if (trimmed === '') continue;
356
+ // 1. Zero execution (structured, anchored): zero tests, zero passing, or no collection.
357
+ if (/^\s*(?:#|ℹ)?\s*tests?\s+0\b/i.test(raw)) return true;
358
+ if (/^\s*(?:#|ℹ)?\s*0\s+tests?\b/i.test(raw)) return true;
359
+ if (/^\s*(?:#|ℹ)?\s*tests?\s+run\s*:\s*0\b/i.test(raw)) return true;
360
+ if (/^\s*0\s+(?:passing|passed)\b/i.test(raw)) return true;
361
+ if (/^\s*Tests?\s*:\s*0\s+(?:passing|passed)\b/i.test(raw)) return true;
362
+ if (/^\s*Tests?\s+passed\s*:\s*0\b/i.test(raw)) return true;
363
+ if (/^\s*Passed\s*:\s*0\b/i.test(raw)) return true;
364
+ if (/^\s*Tests:\s*0\s+total\b/i.test(raw)) return true;
365
+ if (/^\s*collected\s+0\s+items\b/i.test(raw)) return true;
366
+ if (/^\s*No tests? found\b/i.test(raw)) return true;
367
+ // 2. Cancellation (optional marker #/ℹ, cancelled/canceled, both orders, count >0; 0 passes)
368
+ if (/^\s*(?:#|ℹ)?\s*cancel(?:l)?ed\s*[:\s]*[1-9]\d*\b/i.test(raw)) return true;
369
+ if (/^\s*(?:#|ℹ)?\s*[1-9]\d*\s+cancel(?:l)?ed\b/i.test(raw)) return true;
370
+ // 3. Suite / file failures: Test Suites: N failed, Test Files N failed (N>0)
371
+ if (/^\s*Test Suites?:?\s*[1-9]\d*\s+failed\b/i.test(raw)) return true;
372
+ if (/^\s*Test Files?:?\s*[1-9]\d*\s+failed\b/i.test(raw)) return true;
373
+ if (/^\s*Test Suites?:?\s*0\s+failed\b[^\r\n]*\b0\s+passed\b/i.test(raw)) return true;
374
+ if (/^\s*Test Files?:?\s*0\s+failed\b[^\r\n]*\b0\s+passed\b/i.test(raw)) return true;
375
+ // 4. Pytest short-summary entries (anchored to a Python test path)
376
+ if (/^\s*FAILED[ \t]+\S+\.py(?:::\S+)?(?:[ \t]+-[^\r\n]*)?\s*$/i.test(raw)) return true;
377
+ // 5. Runner FAIL (anchored exact FAIL, horizontal whitespace/colon/end; --- FAIL:)
378
+ if (/^\s*FAIL(?:[ \t]+|:|$)/.test(raw)) return true;
379
+ if (/^\s*---\s*FAIL:/.test(raw)) return true;
380
+ // 6. Error/failure summaries (anchored, N>0; prose Notes/Description/Command skipped)
381
+ if (/^\s*[1-9]\d*\s+errors?\b/i.test(raw)) return true;
382
+ if (/^\s*errors?\s*[:\s]*[1-9]\d*\b/i.test(raw)) return true;
383
+ if (/^\s*failures?\s*[:\s]*[1-9]\d*\b/i.test(raw)) return true;
384
+ if (/^\s*failure\s+summary\s*[:.]?\s*$/i.test(raw)) return true;
385
+ if (/^\s*Found\s+[1-9]\d*\s+errors?\b/i.test(raw)) return true;
386
+ if (/^\s*failureCount\s*:\s*[1-9]\d*\s*[,;]?\s*$/i.test(raw)) return true;
387
+ if (/^\s*ERROR\s*$/i.test(raw)) return true;
388
+ if (/^\s*ERROR\s+collecting\b/i.test(raw)) return true;
389
+ if (/^\s*Tests run\s*:/i.test(raw) && (/\b(?:Failures?|Errors?)\s*[:=]\s*[1-9]/i.test(raw) || /\b0\s+(?:tests?|passing|passed)\b/i.test(raw))) return true;
390
+ // Maven Surefire's structured failure lines; do not treat arbitrary [ERROR] prose as failure.
391
+ if (/^\s*\[ERROR\]\s+Tests run:\s*\d+\b[^\r\n]*\b(?:Failures?|Errors?):\s*[1-9]\d*\b/i.test(raw)) return true;
392
+ if (/^\s*\[ERROR\]\s+Failed to execute goal\b/i.test(raw)) return true;
393
+ // 7. Gradle (anchored, optional)
394
+ if (/^\s*\d+\s+tests?\s+completed,\s*[1-9]\d*\s+failed\b/i.test(raw)) return true;
395
+ // Existing structured checks (preserve, anchored where needed to avoid prose false positive)
396
+ if (/^\s*Tests?\s+failed\s*$/i.test(raw)) return true;
397
+ if (/^\s*Tests?\s+failed\s*:\s*[1-9]\d*\b/i.test(raw)) return true;
398
+ if (/\b(?:Verification|Result|Status|Outcome)\s*:\s*FAIL(?:ED|URE)?\b/i.test(raw)) return true;
399
+ if (/^\s*FAIL\s*$/i.test(trimmed)) return true;
400
+ if (/^\s*FAIL\s*:/i.test(trimmed)) return true;
401
+ if (/^\s*FAILED\s*$/i.test(trimmed)) return true;
402
+ if (/^\s*FAILED\s*:/i.test(trimmed)) return true;
403
+ if (/^\s*FAILURE\s*$/i.test(trimmed)) return true;
404
+ if (/^\s*FAILURE\s*:/i.test(trimmed)) return true;
405
+ if (/^\s*one\s+failing(?:\s+(?:test|tests|suite|suites))?\s*[,:]?\s*$/i.test(raw)) return true;
406
+ if (/^\s*(?:#|ℹ)?\s*fail\s+[1-9]/i.test(raw)) return true;
407
+ if (/^\s*not ok\b/i.test(raw)) return true;
408
+ if (/^\s*Tests\s*:?\s*[1-9]/i.test(raw) && /\bfailed\b/i.test(raw)) return true;
409
+ if (/^\s*\d+\s+failed\b/i.test(raw)) {
410
+ const m = raw.match(/^\s*(\d+)\s+failed\b/i);
411
+ if (m && parseInt(m[1], 10) !== 0) return true;
412
+ }
413
+ if (/exit\s+code\s*[:=]?\s*[1-9]/i.test(raw)) return true;
414
+ }
415
+ return false;
416
+ }
417
+ const trustedClassifications = new WeakSet();
418
+
28
419
  const APPROVAL_SCHEMA_VERSION = '2.0';
29
420
  const LEGACY_APPROVAL_ERROR = `Legacy approval state detected (field \`approved\`). Migration required: replace \`approved\` with \`agent_validated\` and \`user_approved\` per schema v${APPROVAL_SCHEMA_VERSION} (schema_version: "${APPROVAL_SCHEMA_VERSION}"). See spec-state.json template. Refusing to infer user approval.`;
30
421
 
@@ -108,22 +499,37 @@ function overrideLane(input = {}) {
108
499
  return input.override ?? input.laneOverride ?? input.requestedLane ?? input.lane ?? null;
109
500
  }
110
501
 
111
- function isUserAuthorizedForDowngrade(input = {}) {
112
- // Explicit user-originated authorization is required for any downgrade from Critical.
113
- // We accept several synonyms for the authorization flag to allow different callers.
114
- return input.userAuthorized === true
115
- || input.user_authorized === true
116
- || input.explicitUserAuthorization === true
117
- || input.downgradeAuthorized === true
118
- || input.confirmDowngrade === true
119
- || input.authorization === 'user'
120
- || input.origin === 'user'
121
- || input.requestedBy === 'user'
122
- || input.user_approved === true;
502
+ function isValidOverrideReceipt(receipt, automaticLane, requestedLane, risks = []) {
503
+ // P0.4 fail-closed: no trusted runtime issuer exists yet.
504
+ // Even a correctly shaped object with source:runtime must NOT be considered valid provenance.
505
+ // Downgrade is blocked until a verifiable, runtime-issued override receipt is implemented
506
+ // (bound to automaticLane/requestedLane/risks/session and verifiable by that issuer).
507
+ // Keep this function fail-closed; do not accept plain objects — always return false for P0.
508
+ return false;
509
+ }
510
+
511
+ function isUserAuthorizedForDowngrade(input = {}, automaticLane, requestedLane, risks) {
512
+ // P0.4: downgrade requires structured override receipt with runtime provenance.
513
+ // Boolean flags (userAuthorized, user_approved, etc.) are never trusted.
514
+ // Fail closed — no trusted issuer exists yet, so always false.
515
+ // Explicitly ignore all boolean/string downgrade flags to prevent forged approvals.
516
+ return false;
517
+ }
518
+
519
+ function freezeClassification(obj) {
520
+ Object.freeze(obj);
521
+ if (Array.isArray(obj.warnings)) Object.freeze(obj.warnings);
522
+ if (Array.isArray(obj.risks)) Object.freeze(obj.risks);
523
+ if (obj.signals && typeof obj.signals === 'object') Object.freeze(obj.signals);
524
+ trustedClassifications.add(obj);
525
+ return obj;
123
526
  }
124
527
 
125
528
  function classifyLane(input = {}) {
126
- if (typeof input === 'string') return { lane: assertLane(input), automaticLane: input, overridden: false, warnings: [], risks: [] };
529
+ if (typeof input === 'string') {
530
+ const c = { lane: assertLane(input), automaticLane: input, overridden: false, warnings: [], risks: [], signals: {} };
531
+ return freezeClassification(c);
532
+ }
127
533
  if (!input || typeof input !== 'object') throw new TypeError('Lane classification input must be an object');
128
534
 
129
535
  const signals = classifyRiskSignals(input);
@@ -138,7 +544,7 @@ function classifyLane(input = {}) {
138
544
  : 'Standard';
139
545
  const requested = overrideLane(input);
140
546
  if (requested === null || requested === undefined) {
141
- return {
547
+ const c = {
142
548
  lane: automaticLane,
143
549
  automaticLane,
144
550
  overridden: false,
@@ -146,14 +552,15 @@ function classifyLane(input = {}) {
146
552
  risks,
147
553
  signals,
148
554
  };
555
+ return freezeClassification(c);
149
556
  }
150
557
  assertLane(requested);
151
558
  const isDowngrade = LANES.indexOf(requested) < LANES.indexOf(automaticLane);
152
- if (isDowngrade && !isUserAuthorizedForDowngrade(input)) {
559
+ if (isDowngrade && !isUserAuthorizedForDowngrade(input, automaticLane, requested, risks)) {
153
560
  throw new Error(
154
- `Downgrade from ${automaticLane} to ${requested} requires explicit user authorization. ` +
561
+ `Downgrade from ${automaticLane} to ${requested} is blocked: no trusted runtime issuer is available to authorize Critical downgrades. ` +
155
562
  `Automatic ${automaticLane} due to: ${risks.join(', ') || 'default risk policy'}. ` +
156
- `Provide userAuthorized:true (origin=user) to confirm downgrade and acknowledge reduced review/evidence coverage.`
563
+ `Keep ${automaticLane} or escalate; downgrade is unavailable until a verifiable runtime override receipt is implemented.`
157
564
  );
158
565
  }
159
566
  const warnings = [`Explicit lane override selected: ${requested}.`];
@@ -161,12 +568,12 @@ function classifyLane(input = {}) {
161
568
  warnings.push(`Override changes automatic ${automaticLane} classification to ${requested}.`);
162
569
  if (LANES.indexOf(requested) < LANES.indexOf(automaticLane)) {
163
570
  warnings.push(`Downgrading risk lane may reduce review and evidence coverage: ${risks.join(', ') || 'default risk policy'}.`);
164
- if (isUserAuthorizedForDowngrade(input)) {
571
+ if (isUserAuthorizedForDowngrade(input, automaticLane, requested, risks)) {
165
572
  warnings.push(`Downgrade authorized by user - proceeding with ${requested} despite ${automaticLane} signals.`);
166
573
  }
167
574
  }
168
575
  }
169
- return {
576
+ const c2 = {
170
577
  lane: requested,
171
578
  automaticLane,
172
579
  overridden: true,
@@ -174,29 +581,265 @@ function classifyLane(input = {}) {
174
581
  risks,
175
582
  signals,
176
583
  };
584
+ return freezeClassification(c2);
177
585
  }
178
586
 
179
- function lanePolicy(input = {}) {
180
- const classification = typeof input === 'string'
181
- ? classifyLane(input)
182
- : input && LANES.includes(input.lane) && Object.hasOwn(input, 'automaticLane')
183
- ? input
184
- : classifyLane(input);
587
+ function uniqueStrings(values, field) {
588
+ if (!Array.isArray(values)) return { valid: false, error: `${field} must be an array` };
589
+ if (values.some((value) => typeof value !== 'string' || value.trim() === '')) {
590
+ return { valid: false, error: `${field} must contain non-empty strings` };
591
+ }
592
+ if (new Set(values).size !== values.length) return { valid: false, error: `${field} must not contain duplicates` };
593
+ return { valid: true };
594
+ }
595
+
596
+ function obligationsForLane(lane, risks = []) {
597
+ assertLane(lane);
598
+ const riskSet = new Set(risks);
599
+ const obligations = new Set(['needsExecutionProof']);
600
+ if (lane === 'Standard') obligations.add('needsInspection');
601
+ if (lane === 'Critical') {
602
+ obligations.add('needsInspection');
603
+ obligations.add('needsIndependentAudit');
604
+ if ([
605
+ 'destructive', 'reversibility', 'rollback', 'data', 'schema', 'migration',
606
+ 'privacy', 'payment', 'crossRuntime',
607
+ ].some((risk) => riskSet.has(risk))) {
608
+ obligations.add('needsDurableTaskState');
609
+ }
610
+ if ([
611
+ 'auth', 'payment', 'privacy', 'schema', 'migration', 'publicContract',
612
+ 'crossRuntime', 'ambiguity',
613
+ ].some((risk) => riskSet.has(risk))) {
614
+ obligations.add('needsResearchGrounding');
615
+ }
616
+ }
617
+ return PROOF_OBLIGATIONS.filter((obligation) => obligations.has(obligation));
618
+ }
619
+
620
+ function actorNeedsFor(obligations) {
621
+ return obligations.map((obligation) => ({ ...ACTOR_NEEDS_BY_OBLIGATION[obligation] }));
622
+ }
623
+
624
+ function minimumLaneForRisks(risks = []) {
625
+ const listedRisks = Array.isArray(risks) ? risks : [];
626
+ const classifiedRisks = classifyRiskSignals({ risks: listedRisks });
627
+ if (Object.entries(classifiedRisks).some(([risk, active]) => active && CRITICAL_RISKS.has(risk))) {
628
+ return 'Critical';
629
+ }
630
+ if (listedRisks.length > 0) return 'Standard';
631
+ return 'Direct';
632
+ }
633
+
634
+ function snapshotFromClassification(classification) {
185
635
  const lane = assertLane(classification.lane);
186
- const delegated = [...LANE_DELEGATION[lane]];
636
+ const automaticLane = assertLane(classification.automaticLane || lane);
637
+ if (LANES.indexOf(lane) < LANES.indexOf(automaticLane)) {
638
+ throw new Error(`Workflow policy cannot downgrade ${automaticLane} to ${lane} without a trusted override receipt`);
639
+ }
640
+ const risks = Array.isArray(classification.risks) ? [...new Set(classification.risks)] : [];
641
+ const minimumLane = minimumLaneForRisks(risks);
642
+ if (LANES.indexOf(lane) < LANES.indexOf(minimumLane)) {
643
+ throw new Error(`Workflow policy lane ${lane} is below ${minimumLane} for risks: ${risks.join(', ')}`);
644
+ }
187
645
  return {
646
+ version: WORKFLOW_POLICY_VERSION,
188
647
  lane,
189
- executionTier: lane === 'Critical' ? 'Deep' : lane === 'Direct' ? 'Light' : 'Standard',
190
- automaticLane: classification.automaticLane,
191
- overridden: classification.overridden,
192
- warnings: [...classification.warnings],
193
- risks: [...classification.risks],
194
- delegated,
648
+ automatic_lane: automaticLane,
649
+ risks,
650
+ artifact_profile: ARTIFACT_PROFILE_BY_LANE[lane],
651
+ proof_obligations: obligationsForLane(lane, risks),
652
+ actor_needs: actorNeedsFor(obligationsForLane(lane, risks)),
653
+ override_receipt: null,
654
+ };
655
+ }
656
+
657
+ function workflowPolicySnapshot(input = {}) {
658
+ if (input && typeof input === 'object' && hasOwn(input, 'workflow_policy')) {
659
+ return readWorkflowPolicySnapshot(input);
660
+ }
661
+ if (input && typeof input === 'object' && input.design_context?.execution_tier) {
662
+ return readWorkflowPolicySnapshot(input);
663
+ }
664
+ if (input && typeof input === 'object' && isWorkflowPolicySnapshot(input)) {
665
+ assertWorkflowPolicySnapshot(input);
666
+ return JSON.parse(JSON.stringify(input));
667
+ }
668
+ const classification = trustedClassifications.has(input)
669
+ ? input
670
+ : classifyLane(input);
671
+ return snapshotFromClassification(classification);
672
+ }
673
+
674
+ function isWorkflowPolicySnapshot(value) {
675
+ return Boolean(value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'version')
676
+ && Object.prototype.hasOwnProperty.call(value, 'automatic_lane')
677
+ && Object.prototype.hasOwnProperty.call(value, 'artifact_profile'));
678
+ }
679
+
680
+ function validateWorkflowPolicySnapshot(snapshot) {
681
+ const errors = [];
682
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
683
+ return { valid: false, errors: ['workflow_policy must be an object'] };
684
+ }
685
+ const keys = Object.keys(snapshot).sort();
686
+ const expectedKeys = [...WORKFLOW_POLICY_FIELDS].sort();
687
+ if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)) {
688
+ errors.push(`workflow_policy fields must be exactly ${WORKFLOW_POLICY_FIELDS.join(', ')}`);
689
+ }
690
+ if (snapshot.version !== WORKFLOW_POLICY_VERSION) errors.push('workflow_policy.version must be "1"');
691
+ if (!LANES.includes(snapshot.lane)) errors.push(`workflow_policy.lane must be one of ${LANES.join(', ')}`);
692
+ if (!LANES.includes(snapshot.automatic_lane)) errors.push(`workflow_policy.automatic_lane must be one of ${LANES.join(', ')}`);
693
+ if (LANES.includes(snapshot.lane) && LANES.includes(snapshot.automatic_lane)
694
+ && LANES.indexOf(snapshot.lane) < LANES.indexOf(snapshot.automatic_lane)) {
695
+ errors.push('workflow_policy.lane cannot be lower than automatic_lane without a trusted override receipt');
696
+ }
697
+ if (LANES.includes(snapshot.lane) && snapshot.artifact_profile !== ARTIFACT_PROFILE_BY_LANE[snapshot.lane]) {
698
+ errors.push(`workflow_policy.artifact_profile must be ${ARTIFACT_PROFILE_BY_LANE[snapshot.lane]} for ${snapshot.lane}`);
699
+ }
700
+ const risks = uniqueStrings(snapshot.risks, 'workflow_policy.risks');
701
+ if (!risks.valid) errors.push(risks.error);
702
+ if (risks.valid && LANES.includes(snapshot.lane)) {
703
+ const minimumLane = minimumLaneForRisks(snapshot.risks);
704
+ if (LANES.indexOf(snapshot.lane) < LANES.indexOf(minimumLane)) {
705
+ errors.push(`workflow_policy.lane must be at least ${minimumLane} for its risks`);
706
+ }
707
+ }
708
+ const obligations = uniqueStrings(snapshot.proof_obligations, 'workflow_policy.proof_obligations');
709
+ if (!obligations.valid) {
710
+ errors.push(obligations.error);
711
+ } else {
712
+ for (const obligation of snapshot.proof_obligations) {
713
+ if (!PROOF_OBLIGATIONS.includes(obligation)) errors.push(`workflow_policy.proof_obligations has unknown obligation ${obligation}`);
714
+ }
715
+ if (!snapshot.proof_obligations.includes('needsExecutionProof')) {
716
+ errors.push('workflow_policy.proof_obligations must include needsExecutionProof');
717
+ }
718
+ if (LANES.includes(snapshot.lane) && risks.valid) {
719
+ const expected = obligationsForLane(snapshot.lane, snapshot.risks);
720
+ if (JSON.stringify(snapshot.proof_obligations) !== JSON.stringify(expected)) {
721
+ errors.push('workflow_policy.proof_obligations do not match lane and risks');
722
+ }
723
+ }
724
+ }
725
+ if (!Array.isArray(snapshot.actor_needs)) {
726
+ errors.push('workflow_policy.actor_needs must be an array');
727
+ } else {
728
+ for (const need of snapshot.actor_needs) {
729
+ if (!need || typeof need !== 'object' || Array.isArray(need)
730
+ || Object.keys(need).sort().join(',') !== 'capability,independence'
731
+ || !ACTOR_CAPABILITIES.has(need.capability)
732
+ || !ACTOR_INDEPENDENCE.has(need.independence)) {
733
+ errors.push('workflow_policy.actor_needs must describe capability and independence, never an agent name');
734
+ break;
735
+ }
736
+ }
737
+ if (LANES.includes(snapshot.lane) && obligations.valid
738
+ && JSON.stringify(snapshot.actor_needs) !== JSON.stringify(actorNeedsFor(snapshot.proof_obligations))) {
739
+ errors.push('workflow_policy.actor_needs must correspond to proof_obligations');
740
+ }
741
+ }
742
+ if (snapshot.override_receipt !== null) {
743
+ errors.push('workflow_policy.override_receipt must be null until a trusted downgrade issuer exists');
744
+ }
745
+ return { valid: errors.length === 0, errors };
746
+ }
747
+
748
+ function assertWorkflowPolicySnapshot(snapshot) {
749
+ const result = validateWorkflowPolicySnapshot(snapshot);
750
+ if (!result.valid) throw new TypeError(`Invalid workflow policy snapshot: ${result.errors.join('; ')}`);
751
+ return snapshot;
752
+ }
753
+
754
+ function readWorkflowPolicySnapshot(spec = {}) {
755
+ if (!spec || typeof spec !== 'object') throw new TypeError('Spec state must be an object');
756
+ if (hasOwn(spec, 'workflow_policy')) {
757
+ assertWorkflowPolicySnapshot(spec.workflow_policy);
758
+ return JSON.parse(JSON.stringify(spec.workflow_policy));
759
+ }
760
+ // Legacy state is read-compatible only. The fallback is deliberately not
761
+ // written back and cannot authorize a downgrade or override new input.
762
+ const rawLegacyTier = spec.design_context && spec.design_context.execution_tier;
763
+ const legacyTier = typeof rawLegacyTier === 'string'
764
+ ? Object.keys(LEGACY_TIER_LANES).find((tier) => tier.toLowerCase() === rawLegacyTier.toLowerCase())
765
+ : null;
766
+ const legacyLane = legacyTier ? LEGACY_TIER_LANES[legacyTier] : null;
767
+ if (legacyLane) return snapshotFromClassification({ lane: legacyLane, automaticLane: legacyLane, risks: [] });
768
+ throw new TypeError('workflow_policy snapshot is missing at the spec boundary');
769
+ }
770
+
771
+ function persistWorkflowPolicySnapshot(spec, input = {}) {
772
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) throw new TypeError('Spec state must be an object');
773
+ const next = { ...spec };
774
+ delete next.override_receipt;
775
+ if (hasOwn(spec, 'workflow_policy')) {
776
+ assertWorkflowPolicySnapshot(spec.workflow_policy);
777
+ return { ...next, workflow_policy: JSON.parse(JSON.stringify(spec.workflow_policy)) };
778
+ }
779
+ return { ...next, workflow_policy: workflowPolicySnapshot(input) };
780
+ }
781
+
782
+ const persistWorkflowPolicy = persistWorkflowPolicySnapshot;
783
+ const createWorkflowPolicySnapshot = workflowPolicySnapshot;
784
+
785
+ function candidateLaneForRisks(input, risks) {
786
+ const explicitLevel = String(input?.riskLevel || input?.risk_level || input?.level || '').toLowerCase();
787
+ const explicitLane = ['critical', 'high', 'deep'].includes(explicitLevel)
788
+ ? 'Critical'
789
+ : ['standard', 'medium'].includes(explicitLevel)
790
+ ? 'Standard'
791
+ : null;
792
+ const riskLane = minimumLaneForRisks(risks);
793
+ if (explicitLane === null && riskLane === 'Direct') return null;
794
+ if (explicitLane === null) return riskLane;
795
+ return LANES[Math.max(LANES.indexOf(explicitLane), LANES.indexOf(riskLane))];
796
+ }
797
+
798
+ function escalateWorkflowPolicy(policyOrSpec, riskInput = {}) {
799
+ const current = policyOrSpec && typeof policyOrSpec === 'object' && hasOwn(policyOrSpec, 'workflow_policy')
800
+ ? readWorkflowPolicySnapshot(policyOrSpec)
801
+ : workflowPolicySnapshot(policyOrSpec);
802
+ const discovered = classifyRiskSignals(riskInput);
803
+ const explicitRisks = Array.isArray(riskInput?.risks)
804
+ ? riskInput.risks.filter((risk) => typeof risk === 'string' && risk.trim() !== '')
805
+ : [];
806
+ const newRisks = [...new Set([
807
+ ...Object.entries(discovered).filter(([, active]) => active).map(([name]) => name),
808
+ ...explicitRisks,
809
+ ])];
810
+ const risks = [...new Set([...current.risks, ...newRisks])];
811
+ if (newRisks.every((risk) => current.risks.includes(risk))) return current;
812
+ const candidate = candidateLaneForRisks(riskInput, newRisks);
813
+ if (!candidate) return current;
814
+ const lane = LANES[Math.max(LANES.indexOf(current.lane), LANES.indexOf(candidate))];
815
+ return snapshotFromClassification({
816
+ lane,
817
+ automaticLane: current.automatic_lane,
818
+ risks,
819
+ });
820
+ }
821
+
822
+ function policyForSpec(spec, riskInput = null) {
823
+ const snapshot = riskInput ? escalateWorkflowPolicy(spec, riskInput) : readWorkflowPolicySnapshot(spec);
824
+ return lanePolicy(snapshot);
825
+ }
826
+
827
+ function lanePolicy(input = {}) {
828
+ const snapshot = input && typeof input === 'object' && hasOwn(input, 'workflow_policy')
829
+ ? readWorkflowPolicySnapshot(input)
830
+ : input && typeof input === 'object' && isWorkflowPolicySnapshot(input)
831
+ ? (assertWorkflowPolicySnapshot(input), JSON.parse(JSON.stringify(input)))
832
+ : workflowPolicySnapshot(input);
833
+ const lane = assertLane(snapshot.lane);
834
+ return {
835
+ ...snapshot,
836
+ proof_obligations: [...snapshot.proof_obligations],
837
+ actor_needs: snapshot.actor_needs.map((need) => ({ ...need })),
195
838
  requiresSpec: lane !== 'Direct',
196
- requiresState: lane !== 'Direct',
839
+ requiresState: lane === 'Critical',
197
840
  qualityGate: lane === 'Direct' ? 'main-session' : lane === 'Standard' ? 'combined-feature-review' : 'strict-evidence',
198
841
  shipPoint: lane === 'Direct' ? 'task' : 'feature',
199
- evidence: lane === 'Critical' ? 'strict' : lane === 'Standard' ? 'bounded' : 'targeted',
842
+ evidence: snapshot.artifact_profile,
200
843
  };
201
844
  }
202
845
 
@@ -205,85 +848,106 @@ function isLegacyApprovalObject(obj) {
205
848
  }
206
849
 
207
850
  function validateApprovalSchema(spec) {
208
- if (!spec || typeof spec !== 'object') return { valid: false, legacy: false, error: 'spec must be an object' };
851
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return { valid: false, absent: false, legacy: false, error: 'spec must be a non-array object' };
852
+ if (!Object.prototype.hasOwnProperty.call(spec, 'approvals')) {
853
+ return { valid: true, absent: true, legacy: false };
854
+ }
209
855
  const approvals = spec.approvals;
210
- if (!approvals || typeof approvals !== 'object') return { valid: true, legacy: false };
856
+ if (approvals === null || typeof approvals !== 'object' || Array.isArray(approvals)) {
857
+ return { valid: false, absent: false, legacy: false, error: 'approvals must be a non-empty object' };
858
+ }
859
+ if (Object.keys(approvals).length === 0) {
860
+ return { valid: false, absent: false, legacy: false, error: 'approvals must not be empty' };
861
+ }
211
862
  // Check for legacy field `approved` in any stage
212
863
  for (const [stage, val] of Object.entries(approvals)) {
213
864
  if (isLegacyApprovalObject(val)) {
214
865
  return {
215
866
  valid: false,
867
+ absent: false,
216
868
  legacy: true,
217
869
  error: `Legacy approval state at approvals.${stage}.approved. ${LEGACY_APPROVAL_ERROR}`,
218
870
  };
219
871
  }
220
- if (val && typeof val === 'object') {
221
- // also check if missing new fields but has generated/approved only
222
- if ('generated' in val && !('agent_validated' in val) && !('user_approved' in val) && !('approved' in val)) {
223
- // This is ambiguous: generated without new fields - treat as not ready but not legacy
224
- }
872
+ if (!val || typeof val !== 'object' || Array.isArray(val)) {
873
+ return { valid: false, absent: false, legacy: false, error: `approvals.${stage} must be an approval object` };
874
+ }
875
+ const keys = Object.keys(val).sort();
876
+ if (keys.join(',') !== 'agent_validated,generated,user_approved') {
877
+ return { valid: false, absent: false, legacy: false, error: `approvals.${stage} must contain exactly generated, agent_validated, and user_approved` };
878
+ }
879
+ if (keys.some((key) => typeof val[key] !== 'boolean')) {
880
+ return { valid: false, absent: false, legacy: false, error: `approvals.${stage} fields must be boolean` };
225
881
  }
226
882
  }
227
883
  // Check schema version
228
- const version = spec.schema_version || spec.approval_schema_version;
884
+ const version = Object.prototype.hasOwnProperty.call(spec, 'schema_version')
885
+ ? spec.schema_version
886
+ : spec.approval_schema_version;
887
+ if (version !== undefined && typeof version !== 'string') {
888
+ return { valid: false, absent: false, legacy: false, error: 'schema_version must be a string' };
889
+ }
229
890
  if (version && version !== APPROVAL_SCHEMA_VERSION) {
230
- return { valid: false, legacy: false, error: `Unsupported schema_version ${version}, expected ${APPROVAL_SCHEMA_VERSION}` };
891
+ return { valid: false, absent: false, legacy: false, error: `Unsupported schema_version ${version}, expected ${APPROVAL_SCHEMA_VERSION}` };
231
892
  }
232
893
  if (!version && Object.keys(approvals).length > 0) {
233
894
  // No version but uses new schema - allow for backward compat but warn that version should be present
234
895
  // Fail closed only if legacy detected above; otherwise allow.
235
- return { valid: true, legacy: false, warning: `Missing schema_version, expected ${APPROVAL_SCHEMA_VERSION}` };
896
+ return { valid: true, absent: false, legacy: false, warning: `Missing schema_version, expected ${APPROVAL_SCHEMA_VERSION}` };
236
897
  }
237
- return { valid: true, legacy: false };
898
+ return { valid: true, absent: false, legacy: false };
238
899
  }
239
900
 
240
901
  function approvalState(state = {}) {
241
- // Detect legacy at top level or inside approvals map
242
- const approvalsMap = state && state.approvals && typeof state.approvals === 'object' ? state.approvals : null;
243
- if (approvalsMap) {
244
- for (const val of Object.values(approvalsMap)) {
245
- if (isLegacyApprovalObject(val)) {
246
- throw new Error(LEGACY_APPROVAL_ERROR);
247
- }
248
- }
902
+ if (!state || typeof state !== 'object' || Array.isArray(state)) {
903
+ throw new TypeError('approval state must be an object');
904
+ }
905
+ if (Object.prototype.hasOwnProperty.call(state, 'approvals')) {
906
+ const validation = validateApprovalSchema(state);
907
+ if (!validation.valid) throw new Error(validation.error);
908
+ return {
909
+ generated: false,
910
+ agent_validated: false,
911
+ user_approved: false,
912
+ ready: false,
913
+ present: true,
914
+ schema_version: APPROVAL_SCHEMA_VERSION,
915
+ legacy: false,
916
+ };
917
+ }
918
+ if (isLegacyApprovalObject(state)) throw new Error(LEGACY_APPROVAL_ERROR);
919
+
920
+ const version = Object.prototype.hasOwnProperty.call(state, 'schema_version')
921
+ ? state.schema_version
922
+ : state.approval_schema_version;
923
+ if (version !== undefined && (typeof version !== 'string' || version !== APPROVAL_SCHEMA_VERSION)) {
924
+ throw new Error(`Unsupported schema_version ${String(version)}, expected ${APPROVAL_SCHEMA_VERSION}`);
249
925
  }
250
- const source = approvalsMap ? null : state;
251
- // If source is approvals map itself, we should not try to interpret it as single stage
252
- // But approvalState is intended for a single stage object; if passed whole approvals map, treat as error?
253
- // Keep original behavior: if state has approvals, use that, otherwise state itself.
254
- // For whole-spec case with legacy inside map, we already threw above.
255
- // For single-stage object:
256
- const singleSource = state && state.approvals && typeof state.approvals === 'object' && !approvalsMap ? state.approvals : state;
257
- // Actually simplify: if state has approvals, we already handled legacy; for single stage, source is state itself.
258
- // If state was {generated:true, agent_validated:true}, then approvalsMap is null, source = state
259
- const target = approvalsMap ? null : state;
260
- if (target && isLegacyApprovalObject(target)) {
261
- throw new Error(LEGACY_APPROVAL_ERROR);
262
- }
263
- // For single stage case, use target
264
- // For map case, we shouldn't be called with map; but if called with spec object containing approvals map, we return not-ready?
265
- if (approvalsMap) {
266
- // This is a spec-level object, not a stage. Return aggregate? For compatibility, treat as not ready.
267
- // Check if any stage has valid approval? But spec-level ready requires all stages.
268
- // We'll just return not ready with legacy check already done.
269
- return { generated: false, agent_validated: false, user_approved: false, ready: false, schema_version: APPROVAL_SCHEMA_VERSION, legacy: false };
270
- }
271
- const src = target || state;
272
- // Final legacy check on src
273
- if (isLegacyApprovalObject(src)) {
274
- throw new Error(LEGACY_APPROVAL_ERROR);
926
+
927
+ const known = ['generated', 'agent_validated', 'user_approved'];
928
+ const present = known.some((key) => Object.prototype.hasOwnProperty.call(state, key));
929
+ const unknown = Object.keys(state).filter((key) => !known.includes(key) && key !== 'schema_version' && key !== 'approval_schema_version');
930
+ if (unknown.length > 0) throw new TypeError(`malformed approval state fields: ${unknown.join(', ')}`);
931
+ for (const key of known) {
932
+ if (Object.prototype.hasOwnProperty.call(state, key) && typeof state[key] !== 'boolean') {
933
+ throw new TypeError(`approval state field ${key} must be boolean`);
934
+ }
275
935
  }
276
936
  const result = {
277
- generated: src?.generated === true,
278
- agent_validated: src?.agent_validated === true,
279
- user_approved: src?.user_approved === true,
937
+ generated: state.generated === true,
938
+ agent_validated: state.agent_validated === true,
939
+ user_approved: state.user_approved === true,
940
+ };
941
+ return {
942
+ ...result,
943
+ ready: result.generated && result.agent_validated && result.user_approved,
944
+ present,
945
+ schema_version: APPROVAL_SCHEMA_VERSION,
280
946
  };
281
- return { ...result, ready: result.generated && result.agent_validated && result.user_approved, schema_version: APPROVAL_SCHEMA_VERSION };
282
947
  }
283
-
284
948
  function assertVerdict(verdict) {
285
- if (!REVIEW_VERDICTS.includes(verdict)) {
286
- throw new TypeError(`Unsupported review verdict: ${String(verdict)}`);
949
+ if (!CANONICAL_VERDICTS.includes(verdict)) {
950
+ throw new TypeError(`Unsupported canonical verdict: ${String(verdict)}`);
287
951
  }
288
952
  return verdict;
289
953
  }
@@ -310,10 +974,9 @@ function delegationPlan({ tier, lane = null, mode = 'full-spec', taskCount = 1 }
310
974
  const policy = lanePolicy(lane);
311
975
  return {
312
976
  lane: policy.lane,
313
- executionTier: policy.executionTier,
314
- tier: tier || null,
315
977
  mode,
316
- delegated: policy.delegated,
978
+ proof_obligations: [...policy.proof_obligations],
979
+ actor_needs: policy.actor_needs.map((need) => ({ ...need })),
317
980
  qualityGate: policy.qualityGate,
318
981
  shipPoint: policy.shipPoint,
319
982
  };
@@ -325,65 +988,476 @@ function delegationPlan({ tier, lane = null, mode = 'full-spec', taskCount = 1 }
325
988
  throw new RangeError('taskCount must be a positive integer');
326
989
  }
327
990
 
328
- if (tier === 'Light') {
329
- return { tier, mode, delegated: [], qualityGate: 'main-session', shipPoint: 'task' };
991
+ // Legacy tier input is read-only compatibility. It is translated once to
992
+ // the canonical lane contract and never emits a new tier authority or an
993
+ // agent sequence.
994
+ const legacyLane = LEGACY_TIER_LANES[tier];
995
+ const policy = lanePolicy(legacyLane);
996
+ return {
997
+ tier,
998
+ legacy: true,
999
+ lane: policy.lane,
1000
+ mode,
1001
+ proof_obligations: [...policy.proof_obligations],
1002
+ actor_needs: policy.actor_needs.map((need) => ({ ...need })),
1003
+ qualityGate: policy.qualityGate,
1004
+ shipPoint: policy.shipPoint,
1005
+ taskCount,
1006
+ };
1007
+ }
1008
+
1009
+ function normalizeVerdict(verdict) {
1010
+ if (CANONICAL_VERDICTS.includes(verdict)) return { verdict, diagnostic: null };
1011
+ if (LEGACY_DIAGNOSTIC_VERDICTS.includes(verdict)) return { verdict: 'BLOCKED', diagnostic: verdict };
1012
+ throw new TypeError(`Unsupported canonical verdict: ${String(verdict)}`);
1013
+ }
1014
+
1015
+ function unfinishedVerdictDecision(verdict, context = {}, diagnostic = null) {
1016
+ const action = diagnostic === 'PARTIAL'
1017
+ ? 'continue-verification'
1018
+ : diagnostic === 'NO_TESTS'
1019
+ ? 'configure-or-run-tests'
1020
+ : verdict === 'FAIL'
1021
+ ? 'fix-and-rerun'
1022
+ : 'stop';
1023
+ return {
1024
+ verdict,
1025
+ ...(diagnostic ? { diagnostic } : {}),
1026
+ action,
1027
+ completion: 'unfinished',
1028
+ terminal: verdict !== 'FAIL',
1029
+ unfinished: true,
1030
+ retry: verdict === 'FAIL',
1031
+ blocker: context.blocker || (
1032
+ diagnostic === 'PARTIAL'
1033
+ ? 'verification is partial; required proof remains unfinished'
1034
+ : diagnostic === 'NO_TESTS'
1035
+ ? 'no tests were executed'
1036
+ : verdict === 'FAIL'
1037
+ ? 'verification returned FAIL'
1038
+ : 'verification is blocked; resolve the blocker before retrying'
1039
+ ),
1040
+ };
1041
+ }
1042
+
1043
+ function adaptVerdict(verdict, context = {}) {
1044
+ const normalized = normalizeVerdict(verdict);
1045
+ if (normalized.verdict === 'PASS' || normalized.verdict === 'PASS_WITH_WARNINGS') {
1046
+ return {
1047
+ verdict: normalized.verdict,
1048
+ ...(normalized.diagnostic ? { diagnostic: normalized.diagnostic } : {}),
1049
+ action: normalized.verdict === 'PASS_WITH_WARNINGS' ? 'review-passed-with-warnings' : 'review-passed',
1050
+ completion: 'unfinished',
1051
+ terminal: true,
1052
+ unfinished: true,
1053
+ retry: false,
1054
+ blocker: context.blocker || 'completion decision requires canonical execution proof and workflow obligations',
1055
+ };
1056
+ }
1057
+ return unfinishedVerdictDecision(normalized.verdict, context, normalized.diagnostic);
1058
+ }
1059
+
1060
+ function workflowPolicyObligations(context) {
1061
+ if (!context || typeof context !== 'object' || Array.isArray(context)) return null;
1062
+ const policy = hasOwn(context, 'workflow_policy')
1063
+ ? context.workflow_policy
1064
+ : hasOwn(context, 'workflowPolicy')
1065
+ ? context.workflowPolicy
1066
+ : hasOwn(context, 'policy')
1067
+ ? context.policy
1068
+ : context.task_context && typeof context.task_context === 'object'
1069
+ ? (hasOwn(context.task_context, 'workflow_policy')
1070
+ ? context.task_context.workflow_policy
1071
+ : hasOwn(context.task_context, 'workflowPolicy')
1072
+ ? context.task_context.workflowPolicy
1073
+ : null)
1074
+ : null;
1075
+ if (!validateWorkflowPolicySnapshot(policy).valid) return null;
1076
+ return [...policy.proof_obligations];
1077
+ }
1078
+
1079
+ function executionReceiptFromContext(context) {
1080
+ const candidates = [
1081
+ context?.execution_receipt,
1082
+ context?.executionReceipt,
1083
+ context?.canonical_execution_receipt,
1084
+ context?.canonicalExecutionReceipt,
1085
+ context?.execution?.receipt,
1086
+ context?.execution?.body,
1087
+ context?.receipt,
1088
+ ];
1089
+ return candidates.find((value) => typeof value === 'string' && value.trim() !== '') || null;
1090
+ }
1091
+
1092
+ function proofContainers(context) {
1093
+ return [
1094
+ context?.proofs,
1095
+ context?.workflow_proofs,
1096
+ context?.workflowProofs,
1097
+ context?.evidence,
1098
+ context?.obligation_evidence,
1099
+ context?.obligationEvidence,
1100
+ ].filter((value) => value && typeof value === 'object' && !Array.isArray(value));
1101
+ }
1102
+
1103
+ function concreteEvidence(value) {
1104
+ if (typeof value === 'string') return value.trim() !== '' && !isPlaceholderToken(value);
1105
+ if (Array.isArray(value)) return value.some(concreteEvidence);
1106
+ if (!value || typeof value !== 'object') return false;
1107
+ return ['evidence', 'proof', 'receipt', 'report', 'summary']
1108
+ .some((key) => concreteEvidence(value[key]));
1109
+ }
1110
+
1111
+ function evidenceForObligation(context, obligation) {
1112
+ for (const container of proofContainers(context)) {
1113
+ if (Object.prototype.hasOwnProperty.call(container, obligation) && concreteEvidence(container[obligation])) {
1114
+ return container[obligation];
1115
+ }
1116
+ }
1117
+ const aliases = {
1118
+ needsInspection: ['inspection', 'inspectionEvidence', 'inspection_evidence'],
1119
+ needsDurableTaskState: ['durableTaskState', 'durable_task_state', 'taskState', 'task_state'],
1120
+ needsResearchGrounding: ['research', 'researchGrounding', 'research_grounding'],
1121
+ };
1122
+ for (const key of aliases[obligation] || []) {
1123
+ if (concreteEvidence(context?.[key])) return context[key];
330
1124
  }
331
- if (tier === 'Standard') {
1125
+ return null;
1126
+ }
1127
+
1128
+ function independentAuditEvidence(context) {
1129
+ const runtimeContext = runtimeContextFromContext(context);
1130
+ const expectedProvenance = runtimeContext
1131
+ ? { base: runtimeContext.base, head: runtimeContext.head }
1132
+ : null;
1133
+ const candidates = [
1134
+ ...proofContainers(context).map((container) => container.needsIndependentAudit),
1135
+ context?.independentAudit,
1136
+ context?.independent_audit,
1137
+ context?.audit,
1138
+ ];
1139
+ return candidates.find((value) => {
1140
+ if (Array.isArray(value)) return false;
1141
+ return validateIndependentAuditEvidence(value, expectedProvenance, runtimeContext).valid;
1142
+ }) || null;
1143
+ }
1144
+
1145
+ function validateIndependentAuditEvidence(value, expectedProvenance, runtimeContext) {
1146
+ const errors = [];
1147
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1148
+ return { valid: false, errors: ['audit must be an object'] };
1149
+ }
1150
+ if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify([...INDEPENDENT_AUDIT_FIELDS].sort())) {
1151
+ errors.push(`audit fields must be exactly ${INDEPENDENT_AUDIT_FIELDS.join(', ')}`);
1152
+ }
1153
+ if (value.schema_version !== INDEPENDENT_AUDIT_SCHEMA_VERSION) {
1154
+ errors.push(`audit.schema_version must be "${INDEPENDENT_AUDIT_SCHEMA_VERSION}"`);
1155
+ }
1156
+ for (const field of ['reviewer_session_id', 'implementation_session_id']) {
1157
+ if (typeof value[field] !== 'string' || isPlaceholderToken(value[field])) {
1158
+ errors.push(`audit.${field} must be concrete`);
1159
+ }
1160
+ }
1161
+ if (typeof value.reviewer_session_id === 'string'
1162
+ && typeof value.implementation_session_id === 'string'
1163
+ && value.reviewer_session_id.trim() === value.implementation_session_id.trim()) {
1164
+ errors.push('audit reviewer and implementation sessions must be distinct');
1165
+ }
1166
+ if (typeof value.evidence !== 'string' || isPlaceholderToken(value.evidence)) {
1167
+ errors.push('audit.evidence must be concrete');
1168
+ }
1169
+ if (value.verdict !== 'PASS') errors.push('audit.verdict must be literal PASS');
1170
+
1171
+ if (!PROVENANCE.isTrustedRuntimeContext(runtimeContext)) {
1172
+ errors.push('audit runtime provenance/session context is unavailable');
1173
+ } else if (typeof value.implementation_session_id === 'string'
1174
+ && value.implementation_session_id.trim() !== runtimeContext.runtime_session) {
1175
+ errors.push('audit implementation session does not match the runtime session');
1176
+ }
1177
+
1178
+ if (!value.expected_provenance
1179
+ || typeof value.expected_provenance !== 'object'
1180
+ || Array.isArray(value.expected_provenance)
1181
+ || JSON.stringify(Object.keys(value.expected_provenance).sort()) !== JSON.stringify(['base', 'head'])) {
1182
+ errors.push('audit.expected_provenance must contain exactly base and head');
1183
+ }
1184
+ const actualBinding = expectedProvenanceFromSource(value.expected_provenance);
1185
+ const runtimeBinding = PROVENANCE.isTrustedRuntimeContext(runtimeContext)
1186
+ ? { base: runtimeContext.base, head: runtimeContext.head }
1187
+ : null;
1188
+ if (!actualBinding
1189
+ || !isConcreteProvenanceValue(actualBinding.base)
1190
+ || !isConcreteProvenanceValue(actualBinding.head)
1191
+ || !expectedProvenance
1192
+ || !isConcreteProvenanceValue(expectedProvenance.base)
1193
+ || !isConcreteProvenanceValue(expectedProvenance.head)
1194
+ || normalizeProvenanceValue(actualBinding.base) !== normalizeProvenanceValue(expectedProvenance.base)
1195
+ || normalizeProvenanceValue(actualBinding.head) !== normalizeProvenanceValue(expectedProvenance.head)) {
1196
+ errors.push('audit expected Base and Head binding does not match the runtime binding');
1197
+ }
1198
+ if (runtimeBinding
1199
+ && (normalizeProvenanceValue(actualBinding?.base) !== runtimeBinding.base
1200
+ || normalizeProvenanceValue(actualBinding?.head) !== runtimeBinding.head)) {
1201
+ errors.push('audit expected Base and Head do not match the freshly derived runtime binding');
1202
+ }
1203
+ return { valid: errors.length === 0, errors };
1204
+ }
1205
+
1206
+ function completionReceiptOptions(context) {
1207
+ const task = context?.task_context || context?.taskContext || context?.task || context;
1208
+ const runtimeContext = runtimeContextFromContext(context);
1209
+ const derived = receiptValidatorOptions(task && typeof task === 'object' ? task : {}, {
1210
+ requireProvenanceBinding: true,
1211
+ runtimeContext,
1212
+ });
1213
+ const supplied = context?.receipt_options || context?.receiptOptions;
1214
+ if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied)) return derived;
1215
+ return {
1216
+ ...derived,
1217
+ ...supplied,
1218
+ artifactDeclarationValid: derived.artifactDeclarationValid === false
1219
+ ? false
1220
+ : supplied.artifactDeclarationValid !== false,
1221
+ requireArtifactHash: derived.requireArtifactHash === true || supplied.requireArtifactHash === true,
1222
+ artifactPaths: derived.artifactPaths.length > 0 ? derived.artifactPaths : (Array.isArray(supplied.artifactPaths) ? supplied.artifactPaths : []),
1223
+ artifactRoot: derived.artifactRoot,
1224
+ verifyArtifactBytes: derived.verifyArtifactBytes === true || supplied.verifyArtifactBytes === true,
1225
+ expectedProvenance: derived.expectedProvenance,
1226
+ requireProvenanceBinding: true,
1227
+ };
1228
+ }
1229
+
1230
+ function runtimeBoundProofsFromSpec(spec, runtimeContext) {
1231
+ // Spec files are worker-writable: plain proof strings are never execution
1232
+ // evidence. Only canonical receipts bound to this fresh runtime may enter
1233
+ // the completion decision; independent audit remains a strict object schema.
1234
+ const proofSources = [
1235
+ 'completion_proofs',
1236
+ 'completionProofs',
1237
+ 'workflow_proofs',
1238
+ 'workflowProofs',
1239
+ 'obligation_evidence',
1240
+ 'obligationEvidence',
1241
+ 'proofs',
1242
+ ];
1243
+ const sourceKey = proofSources.find((key) => spec && spec[key] && typeof spec[key] === 'object' && !Array.isArray(spec[key]));
1244
+ if (!sourceKey) return null;
1245
+ const source = spec[sourceKey];
1246
+ const trusted = {};
1247
+ const receiptOptions = receiptValidatorOptions({}, {
1248
+ runtimeContext,
1249
+ requireProvenanceBinding: true,
1250
+ });
1251
+ for (const [obligation, value] of Object.entries(source)) {
1252
+ if (obligation === 'needsIndependentAudit') {
1253
+ trusted[obligation] = value;
1254
+ continue;
1255
+ }
1256
+ const candidates = value && typeof value === 'object' && !Array.isArray(value)
1257
+ ? [value.receipt, value.execution_receipt, value.executionReceipt]
1258
+ : [];
1259
+ const receipt = candidates.find((candidate) => (
1260
+ typeof candidate === 'string'
1261
+ && validateCanonicalReceipt(candidate, receiptOptions).length === 0
1262
+ ));
1263
+ if (receipt) trusted[obligation] = { receipt };
1264
+ }
1265
+ return trusted;
1266
+ }
1267
+
1268
+ function completionDecisionForSpec(spec, {
1269
+ runtimeContext,
1270
+ executionReceipt,
1271
+ taskContext,
1272
+ } = {}) {
1273
+ const policy = spec && typeof spec === 'object' ? spec.workflow_policy : null;
1274
+ const context = {
1275
+ workflow_policy: policy,
1276
+ runtime_context: runtimeContext,
1277
+ execution_receipt: executionReceipt,
1278
+ task_context: taskContext,
1279
+ };
1280
+ const trustedProofs = runtimeBoundProofsFromSpec(spec, runtimeContext);
1281
+ if (trustedProofs) context.proofs = trustedProofs;
1282
+ if (policy && policy.lane === 'Direct') {
332
1283
  return {
333
- tier,
334
- mode,
335
- delegated: ['code-auditor'],
336
- qualityGate: 'combined-ship-point',
337
- shipPoint: mode === 'full-spec' ? 'final-task' : 'requested-task',
1284
+ completion: 'not_applicable',
1285
+ unfinished: false,
1286
+ missingProof: [],
1287
+ blocker: null,
1288
+ };
1289
+ }
1290
+ return completionDecision('PASS', context);
1291
+ }
1292
+
1293
+ function completionDecision(verdict, context = {}) {
1294
+ if (!context || typeof context !== 'object' || Array.isArray(context)) context = {};
1295
+ const normalized = normalizeVerdict(verdict);
1296
+ if (normalized.verdict === 'PASS_WITH_WARNINGS') {
1297
+ return {
1298
+ verdict: normalized.verdict,
1299
+ action: 'review-passed-with-warnings',
1300
+ completion: 'unfinished',
1301
+ terminal: true,
1302
+ unfinished: true,
1303
+ retry: false,
1304
+ blocker: context.blocker || 'PASS_WITH_WARNINGS is not a completion verdict; literal PASS is required',
1305
+ };
1306
+ }
1307
+ if (normalized.verdict !== 'PASS') {
1308
+ return unfinishedVerdictDecision(normalized.verdict, context, normalized.diagnostic);
1309
+ }
1310
+
1311
+ const runtimeContext = runtimeContextFromContext(context);
1312
+ if (!runtimeContext) {
1313
+ return {
1314
+ verdict: normalized.verdict,
1315
+ action: 'blocked-runtime-provenance',
1316
+ completion: 'unfinished',
1317
+ terminal: true,
1318
+ unfinished: true,
1319
+ retry: false,
1320
+ blocker: 'completion blocked: runtime-derived provenance and session context are unavailable or stale',
1321
+ missingProof: ['runtime_provenance'],
1322
+ };
1323
+ }
1324
+ const decisionContext = { ...context, runtime_context: runtimeContext };
1325
+
1326
+ const missing = [];
1327
+ const receipt = executionReceiptFromContext(decisionContext);
1328
+ const receiptFailures = receipt
1329
+ ? validateCanonicalReceipt(receipt, completionReceiptOptions(decisionContext))
1330
+ : ['verification_state'];
1331
+ if (receiptFailures.length > 0) missing.push(`execution_receipt:${receiptFailures.join(',')}`);
1332
+
1333
+ const obligations = workflowPolicyObligations(decisionContext);
1334
+ if (!obligations) {
1335
+ missing.push('workflow_policy.proof_obligations');
1336
+ } else {
1337
+ for (const obligation of obligations) {
1338
+ if (obligation === 'needsExecutionProof') continue;
1339
+ if (obligation === 'needsIndependentAudit') {
1340
+ if (!independentAuditEvidence(decisionContext)) missing.push(obligation);
1341
+ continue;
1342
+ }
1343
+ if (!evidenceForObligation(decisionContext, obligation)) missing.push(obligation);
1344
+ }
1345
+ }
1346
+ if (missing.length > 0) {
1347
+ return {
1348
+ verdict: normalized.verdict,
1349
+ ...(normalized.diagnostic ? { diagnostic: normalized.diagnostic } : {}),
1350
+ action: 'await-required-proof',
1351
+ completion: 'unfinished',
1352
+ terminal: true,
1353
+ unfinished: true,
1354
+ retry: false,
1355
+ blocker: decisionContext.blocker || `completion blocked: missing ${missing.join(', ')}`,
1356
+ missingProof: missing,
338
1357
  };
339
1358
  }
340
1359
  return {
341
- tier,
342
- mode,
343
- delegated: Array.from({ length: taskCount }, () => [...DEEP_TASK_SEQUENCE]).flat(),
344
- qualityGate: 'per-task-stage-a-b',
345
- shipPoint: 'task',
1360
+ verdict: normalized.verdict,
1361
+ action: 'complete',
1362
+ completion: 'complete',
1363
+ terminal: true,
1364
+ unfinished: false,
1365
+ retry: false,
1366
+ blocker: null,
346
1367
  };
347
1368
  }
348
1369
 
349
- function consumeReviewVerdict(verdict) {
350
- assertVerdict(verdict);
351
- if (verdict === 'PASS') return { action: 'proceed', terminal: false };
352
- if (verdict === 'FAIL') return { action: 'fix-and-rerun', terminal: false };
353
- return { action: 'stop', terminal: true, blocker: 'review returned BLOCKED' };
1370
+ function consumeReviewVerdict(verdict, context = {}) {
1371
+ const result = adaptVerdict(verdict, context);
1372
+ return {
1373
+ ...result,
1374
+ source: 'review',
1375
+ completion: 'unfinished',
1376
+ unfinished: true,
1377
+ createsCompletion: false,
1378
+ completionDecisionRequired: true,
1379
+ action: result.verdict === 'PASS' || result.verdict === 'PASS_WITH_WARNINGS'
1380
+ ? 'review-result-only'
1381
+ : result.action,
1382
+ blocker: result.verdict === 'PASS' || result.verdict === 'PASS_WITH_WARNINGS'
1383
+ ? context.blocker || 'review verdict is not completion proof'
1384
+ : result.blocker,
1385
+ };
354
1386
  }
355
1387
 
356
1388
  function isFlashUnverified(task) {
357
1389
  return task?.status === 'in_progress' && task.receipt === 'FLASH_UNVERIFIED';
358
1390
  }
359
1391
 
1392
+ function isCanonicalFlashUnverified(task) {
1393
+ return isFlashUnverified(task)
1394
+ && task.dependencyBlocked === true
1395
+ && task.unblocks === false
1396
+ && typeof task.blocker === 'string'
1397
+ && !isPlaceholderToken(task.blocker)
1398
+ && !FLASH_PROMOTION_FIELDS.some((field) => hasOwn(task, field));
1399
+ }
1400
+
360
1401
  function isStaleFlashDone(task) {
361
1402
  return task?.status === 'done' && task.receipt === 'FLASH_UNVERIFIED';
362
1403
  }
363
1404
 
364
- function promoteFlashTask(task, verdict, proof = 'Verification: PASS') {
365
- if (verdict !== 'NO_TESTS') assertVerdict(verdict);
1405
+ const SYNC_FINALIZE_CAPABILITY = Symbol('sync-finalize-capability');
1406
+ const trustedFlashPromotions = new WeakMap();
1407
+
1408
+ function flashRuntimeContext(value) {
1409
+ if (!PROVENANCE.isTrustedRuntimeContext(value)) return null;
1410
+ try { return PROVENANCE.recomputeRuntimeContext(value); } catch (_) { return null; }
1411
+ }
1412
+
1413
+ function evaluateFlashPromotion(task, verdict, proof = 'Verification: PASS', capability = null, runtimeContext) {
1414
+ const trustedRuntime = flashRuntimeContext(runtimeContext);
1415
+ if (!trustedRuntime) {
1416
+ return blockedFlashSync(task, 'flash promotion blocked: runtime-derived provenance and session context are unavailable or stale');
1417
+ }
1418
+ const normalized = normalizeVerdict(verdict);
366
1419
  if (!isFlashUnverified(task)) return task;
367
1420
 
368
- if (verdict !== 'PASS') {
1421
+ if (normalized.verdict !== 'PASS') {
369
1422
  return {
370
1423
  ...task,
371
1424
  status: 'in_progress',
372
1425
  receipt: 'FLASH_UNVERIFIED',
373
- blocker: verdict === 'NO_TESTS'
1426
+ blocker: normalized.diagnostic === 'NO_TESTS'
374
1427
  ? 'awaiting test proof from /hapo:test <feature>'
375
- : `verification returned ${verdict}`,
1428
+ : normalized.diagnostic === 'PARTIAL'
1429
+ ? 'verification is partial; awaiting complete test proof'
1430
+ : `verification returned ${normalized.verdict}`,
376
1431
  dependencyBlocked: true,
377
1432
  unblocks: false,
378
1433
  readyForSync: false,
1434
+ flashTransition: 'unverified',
1435
+ promotionReceipt: null,
379
1436
  };
380
1437
  }
381
1438
 
382
- if (typeof proof !== 'string' || !/^Verification:\s*PASS\b/.test(proof.trim())) {
1439
+ if (typeof proof !== 'string') {
383
1440
  throw new TypeError('PASS promotion requires a concrete Verification: PASS receipt');
384
1441
  }
1442
+ const failures = validateCanonicalReceipt(proof, receiptValidatorOptions(task, {
1443
+ requireProvenanceBinding: true,
1444
+ runtimeContext: trustedRuntime,
1445
+ }));
1446
+ if (failures.length > 0) {
1447
+ return {
1448
+ ...task,
1449
+ status: 'in_progress',
1450
+ receipt: 'FLASH_UNVERIFIED',
1451
+ blocker: `canonical receipt required for promotion: missing ${failures.join(', ')}`,
1452
+ dependencyBlocked: true,
1453
+ unblocks: false,
1454
+ readyForSync: false,
1455
+ flashTransition: 'unverified',
1456
+ promotionReceipt: null,
1457
+ };
1458
+ }
385
1459
 
386
- return {
1460
+ const promoted = {
387
1461
  ...task,
388
1462
  status: 'in_progress',
389
1463
  receipt: proof.trim(),
@@ -391,26 +1465,110 @@ function promoteFlashTask(task, verdict, proof = 'Verification: PASS') {
391
1465
  dependencyBlocked: true,
392
1466
  unblocks: false,
393
1467
  readyForSync: true,
1468
+ flashTransition: 'promoted',
1469
+ promotionReceipt: proof.trim(),
394
1470
  };
1471
+ if (capability === SYNC_FINALIZE_CAPABILITY) trustedFlashPromotions.set(promoted, capability);
1472
+ return promoted;
395
1473
  }
396
1474
 
397
- function finalizeFlashTask(task, operation) {
398
- if (operation !== 'sync-finalize') return task;
1475
+ function promoteFlashTask(task, verdict, proof = 'Verification: PASS', runtimeContext) {
1476
+ const candidate = runtimeContext
1477
+ || task?.runtime_context
1478
+ || task?.runtimeContext
1479
+ || task?.receipt_binding?.runtimeContext
1480
+ || task?.receiptBinding?.runtimeContext;
1481
+ return evaluateFlashPromotion(task, verdict, proof, null, candidate);
1482
+ }
1483
+
1484
+ function finalizeFlashTaskInternal(task, operation, capability, runtimeContext) {
1485
+ if (operation !== 'sync-finalize' || capability !== SYNC_FINALIZE_CAPABILITY || trustedFlashPromotions.get(task) !== capability) {
1486
+ return blockedFlashSync(task, 'sync-finalize blocked: only trusted same-operation promotion may finalize');
1487
+ }
399
1488
  if (
400
1489
  task?.status !== 'in_progress'
401
- || !/^Verification:\s*PASS\b/.test(String(task.receipt || '').trim())
402
1490
  || task.readyForSync !== true
403
- ) return task;
1491
+ ) return blockedFlashSync(task, 'sync-finalize blocked: trusted promotion state is incomplete');
1492
+ const receipt = String(task.receipt || '');
1493
+ if (task.flashTransition !== 'promoted' || task.promotionReceipt !== receipt) {
1494
+ return blockedFlashSync(task, 'sync-finalize blocked: trusted promotion state is incomplete');
1495
+ }
1496
+ const failures = validateCanonicalReceipt(receipt, receiptValidatorOptions(task, {
1497
+ requireProvenanceBinding: true,
1498
+ runtimeContext,
1499
+ }));
1500
+ if (failures.length > 0) {
1501
+ return blockedFlashSync(task, `sync-finalize blocked: canonical receipt required (${failures.join(', ')})`);
1502
+ }
404
1503
 
1504
+ trustedFlashPromotions.delete(task);
405
1505
  return {
406
1506
  ...task,
407
1507
  status: 'done',
408
1508
  dependencyBlocked: false,
409
1509
  unblocks: true,
410
1510
  readyForSync: false,
1511
+ flashTransition: 'finalized',
1512
+ };
1513
+ }
1514
+
1515
+ function finalizeFlashTask(task, operation) {
1516
+ if (operation !== 'sync-finalize') return task;
1517
+ return blockedFlashSync(task, 'sync-finalize blocked: only syncFinalizeFlashTask owns finalization');
1518
+ }
1519
+
1520
+ function blockedFlashSync(task, blocker) {
1521
+ return {
1522
+ ...task,
1523
+ status: 'in_progress',
1524
+ receipt: 'FLASH_UNVERIFIED',
1525
+ dependencyBlocked: true,
1526
+ unblocks: false,
1527
+ readyForSync: false,
1528
+ flashTransition: 'unverified',
1529
+ promotionReceipt: null,
1530
+ blocker,
411
1531
  };
412
1532
  }
413
1533
 
1534
+ function syncFinalizeFlashTask(task, verdict, proof, runtimeContext) {
1535
+ if (!isCanonicalFlashUnverified(task)) {
1536
+ return blockedFlashSync(
1537
+ task,
1538
+ 'sync-finalize blocked: current task must be FLASH_UNVERIFIED in the exact canonical stored state; caller promotion state is not trusted',
1539
+ );
1540
+ }
1541
+ if (verdict !== 'PASS') {
1542
+ return blockedFlashSync(task, 'sync-finalize blocked: explicit PASS verdict is required');
1543
+ }
1544
+ if (typeof proof !== 'string' || proof.trim() === '') {
1545
+ return blockedFlashSync(task, 'sync-finalize blocked: explicit canonical --proof is required');
1546
+ }
1547
+
1548
+ // Treat all promotion fields from --task-json as untrusted. Rebuild the
1549
+ // unverified state, then derive promotion and finalization in this operation.
1550
+ const current = {
1551
+ ...task,
1552
+ readyForSync: false,
1553
+ flashTransition: 'unverified',
1554
+ promotionReceipt: null,
1555
+ dependencyBlocked: true,
1556
+ unblocks: false,
1557
+ };
1558
+ const candidate = runtimeContext
1559
+ || task?.runtime_context
1560
+ || task?.runtimeContext
1561
+ || task?.receipt_binding?.runtimeContext
1562
+ || task?.receiptBinding?.runtimeContext;
1563
+ const trustedRuntime = flashRuntimeContext(candidate);
1564
+ if (!trustedRuntime) {
1565
+ return blockedFlashSync(task, 'sync-finalize blocked: runtime-derived provenance and session context are unavailable or stale');
1566
+ }
1567
+ const promoted = evaluateFlashPromotion(current, 'PASS', proof, SYNC_FINALIZE_CAPABILITY, trustedRuntime);
1568
+ if (promoted.readyForSync !== true) return promoted;
1569
+ return finalizeFlashTaskInternal(promoted, 'sync-finalize', SYNC_FINALIZE_CAPABILITY, trustedRuntime);
1570
+ }
1571
+
414
1572
  function flashState(taskRegistry = {}) {
415
1573
  return Object.entries(taskRegistry)
416
1574
  .filter(([, task]) => isFlashUnverified(task))
@@ -420,38 +1578,157 @@ function flashState(taskRegistry = {}) {
420
1578
  function validateCanonicalReceipt(body, options = {}) {
421
1579
  if (typeof body !== 'string') return ['verification_state'];
422
1580
  const failures = [];
1581
+ const addFailure = (failure) => {
1582
+ if (!failures.includes(failure)) failures.push(failure);
1583
+ };
1584
+ // Fail-closed on placeholders anywhere — provenance, command, or artifact must not be templated
1585
+ if (/\{\{[^}]+\}\}/.test(body)) {
1586
+ addFailure('placeholder');
1587
+ // placeholder also implies missing concrete provenance/command; keep explicit failures for mapping
1588
+ }
1589
+ // Explicit failure outcome anywhere in body must make canonical validator fail (structured only)
1590
+ if (hasExplicitFailure(body)) {
1591
+ addFailure('verification_state');
1592
+ }
423
1593
  // Unambiguous verification state: must be Verification: PASS exactly
424
1594
  if (!/^\s*Verification:\s*PASS\s*$/m.test(body)) {
425
- failures.push('verification_state');
426
- }
427
- // Command must be present
428
- if (!/^\s*Command(?:\(s\))?\s*:/m.test(body)) {
429
- failures.push('command');
430
- }
431
- // Exit or Result must be present with success
432
- if (!/^\s*Exit\s*:|exit\s+code\s*[:=]|\bResult\s*:\s*PASS\b/im.test(body)) {
433
- failures.push('exit_result');
434
- }
435
- // Provenance: must carry both endpoints with non-empty values on same line
436
- // Base: <non-whitespace> plus Head: <non-whitespace>, OR base_sha: <non-whitespace> plus head_sha: <non-whitespace>
437
- const hasBase = /^\s*Base[ \t]*:[ \t]*\S/im.test(body);
438
- const hasHead = /^\s*Head[ \t]*:[ \t]*\S/im.test(body);
439
- const hasBaseSha = /\bbase_sha[ \t]*:[ \t]*\S/im.test(body);
440
- const hasHeadSha = /\bhead_sha[ \t]*:[ \t]*\S/im.test(body);
441
- if (!((hasBase && hasHead) || (hasBaseSha && hasHeadSha))) {
442
- failures.push('provenance');
443
- }
444
- // Artifact hash: if body mentions artifact, require sha256
445
- // For strict mode, require at least one sha256 or artifact reference?
446
- // Requirement says artifact or evidence hash when applicable. We make it optional but if artifact mentioned, require hash.
447
- // For done tasks that produce artifacts, hash should be present. We enforce that if body contains Artifact|artifact, then sha256 must be present.
448
- if (/\bartifact\b/i.test(body) && !/sha256:/i.test(body)) {
449
- failures.push('artifact_hash');
450
- }
451
- // Evidence hash: if no artifact but strict evidence hash required, we could require sha256 generally for done tasks?
452
- // Make it optional for now; but we can require at least one hash when requireArtifactHash true
453
- if (options.requireArtifactHash && !/sha256:/i.test(body)) {
454
- failures.push('artifact_hash');
1595
+ addFailure('verification_state');
1596
+ }
1597
+ // Command must be present with non-empty concrete value on same line, not placeholder
1598
+ const cmdLine = body.split('\n').find((l) => /^\s*Command(?:\(s\))?\s*:/m.test(l)) || null;
1599
+ if (!cmdLine || !/^\s*Command(?:\(s\))?\s*:[ \t]*\S/m.test(cmdLine)) {
1600
+ addFailure('command');
1601
+ } else {
1602
+ const m = cmdLine.match(/^\s*Command(?:\(s\))?\s*:[ \t]*(.*)$/m);
1603
+ const val = m ? m[1].trim() : '';
1604
+ if (isPlaceholderToken(val)) {
1605
+ addFailure('command');
1606
+ } else if (/\{\{[^}]+\}\}/.test(cmdLine)) {
1607
+ addFailure('command');
1608
+ }
1609
+ }
1610
+ // Exit / Result handling: collect all Result lines; if no Exit then need at least one Result and all PASS; if has Exit then every Exit integer 0 and every Result if any all PASS
1611
+ const lines = body.split('\n');
1612
+ const exitValues = [];
1613
+ const resultValues = [];
1614
+ for (const line of lines) {
1615
+ if (/^\s*Exit\s*:/i.test(line)) {
1616
+ const m = line.match(/^\s*Exit\s*:\s*(.*)$/i);
1617
+ exitValues.push(m ? m[1].trim() : '');
1618
+ } else if (/exit\s+code\s*[:=]/i.test(line)) {
1619
+ const m = line.match(/exit\s+code\s*[:=]\s*(.*)$/i);
1620
+ if (m) exitValues.push(m[1].trim());
1621
+ }
1622
+ if (/^\s*Result\s*:/i.test(line)) {
1623
+ const m = line.match(/^\s*Result\s*:\s*(.+?)\s*$/i);
1624
+ resultValues.push(m ? m[1].trim() : '');
1625
+ }
1626
+ }
1627
+
1628
+ if (exitValues.length === 0) {
1629
+ if (resultValues.length === 0 || resultValues.some((v) => v !== 'PASS')) {
1630
+ addFailure('exit_result');
1631
+ }
1632
+ } else {
1633
+ let exitOk = true;
1634
+ for (const v of exitValues) {
1635
+ if (!/^-?\d+$/.test(v)) { exitOk = false; break; }
1636
+ const n = Number.parseInt(v, 10);
1637
+ if (n !== 0) { exitOk = false; break; }
1638
+ }
1639
+ if (!exitOk) addFailure('exit_result');
1640
+ if (resultValues.length > 0 && resultValues.some((v) => v !== 'PASS')) {
1641
+ addFailure('exit_result');
1642
+ }
1643
+ }
1644
+ // Provenance is either a concrete Base/Head pair or a concrete base_sha/head_sha pair.
1645
+ // Without expected values this validates schema only; it does not claim identity binding.
1646
+ const receiptLines = body.split('\n');
1647
+ const readField = (name) => receiptLines
1648
+ .filter((line) => new RegExp(`^\\s*${name}\\s*:`, 'i').test(line))
1649
+ .map((line) => line.replace(new RegExp(`^\\s*${name}\\s*:\\s*`, 'i'), '').trim());
1650
+ const baseValues = readField('Base');
1651
+ const headValues = readField('Head');
1652
+ const baseShaValues = readField('base_sha');
1653
+ const headShaValues = readField('head_sha');
1654
+ const labelStyle = baseValues.length > 0 || headValues.length > 0;
1655
+ const shaStyle = baseShaValues.length > 0 || headShaValues.length > 0;
1656
+ let actualProvenance = null;
1657
+ if (labelStyle === shaStyle || labelStyle && (baseShaValues.length > 0 || headShaValues.length > 0)) {
1658
+ addFailure('provenance');
1659
+ } else {
1660
+ const base = labelStyle ? baseValues : baseShaValues;
1661
+ const head = labelStyle ? headValues : headShaValues;
1662
+ if (base.length !== 1 || head.length !== 1 || !isConcreteProvenanceValue(base[0]) || !isConcreteProvenanceValue(head[0])) {
1663
+ addFailure('provenance');
1664
+ } else {
1665
+ actualProvenance = { base: normalizeProvenanceValue(base[0]), head: normalizeProvenanceValue(head[0]) };
1666
+ }
1667
+ }
1668
+ const expected = options.expectedProvenance || options.expected_provenance || options.expected || null;
1669
+ const expectedBase = expected && typeof expected === 'object'
1670
+ ? firstDefined(expected, ['base', 'Base', 'base_sha', 'baseSha', 'expectedBase', 'expected_base'])
1671
+ : options.expectedBase ?? options.expected_base ?? options.expectedBaseSha ?? options.expected_base_sha;
1672
+ const expectedHead = expected && typeof expected === 'object'
1673
+ ? firstDefined(expected, ['head', 'Head', 'head_sha', 'headSha', 'expectedHead', 'expected_head'])
1674
+ : options.expectedHead ?? options.expected_head ?? options.expectedHeadSha ?? options.expected_head_sha;
1675
+ if (options.requireProvenanceBinding === true || expectedBase !== undefined || expectedHead !== undefined) {
1676
+ if (!actualProvenance
1677
+ || !isConcreteProvenanceValue(expectedBase)
1678
+ || !isConcreteProvenanceValue(expectedHead)
1679
+ || normalizeProvenanceValue(expectedBase) !== actualProvenance.base
1680
+ || normalizeProvenanceValue(expectedHead) !== actualProvenance.head) {
1681
+ addFailure('provenance');
1682
+ }
1683
+ }
1684
+
1685
+ if (options.artifactDeclarationValid === false) addFailure('artifact_declaration');
1686
+ const artifactDeclarations = parseArtifactDeclarations(body);
1687
+ for (const declaration of artifactDeclarations) {
1688
+ if (declaration.hashes.length === 0 || declaration.hashes.some((hash) => !/^[a-f0-9]{64}$/i.test(hash))) {
1689
+ addFailure('artifact_hash');
1690
+ }
1691
+ const declaredPaths = declaration.pathText
1692
+ .split(/\s*(?:\+|,|;|\band\b)\s*/i)
1693
+ .map((value) => value.trim())
1694
+ .filter(Boolean);
1695
+ if (declaredPaths.length > 1 && declaration.hashes.length !== declaredPaths.length) {
1696
+ addFailure('artifact_hash');
1697
+ }
1698
+ }
1699
+ const artifactPaths = Array.isArray(options.artifactPaths) ? options.artifactPaths : [];
1700
+ if (options.requireArtifactHash) {
1701
+ if (artifactDeclarations.length === 0) addFailure('artifact_hash');
1702
+ for (const artifactPath of artifactPaths) {
1703
+ const matches = artifactDeclarations.filter((declaration) => {
1704
+ const candidates = declaration.pathText.split(/\s*(?:\+|,|;|\band\b)\s*/i).map((value) => value.trim()).filter(Boolean);
1705
+ return candidates.length === 1 && candidates.includes(artifactPath);
1706
+ });
1707
+ if (matches.length !== 1 || matches[0].hashes.length !== 1 || !/^[a-f0-9]{64}$/i.test(matches[0].hashes[0])) {
1708
+ addFailure('artifact_hash');
1709
+ }
1710
+ }
1711
+ }
1712
+ if (options.verifyArtifactBytes === true || typeof options.artifactRoot === 'string') {
1713
+ if (typeof options.artifactRoot !== 'string' || options.artifactRoot.trim() === '') {
1714
+ if (artifactDeclarations.length > 0 || options.requireArtifactHash) addFailure('artifact_hash');
1715
+ } else {
1716
+ for (const declaration of artifactDeclarations) {
1717
+ const declaredPaths = declaration.pathText
1718
+ .split(/\s*(?:\+|,|;|\band\b)\s*/i)
1719
+ .map((value) => value.trim())
1720
+ .filter(Boolean);
1721
+ if (declaredPaths.length === 0 || declaration.hashes.length !== declaredPaths.length) {
1722
+ addFailure('artifact_hash');
1723
+ continue;
1724
+ }
1725
+ for (let index = 0; index < declaredPaths.length; index += 1) {
1726
+ if (!hashValidatedArtifact(options.artifactRoot, declaredPaths[index], declaration.hashes[index])) {
1727
+ addFailure('artifact_hash');
1728
+ }
1729
+ }
1730
+ }
1731
+ }
455
1732
  }
456
1733
  return failures;
457
1734
  }
@@ -466,9 +1743,16 @@ function parseCliArgs(argv) {
466
1743
  action: null,
467
1744
  lane: null,
468
1745
  override: null,
469
- proof: 'Verification: PASS',
1746
+ proof: null,
470
1747
  userAuthorized: false,
471
1748
  origin: null,
1749
+ projectRoot: null,
1750
+ specsRoot: null,
1751
+ specFile: null,
1752
+ featureName: null,
1753
+ runtimeSession: null,
1754
+ provenanceMode: null,
1755
+ runtimeContextInput: undefined,
472
1756
  };
473
1757
  const args = [...argv];
474
1758
  for (let i = 0; i < args.length; i += 1) {
@@ -483,6 +1767,9 @@ function parseCliArgs(argv) {
483
1767
  else if (arg === '--sync-finalize') options.action = 'sync-finalize';
484
1768
  else if (arg === '--classify-lane') options.action = 'classify-lane';
485
1769
  else if (arg === '--lane-policy') options.action = 'lane-policy';
1770
+ else if (arg === '--policy-snapshot') options.action = 'policy-snapshot';
1771
+ else if (arg === '--validate-policy') options.action = 'validate-policy';
1772
+ else if (arg === '--completion-decision') options.action = 'completion-decision';
486
1773
  else if (arg === '--approval-state') options.action = 'approval-state';
487
1774
  else if (arg === '--validate-approval') options.action = 'validate-approval';
488
1775
  else if (arg === '--validate-receipt') options.action = 'validate-receipt';
@@ -498,6 +1785,16 @@ function parseCliArgs(argv) {
498
1785
  } else if (arg === '--proof') {
499
1786
  options.proof = args[++i];
500
1787
  if (!options.proof) throw new Error('--proof requires a value');
1788
+ } else if (arg === '--project-root') options.projectRoot = args[++i];
1789
+ else if (arg === '--specs-root') options.specsRoot = args[++i];
1790
+ else if (arg === '--spec-file') options.specFile = args[++i];
1791
+ else if (arg === '--feature-name') options.featureName = args[++i];
1792
+ else if (arg === '--runtime-session' || arg === '--session') options.runtimeSession = args[++i];
1793
+ else if (arg === '--provenance-mode') options.provenanceMode = args[++i];
1794
+ else if (arg === '--runtime-context') {
1795
+ const raw = args[++i];
1796
+ if (!raw) throw new Error('--runtime-context requires a JSON value');
1797
+ options.runtimeContextInput = JSON.parse(raw);
501
1798
  } else {
502
1799
  throw new Error(`Unknown option: ${arg}`);
503
1800
  }
@@ -505,6 +1802,23 @@ function parseCliArgs(argv) {
505
1802
  return options;
506
1803
  }
507
1804
 
1805
+ function cliRuntimeContext(options) {
1806
+ if (options.runtimeContextInput !== undefined) {
1807
+ throw new Error('--runtime-context JSON is not trusted; provide runtime identity flags');
1808
+ }
1809
+ const supplied = [options.projectRoot, options.specsRoot, options.specFile, options.featureName, options.runtimeSession]
1810
+ .some((value) => value !== null && value !== undefined);
1811
+ if (!supplied) return null;
1812
+ return PROVENANCE.deriveRuntimeContext({
1813
+ projectRoot: options.projectRoot,
1814
+ specsRoot: options.specsRoot,
1815
+ specFile: options.specFile,
1816
+ featureName: options.featureName,
1817
+ runtimeSession: options.runtimeSession,
1818
+ provenanceMode: options.provenanceMode || undefined,
1819
+ });
1820
+ }
1821
+
508
1822
  function cliResult(result, json) {
509
1823
  process.stdout.write(json ? `${JSON.stringify(result)}\n` : `${result.message}\n`);
510
1824
  return result.exitCode;
@@ -541,6 +1855,17 @@ function runCli(argv = process.argv.slice(2)) {
541
1855
  const policy = lanePolicy(input);
542
1856
  return cliResult({ ok: true, policy, exitCode: 0, message: `Lane: ${policy.lane}` }, options.json);
543
1857
  }
1858
+ if (options.action === 'policy-snapshot') {
1859
+ if (!options.task) throw new Error('--policy-snapshot requires --task-json');
1860
+ const snapshot = workflowPolicySnapshot(options.task);
1861
+ return cliResult({ ok: true, snapshot, exitCode: 0, message: `Policy snapshot: ${snapshot.lane}` }, options.json);
1862
+ }
1863
+ if (options.action === 'validate-policy') {
1864
+ if (!options.task) throw new Error('--validate-policy requires --task-json');
1865
+ const snapshot = hasOwn(options.task, 'workflow_policy') ? options.task.workflow_policy : options.task;
1866
+ const result = validateWorkflowPolicySnapshot(snapshot);
1867
+ return cliResult({ ok: result.valid, ...result, exitCode: result.valid ? 0 : 2, message: result.valid ? 'Workflow policy snapshot valid' : result.errors.join('; ') }, options.json);
1868
+ }
544
1869
  if (options.action === 'approval-state') {
545
1870
  if (!options.task) throw new Error('--approval-state requires --task-json');
546
1871
  const state = approvalState(options.task);
@@ -554,22 +1879,30 @@ function runCli(argv = process.argv.slice(2)) {
554
1879
  if (options.action === 'validate-receipt') {
555
1880
  if (!options.task) throw new Error('--validate-receipt requires --task-json');
556
1881
  const body = options.task.body || options.task.receipt || '';
557
- const failures = validateCanonicalReceipt(body);
1882
+ const failures = validateCanonicalReceipt(body, receiptValidatorOptions(options.task, { runtimeContext: cliRuntimeContext(options) }));
558
1883
  return cliResult({ ok: failures.length === 0, failures, exitCode: failures.length === 0 ? 0 : 2, message: failures.length === 0 ? 'Receipt valid' : `Receipt missing: ${failures.join(', ')}` }, options.json);
559
1884
  }
560
1885
  if (options.action === 'consume-verdict') {
561
- const result = consumeReviewVerdict(options.verdict);
1886
+ const result = consumeReviewVerdict(options.verdict, options.task || {});
562
1887
  return cliResult({ ok: true, ...result, exitCode: 0, message: `Verdict ${options.verdict}: ${result.action}` }, options.json);
563
1888
  }
1889
+ if (options.action === 'completion-decision') {
1890
+ const runtimeContext = cliRuntimeContext(options);
1891
+ const result = completionDecision(options.verdict, runtimeContext
1892
+ ? { ...(options.task || {}), runtime_context: runtimeContext }
1893
+ : (options.task || {}));
1894
+ return cliResult({ ok: true, ...result, exitCode: 0, message: `Verdict ${options.verdict}: ${result.completion}` }, options.json);
1895
+ }
564
1896
  if (options.action === 'promote-flash') {
565
1897
  if (!options.task) throw new Error('--promote-flash requires --task-json');
566
- const result = promoteFlashTask(options.task, options.verdict, options.proof);
1898
+ const result = promoteFlashTask(options.task, options.verdict, options.proof, cliRuntimeContext(options));
567
1899
  return cliResult({ ok: true, task: result, exitCode: 0, message: 'Flash task promotion evaluated.' }, options.json);
568
1900
  }
569
1901
  if (options.action === 'sync-finalize') {
570
1902
  if (!options.task) throw new Error('--sync-finalize requires --task-json');
571
- const result = finalizeFlashTask(options.task, 'sync-finalize');
572
- return cliResult({ ok: true, task: result, exitCode: 0, message: 'Flash task sync-finalize evaluated.' }, options.json);
1903
+ const result = syncFinalizeFlashTask(options.task, options.verdict, options.proof, cliRuntimeContext(options));
1904
+ const ok = result.status === 'done';
1905
+ return cliResult({ ok, task: result, exitCode: ok ? 0 : 2, message: ok ? 'Flash task sync-finalize evaluated.' : (result.blocker || 'Flash task sync-finalize blocked.') }, options.json);
573
1906
  }
574
1907
 
575
1908
  return cliResult({ ok: true, contract: 'execution-policy', ...policy, exitCode: 0, message: `Execution mode: ${policy.mode}` }, options.json);
@@ -583,26 +1916,60 @@ if (require.main === module) process.exitCode = runCli();
583
1916
 
584
1917
  module.exports = {
585
1918
  REVIEW_VERDICTS,
1919
+ CANONICAL_VERDICTS,
1920
+ LEGACY_DIAGNOSTIC_VERDICTS,
586
1921
  EXECUTION_TIERS,
587
1922
  LANES,
1923
+ ARTIFACT_PROFILES,
1924
+ PROOF_OBLIGATIONS,
1925
+ WORKFLOW_POLICY_VERSION,
1926
+ WORKFLOW_POLICY_FIELDS,
1927
+ RECEIPT_BINDING_FIELDS,
1928
+ INDEPENDENT_AUDIT_SCHEMA_VERSION,
1929
+ INDEPENDENT_AUDIT_FIELDS,
588
1930
  APPROVAL_SCHEMA_VERSION,
589
1931
  LEGACY_APPROVAL_ERROR,
590
- DEEP_TASK_SEQUENCE,
591
- CRITICAL_LANE_SEQUENCE,
592
1932
  executionPolicy,
593
1933
  classifyLane,
1934
+ obligationsForLane,
1935
+ actorNeedsFor,
1936
+ isWorkflowPolicySnapshot,
1937
+ validateWorkflowPolicySnapshot,
1938
+ assertWorkflowPolicySnapshot,
1939
+ workflowPolicySnapshot,
1940
+ createWorkflowPolicySnapshot,
1941
+ persistWorkflowPolicySnapshot,
1942
+ persistWorkflowPolicy,
1943
+ readWorkflowPolicySnapshot,
1944
+ escalateWorkflowPolicy,
1945
+ policyForSpec,
594
1946
  lanePolicy,
595
1947
  approvalState,
596
1948
  validateApprovalSchema,
597
1949
  isLegacyApprovalObject,
598
1950
  isUserAuthorizedForDowngrade,
1951
+ isValidOverrideReceipt,
1952
+ isTapMetadataHeading,
1953
+ deriveRuntimeContext: PROVENANCE.deriveRuntimeContext,
1954
+ recomputeRuntimeContext: PROVENANCE.recomputeRuntimeContext,
1955
+ isTrustedRuntimeContext: PROVENANCE.isTrustedRuntimeContext,
1956
+ createReceiptBinding,
1957
+ receiptValidatorOptions,
599
1958
  validateCanonicalReceipt,
1959
+ validateIndependentAuditEvidence,
1960
+ completionDecisionForSpec,
600
1961
  delegationPlan,
1962
+ assertVerdict,
1963
+ normalizeVerdict,
1964
+ adaptVerdict,
1965
+ completionDecision,
601
1966
  consumeReviewVerdict,
602
1967
  isFlashUnverified,
1968
+ isCanonicalFlashUnverified,
603
1969
  isStaleFlashDone,
604
1970
  promoteFlashTask,
605
1971
  finalizeFlashTask,
1972
+ syncFinalizeFlashTask,
606
1973
  flashState,
607
1974
  parseCliArgs,
608
1975
  runCli,