@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
@@ -8,6 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
+ const POLICY = require('./workflow-policy.cjs');
11
12
 
12
13
  const TASK_PATH_RE = /^tasks\/task-R\d+-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
13
14
  const REQUIRED_REGISTRY_KEYS = [
@@ -23,6 +24,7 @@ const REQUIRED_REGISTRY_KEYS = [
23
24
  const APPROVAL_SCHEMA_VERSION = '2.0';
24
25
  const LEGACY_APPROVAL_ERROR =
25
26
  `Legacy approval field "approved" detected. 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.`;
27
+ const FEATURE_RECEIPT_FILE = 'feature-receipt.md';
26
28
 
27
29
  function usage() {
28
30
  console.error('Usage: node .claude/scripts/validate-spec-output.cjs specs/<feature>');
@@ -50,15 +52,121 @@ function readJson(filePath, errors) {
50
52
  }
51
53
  }
52
54
 
53
- function listTaskFiles(specDir) {
54
- const tasksDir = path.join(specDir, 'tasks');
55
- if (!fs.existsSync(tasksDir)) return [];
55
+ function isPathInside(parent, child) {
56
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
57
+ return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
58
+ }
59
+
60
+ function isMissingError(error) {
61
+ return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
62
+ }
63
+
64
+ function validateSpecRoot(specDir, errors) {
65
+ let rootStat;
66
+ try {
67
+ rootStat = fs.lstatSync(specDir);
68
+ } catch (error) {
69
+ errors.push(`${specDir}: spec directory does not exist (${error.message})`);
70
+ return null;
71
+ }
72
+ if (rootStat.isSymbolicLink()) {
73
+ errors.push(`${specDir}: spec directory cannot be a symlink`);
74
+ return null;
75
+ }
76
+ if (!rootStat.isDirectory()) {
77
+ errors.push(`${specDir}: spec path must be a directory`);
78
+ return null;
79
+ }
80
+ try {
81
+ return fs.realpathSync(specDir);
82
+ } catch (error) {
83
+ errors.push(`${specDir}: spec directory canonicalization failed (${error.message})`);
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function inspectSpecArtifact(specDir, canonicalSpecDir, relativePath, label, errors, { required = false, type = 'file' } = {}) {
89
+ const target = path.resolve(specDir, relativePath);
90
+ if (!isPathInside(specDir, target)) {
91
+ errors.push(`${label}: path must stay inside the spec directory`);
92
+ return null;
93
+ }
94
+
95
+ const relative = path.relative(path.resolve(specDir), target);
96
+ let current = path.resolve(specDir);
97
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
98
+ current = path.join(current, segment);
99
+ let stat;
100
+ try {
101
+ stat = fs.lstatSync(current);
102
+ } catch (error) {
103
+ if (isMissingError(error)) {
104
+ if (required) errors.push(`${label}: missing`);
105
+ return null;
106
+ }
107
+ errors.push(`${label}: cannot be inspected (${error.message})`);
108
+ return null;
109
+ }
110
+ if (stat.isSymbolicLink()) {
111
+ errors.push(`${label}: symlink is not allowed (${current})`);
112
+ return null;
113
+ }
114
+ }
115
+
116
+ let stat;
117
+ try {
118
+ stat = fs.lstatSync(target);
119
+ } catch (error) {
120
+ if (isMissingError(error)) {
121
+ if (required) errors.push(`${label}: missing`);
122
+ return null;
123
+ }
124
+ errors.push(`${label}: cannot be inspected (${error.message})`);
125
+ return null;
126
+ }
127
+ const typeMatches = type === 'directory' ? stat.isDirectory() : stat.isFile();
128
+ if (!typeMatches) {
129
+ errors.push(`${label}: must be a regular ${type}`);
130
+ return null;
131
+ }
132
+
133
+ let canonicalTarget;
134
+ try {
135
+ canonicalTarget = fs.realpathSync(target);
136
+ } catch (error) {
137
+ errors.push(`${label}: canonicalization failed (${error.message})`);
138
+ return null;
139
+ }
140
+ if (!isPathInside(canonicalSpecDir, canonicalTarget)) {
141
+ errors.push(`${label}: canonical path escapes the spec directory`);
142
+ return null;
143
+ }
144
+ return { path: target, canonicalPath: canonicalTarget, stat };
145
+ }
56
146
 
57
- return fs
58
- .readdirSync(tasksDir, { withFileTypes: true })
59
- .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
60
- .map((entry) => `tasks/${entry.name}`)
61
- .sort();
147
+ function listTaskFiles(specDir, canonicalSpecDir, errors) {
148
+ const tasks = inspectSpecArtifact(specDir, canonicalSpecDir, 'tasks', 'tasks directory', errors, {
149
+ type: 'directory',
150
+ });
151
+ if (!tasks) return [];
152
+
153
+ let entries;
154
+ try {
155
+ entries = fs.readdirSync(tasks.path, { withFileTypes: true });
156
+ } catch (error) {
157
+ errors.push(`tasks directory: cannot be read (${error.message})`);
158
+ return [];
159
+ }
160
+ const files = [];
161
+ for (const entry of entries) {
162
+ const relative = path.join('tasks', entry.name).split(path.sep).join('/');
163
+ const artifact = inspectSpecArtifact(specDir, canonicalSpecDir, relative, relative, errors);
164
+ if (artifact && !entry.name.endsWith('.md')) {
165
+ errors.push(`${relative}: unexpected task artifact; only .md task files are allowed`);
166
+ }
167
+ if (artifact && entry.name.endsWith('.md')) files.push(relative);
168
+ }
169
+ return files.sort();
62
170
  }
63
171
 
64
172
  function hasHeading(content, heading) {
@@ -306,7 +414,150 @@ function validateRelatedFiles(taskPath, content, errors) {
306
414
  }
307
415
  }
308
416
 
309
- function validateStateTransitions(spec, errors) {
417
+ function validateArtifactDeclaration(taskPath, entry, errors) {
418
+ if (!Object.prototype.hasOwnProperty.call(entry || {}, 'artifacts')) return;
419
+ const artifacts = entry.artifacts;
420
+ if (!Array.isArray(artifacts) || artifacts.length === 0) {
421
+ errors.push(`spec.json.task_registry.${taskPath}.artifacts: must be a non-empty array of safe relative paths`);
422
+ return;
423
+ }
424
+
425
+ const seen = new Set();
426
+ for (const artifact of artifacts) {
427
+ const invalid = typeof artifact !== 'string'
428
+ || artifact.trim() === ''
429
+ || artifact !== artifact.trim()
430
+ || /^\{\{[^}]+\}\}$/.test(artifact)
431
+ || /^(?:TBD|TODO|N\/A|NA|NONE|UNKNOWN|PENDING|PLACEHOLDER|REPLACE_ME)$/i.test(artifact)
432
+ || /^(?:[A-Za-z]:[\\/]|[\\/]|https?:\/\/)/.test(artifact)
433
+ || artifact.split(/[\\/]+/).includes('..')
434
+ || seen.has(artifact);
435
+ if (invalid) {
436
+ errors.push(`spec.json.task_registry.${taskPath}.artifacts: each entry must be a unique safe relative path (${String(artifact)})`);
437
+ continue;
438
+ }
439
+ seen.add(artifact);
440
+ }
441
+ }
442
+
443
+ function workflowPolicyContext(spec, errors) {
444
+ if (Object.prototype.hasOwnProperty.call(spec, 'workflow_policy')) {
445
+ const result = POLICY.validateWorkflowPolicySnapshot(spec.workflow_policy);
446
+ if (!result.valid) {
447
+ for (const error of result.errors) errors.push(error);
448
+ return { explicit: true, policy: null, bounded: false, strict: false };
449
+ }
450
+ return {
451
+ explicit: true,
452
+ policy: spec.workflow_policy,
453
+ bounded: spec.workflow_policy.artifact_profile === 'bounded',
454
+ strict: spec.workflow_policy.artifact_profile === 'strict',
455
+ };
456
+ }
457
+
458
+ const legacyTier = spec.design_context?.execution_tier;
459
+ errors.push(
460
+ legacyTier
461
+ ? 'spec.json.workflow_policy: missing persisted snapshot; design_context.execution_tier is a read-only legacy adapter and cannot establish workflow authority or readiness'
462
+ : 'spec.json.workflow_policy: missing persisted snapshot; the current spec boundary requires an explicit workflow_policy',
463
+ );
464
+ return { explicit: false, policy: null, bounded: false, strict: false };
465
+ }
466
+
467
+ function validateFeatureReceipt(spec, context, errors, receiptArtifact) {
468
+ if (!context.explicit || !context.bounded) return;
469
+ if (!receiptArtifact) {
470
+ errors.push(`${FEATURE_RECEIPT_FILE}: required by bounded Standard workflow policy`);
471
+ return;
472
+ }
473
+ let body;
474
+ try {
475
+ body = fs.readFileSync(receiptArtifact.path, 'utf8');
476
+ } catch (error) {
477
+ errors.push(`${FEATURE_RECEIPT_FILE}: cannot be read (${error.message})`);
478
+ return;
479
+ }
480
+ // A spec-ready artifact is a design boundary, not execution closeout. Keep a
481
+ // pending receipt explicit. Feature receipts are informational and never own
482
+ // completion authority; task Evidence receipts are the only closeout source.
483
+ if (/^\s*Verification:\s*PENDING\s*$/im.test(body)) return;
484
+ errors.push(`${FEATURE_RECEIPT_FILE}: informational only; keep Verification: PENDING and use task Evidence for completion authority`);
485
+ }
486
+
487
+ const SPEC_PLACEHOLDER_RE = /\{\{[^}\n]+\}\}|<[^>\n]+>|\b(?:TBD|TODO|N\/A|NA|NONE|UNKNOWN|PENDING|PLACEHOLDER|REPLACE_ME)\b|\.{3,}/i;
488
+ const SPEC_TEMPLATE_GUIDANCE_RE = /\b(?:to be filled|filled by (?:the )?(?:feature owner|user)|fill (?:this|in)|replace (?:this|with)|add (?:the )?real)\b/i;
489
+
490
+ function hasSpecPlaceholders(content) {
491
+ return SPEC_PLACEHOLDER_RE.test(content) || SPEC_TEMPLATE_GUIDANCE_RE.test(content);
492
+ }
493
+
494
+ function validateBoundedReadiness(spec, requirementsText, designText, errors) {
495
+ if (spec.ready_for_implementation !== true) return;
496
+
497
+ const source = spec.scope_lock?.source;
498
+ if (typeof source !== 'string' || source.trim() === '' || hasSpecPlaceholders(source)) {
499
+ errors.push('spec.json.scope_lock.source: Standard bounded readiness requires a concrete scope source');
500
+ }
501
+ if (hasSpecPlaceholders(requirementsText)) {
502
+ errors.push('requirements.md: Standard bounded readiness rejects placeholders');
503
+ }
504
+ if (hasSpecPlaceholders(designText)) {
505
+ errors.push('design.md: Standard bounded readiness rejects placeholders');
506
+ }
507
+ }
508
+
509
+ function validateResearchPointer(specDir, canonicalSpecDir, spec, errors) {
510
+ if (!Object.prototype.hasOwnProperty.call(spec, 'research') || spec.research === null) return;
511
+ const pointer = spec.research && typeof spec.research === 'object' && !Array.isArray(spec.research)
512
+ ? spec.research.path
513
+ : spec.research;
514
+ if (
515
+ typeof pointer !== 'string'
516
+ || pointer.trim() === ''
517
+ || pointer !== pointer.trim()
518
+ || path.isAbsolute(pointer)
519
+ || /^[A-Za-z]:[\\/]/.test(pointer)
520
+ || pointer.split(/[\\/]+/).includes('..')
521
+ ) {
522
+ errors.push('spec.research: must be a safe relative path inside the spec directory');
523
+ return;
524
+ }
525
+ inspectSpecArtifact(specDir, canonicalSpecDir, pointer, 'spec.research path', errors);
526
+ }
527
+
528
+ function hasConcreteResearchEvidence(section) {
529
+ const placeholder = /\{\{[^}]+\}\}|<[^>]+>|\[[^\]]+\]|\b(?:TBD|TODO|N\/A|NA|NONE|UNKNOWN|PENDING|PLACEHOLDER|REPLACE_ME)\b/i;
530
+ const boilerplate = /^(?:this section is mandatory\b.*|(?:result or skip rationale|relevant files\/modules|existing patterns\/contracts|tests or checks affected|decision|why it fits the current codebase|why it fits current external constraints|task implication|test\/verification implication)\s*:?)$/i;
531
+ const generic = /^(?:finding|gap|alternative)\s+\d+\b|^(?:codebase scout|external \/ current research)\s*:\s*required\s*\/\s*skipped/i;
532
+
533
+ return section.split('\n').some((rawLine) => {
534
+ const line = rawLine
535
+ .trim()
536
+ .replace(/^[-*+]\s*/, '')
537
+ .replace(/^\||\|$/g, '')
538
+ .replace(/\*\*/g, '')
539
+ .replace(/`/g, '')
540
+ .trim();
541
+ if (!line || line.endsWith(':') || placeholder.test(line) || boilerplate.test(line) || generic.test(line)) return false;
542
+ return line.length >= 8;
543
+ });
544
+ }
545
+
546
+ function validateResearchArtifact(researchArtifact, errors, requireConcrete = true) {
547
+ if (!researchArtifact) {
548
+ errors.push('research.md: missing Evidence Summary for non-trivial spec');
549
+ return;
550
+ }
551
+ const research = fs.readFileSync(researchArtifact.path, 'utf8');
552
+ const evidence = extractSection(research, 'Evidence Summary');
553
+ if (evidence === null) {
554
+ errors.push('research.md: missing ## Evidence Summary');
555
+ } else if (requireConcrete && !hasConcreteResearchEvidence(evidence)) {
556
+ errors.push('research.md: Evidence Summary must contain concrete evidence; empty or placeholder content is not sufficient');
557
+ }
558
+ }
559
+
560
+ function validateStateTransitions(spec, errors, context = {}) {
310
561
  // Approval schema v2: generated, agent_validated, user_approved independent.
311
562
  // Legacy "approved" field is rejected fail-closed with migration guidance.
312
563
  const approvals = spec.approvals || {};
@@ -349,7 +600,10 @@ function validateStateTransitions(spec, errors) {
349
600
  if (hasLegacy) {
350
601
  errors.push(`spec.json.ready_for_implementation: cannot be true with legacy approval fields — migrate to agent_validated/user_approved`);
351
602
  }
352
- for (const stage of ['requirements', 'design', 'tasks']) {
603
+ const stages = context.bounded && !(Array.isArray(spec.task_files) && spec.task_files.length > 0)
604
+ ? ['requirements', 'design']
605
+ : ['requirements', 'design', 'tasks'];
606
+ for (const stage of stages) {
353
607
  const approval = spec.approvals?.[stage];
354
608
  if (!approval || approval?.generated !== true || approval?.agent_validated !== true || approval?.user_approved !== true) {
355
609
  errors.push(`spec.json.ready_for_implementation: requires generated and agent_validated and user_approved ${stage} evidence (v2)`);
@@ -486,39 +740,47 @@ function validateDependencyTopology(spec, taskFiles, registry, taskRecords, erro
486
740
  function validateSpec(specDir) {
487
741
  const errors = [];
488
742
  const warnings = [];
489
- const specJsonPath = path.join(specDir, 'spec.json');
490
-
491
- if (!fs.existsSync(specDir)) {
492
- errors.push(`${specDir}: spec directory does not exist`);
493
- return { errors, warnings };
494
- }
743
+ const canonicalSpecDir = validateSpecRoot(specDir, errors);
744
+ if (!canonicalSpecDir) return { errors, warnings };
495
745
 
496
746
  for (const forbidden of ['init.json', 'spec-state.json', 'hydration.md']) {
497
- if (fs.existsSync(path.join(specDir, forbidden))) {
747
+ const artifact = inspectSpecArtifact(specDir, canonicalSpecDir, forbidden, forbidden, errors);
748
+ if (artifact) {
498
749
  errors.push(`${forbidden}: forbidden generated artifact`);
499
750
  }
500
751
  }
501
752
 
502
- if (!fs.existsSync(specJsonPath)) {
503
- errors.push('spec.json: missing');
753
+ const specJsonArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, 'spec.json', 'spec.json', errors, { required: true });
754
+ if (!specJsonArtifact) return { errors, warnings };
755
+
756
+ const spec = readJson(specJsonArtifact.path, errors);
757
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
758
+ if (errors.length === 0) errors.push('spec.json: must contain a JSON object');
504
759
  return { errors, warnings };
505
760
  }
506
761
 
507
- const spec = readJson(specJsonPath, errors);
508
- if (!spec) return { errors, warnings };
762
+ const policyContext = workflowPolicyContext(spec, errors);
763
+ if (policyContext.explicit && Object.prototype.hasOwnProperty.call(spec, 'override_receipt')) {
764
+ errors.push('spec.json.override_receipt: duplicate legacy field; persist only workflow_policy.override_receipt');
765
+ }
509
766
 
510
767
  if (!spec.scope_lock || typeof spec.scope_lock !== 'object' || Array.isArray(spec.scope_lock)) {
511
768
  errors.push('spec.json.scope_lock: must be an object, not a boolean or array');
512
769
  }
513
770
 
514
771
  validateTimestamps(spec, errors);
515
- validateStateTransitions(spec, errors);
772
+ validateStateTransitions(spec, errors, policyContext);
516
773
 
517
- const taskFiles = listTaskFiles(specDir);
774
+ const taskFiles = listTaskFiles(specDir, canonicalSpecDir, errors);
518
775
  const taskFileSet = new Set(taskFiles);
776
+ const declaredTaskBundle = Array.isArray(spec.task_files) && spec.task_files.length > 0;
777
+ const requiresTaskBundle = taskFiles.length > 0 || declaredTaskBundle || policyContext.strict;
778
+ if (policyContext.strict && taskFiles.length === 0) {
779
+ errors.push('strict workflow policy requires at least one task file and task registry entry');
780
+ }
519
781
 
520
782
  if (!Array.isArray(spec.task_files)) {
521
- errors.push('spec.json.task_files: missing array');
783
+ if (requiresTaskBundle) errors.push('spec.json.task_files: missing array');
522
784
  if (Array.isArray(spec.tasks)) {
523
785
  errors.push('spec.json.tasks: legacy field detected; use task_files');
524
786
  }
@@ -531,7 +793,7 @@ function validateSpec(specDir) {
531
793
  }
532
794
 
533
795
  if (!spec.task_registry || typeof spec.task_registry !== 'object' || Array.isArray(spec.task_registry)) {
534
- errors.push('spec.json.task_registry: missing object keyed by task file path');
796
+ if (requiresTaskBundle) errors.push('spec.json.task_registry: missing object keyed by task file path');
535
797
  } else {
536
798
  const registryKeys = Object.keys(spec.task_registry).sort();
537
799
  if (JSON.stringify(registryKeys) !== JSON.stringify(taskFiles)) {
@@ -547,6 +809,7 @@ function validateSpec(specDir) {
547
809
  errors.push(`spec.json.task_registry.${registryPath}: missing ${key}`);
548
810
  }
549
811
  }
812
+ validateArtifactDeclaration(registryPath, entry, errors);
550
813
  if (entry && !Array.isArray(entry.dependencies)) {
551
814
  errors.push(`spec.json.task_registry.${registryPath}.dependencies: must be an array`);
552
815
  }
@@ -590,48 +853,62 @@ function validateSpec(specDir) {
590
853
  }
591
854
  }
592
855
 
593
- const requirementsPath = path.join(specDir, 'requirements.md');
594
- const designPath = path.join(specDir, 'design.md');
595
- const researchPath = path.join(specDir, 'research.md');
596
-
597
- if (!fs.existsSync(requirementsPath)) errors.push('requirements.md: missing');
598
- if (!fs.existsSync(designPath)) errors.push('design.md: missing');
599
-
600
- if (taskFiles.length > 0) {
601
- if (!fs.existsSync(researchPath)) {
602
- errors.push('research.md: missing Evidence Summary for non-trivial spec');
603
- } else {
604
- const research = fs.readFileSync(researchPath, 'utf8');
605
- if (!/^##\s+Evidence Summary\s*$/m.test(research)) {
606
- errors.push('research.md: missing ## Evidence Summary');
607
- }
608
- }
856
+ const requirementsArtifact = inspectSpecArtifact(
857
+ specDir,
858
+ canonicalSpecDir,
859
+ 'requirements.md',
860
+ 'requirements.md',
861
+ errors,
862
+ { required: true },
863
+ );
864
+ const designArtifact = inspectSpecArtifact(
865
+ specDir,
866
+ canonicalSpecDir,
867
+ 'design.md',
868
+ 'design.md',
869
+ errors,
870
+ { required: true },
871
+ );
872
+ const researchArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, 'research.md', 'research.md', errors);
873
+ const receiptArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, FEATURE_RECEIPT_FILE, FEATURE_RECEIPT_FILE, errors);
874
+ validateResearchPointer(specDir, canonicalSpecDir, spec, errors);
875
+
876
+ const requiresResearchArtifact = policyContext.explicit
877
+ && Boolean(policyContext.policy?.proof_obligations?.includes('needsResearchGrounding'));
878
+ if (requiresResearchArtifact) {
879
+ validateResearchArtifact(researchArtifact, errors, policyContext.explicit);
609
880
  }
610
881
 
882
+ validateFeatureReceipt(spec, policyContext, errors, receiptArtifact);
883
+
611
884
  let requirementIds = [];
612
885
  let subCriteriaIds = [];
613
- if (fs.existsSync(requirementsPath)) {
614
- const requirementsText = fs.readFileSync(requirementsPath, 'utf8');
886
+ let requirementsText = '';
887
+ let designText = '';
888
+ if (requirementsArtifact) {
889
+ requirementsText = fs.readFileSync(requirementsArtifact.path, 'utf8');
615
890
  requirementIds = extractRequirementIds(requirementsText);
616
891
  subCriteriaIds = extractSubCriteriaIds(requirementsText);
617
892
  }
893
+ if (designArtifact) designText = fs.readFileSync(designArtifact.path, 'utf8');
894
+ validateBoundedReadiness(spec, requirementsText, designText, errors);
618
895
 
619
896
  const coveredRequirementIds = new Set();
620
897
  const coveredSubCriteriaIds = new Set();
898
+ const mappedRequirementReferences = [];
621
899
  const taskRecords = new Map();
622
900
  // Cross-layer contract defs (opt-in): empty unless design.md uses
623
901
  // <!-- contract:NAME --> markers, so legacy specs are unaffected.
624
- const contractDefs = fs.existsSync(designPath)
625
- ? extractContractDefs(fs.readFileSync(designPath, 'utf8'))
626
- : new Map();
902
+ const contractDefs = designArtifact ? extractContractDefs(designText) : new Map();
627
903
  if (taskFiles.length >= 5 && contractDefs.size === 0) {
628
904
  errors.push(
629
905
  'design.md: 5+ task spec requires contract blocks for BE/FE shared shapes (<!-- contract:NAME -->) — fail closed, not warning',
630
906
  );
631
907
  }
632
908
  for (const taskFile of taskFiles) {
633
- const fullPath = path.join(specDir, taskFile);
634
- const content = fs.readFileSync(fullPath, 'utf8');
909
+ const taskArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, taskFile, taskFile, errors);
910
+ if (!taskArtifact) continue;
911
+ const content = fs.readFileSync(taskArtifact.path, 'utf8');
635
912
  validateTaskSections(taskFile, content, errors);
636
913
  validateTaskPlaceholders(taskFile, content, errors, warnings);
637
914
  validateRelatedFiles(taskFile, content, errors);
@@ -651,7 +928,15 @@ function validateSpec(specDir) {
651
928
  for (const token of match[1].split(',')) {
652
929
  const trimmed = token.trim();
653
930
  const major = trimmed.match(/^(\d+)(?:\.\d+)?$/);
654
- if (major) coveredRequirementIds.add(`R${major[1]}`);
931
+ if (major) {
932
+ const requirementId = `R${major[1]}`;
933
+ const sub = trimmed.match(/^(\d+\.\d+)$/);
934
+ const subId = sub ? `R${sub[1]}` : null;
935
+ coveredRequirementIds.add(requirementId);
936
+ if (policyContext.strict) mappedRequirementReferences.push({ taskFile, token: trimmed, requirementId, subId });
937
+ } else if (policyContext.strict && trimmed !== '') {
938
+ errors.push(`${taskFile}: requirement mapping "${trimmed}" must use numeric IDs such as 1 or 1.1`);
939
+ }
655
940
  // Record the full sub-criterion (e.g. 3.4 -> R3.4) for per-criterion coverage.
656
941
  const sub = trimmed.match(/^(\d+\.\d+)$/);
657
942
  if (sub) coveredSubCriteriaIds.add(`R${sub[1]}`);
@@ -698,15 +983,32 @@ function validateSpec(specDir) {
698
983
 
699
984
  validateDependencyTopology(spec, taskFiles, spec.task_registry, taskRecords, errors);
700
985
 
701
- for (const requirementId of requirementIds) {
702
- if (!coveredRequirementIds.has(requirementId)) {
703
- errors.push(`requirements.md:${requirementId}: not covered by any task`);
986
+ if (policyContext.strict) {
987
+ if (requirementIds.length === 0) {
988
+ errors.push('requirements.md: strict workflow requires numeric requirement IDs (for example Requirement 1 or R1)');
989
+ }
990
+ const knownRequirements = new Set(requirementIds);
991
+ const knownSubCriteria = new Set(subCriteriaIds);
992
+ for (const reference of mappedRequirementReferences) {
993
+ if (!knownRequirements.has(reference.requirementId)) {
994
+ errors.push(`${reference.taskFile}: requirement mapping "${reference.token}" references unknown requirement ${reference.requirementId}`);
995
+ } else if (reference.subId && !knownSubCriteria.has(reference.subId)) {
996
+ errors.push(`${reference.taskFile}: requirement mapping "${reference.token}" references unknown acceptance criterion ${reference.subId}`);
997
+ }
998
+ }
999
+ }
1000
+
1001
+ if (requiresTaskBundle) {
1002
+ for (const requirementId of requirementIds) {
1003
+ if (!coveredRequirementIds.has(requirementId)) {
1004
+ errors.push(`requirements.md:${requirementId}: not covered by any task`);
1005
+ }
704
1006
  }
705
1007
  }
706
1008
 
707
1009
  // Per-criterion coverage: every explicit R{N}.{M} literal in requirements.md
708
1010
  // must appear in a numeric `_Requirements: x.y_` task mapping.
709
- if (subCriteriaIds.length > 0) {
1011
+ if (requiresTaskBundle && subCriteriaIds.length > 0) {
710
1012
  for (const subId of subCriteriaIds) {
711
1013
  if (!coveredSubCriteriaIds.has(subId)) {
712
1014
  errors.push(`requirements.md:${subId}: acceptance criterion not covered by any task`);