@haposoft/cafekit 0.15.3 → 0.16.0-rc.2

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 (90) hide show
  1. package/README.md +4 -2
  2. package/bin/__tests__/codex-hooks.test.js +140 -0
  3. package/bin/__tests__/codex-native.test.js +2 -2
  4. package/bin/__tests__/develop-contract.test.js +1120 -0
  5. package/bin/__tests__/installer-safety.test.js +510 -2
  6. package/bin/__tests__/package-inventory.test.js +258 -0
  7. package/bin/__tests__/secret-scanner.test.js +157 -0
  8. package/bin/__tests__/validator-grounding.test.js +713 -0
  9. package/bin/install.js +5 -1
  10. package/bin/lib/codex-install.js +21 -6
  11. package/bin/lib/context.js +17 -6
  12. package/bin/lib/copy-utils.js +31 -0
  13. package/bin/lib/instruction-blocks.js +85 -0
  14. package/bin/lib/managed-writer.js +4 -1
  15. package/bin/lib/opencode-install.js +75 -30
  16. package/bin/phases/claude-runtime.js +103 -4
  17. package/bin/phases/codex-runtime.js +9 -5
  18. package/bin/phases/post-install.js +29 -22
  19. package/bin/phases/root-config.js +89 -10
  20. package/package.json +14 -4
  21. package/src/claude/CLAUDE.md +6 -122
  22. package/src/claude/agents/code-auditor.md +28 -22
  23. package/src/claude/agents/debugger.md +3 -3
  24. package/src/claude/agents/deployer.md +1 -1
  25. package/src/claude/agents/{god-developer.md → implementer.md} +2 -2
  26. package/src/claude/agents/project-manager.md +1 -1
  27. package/src/claude/agents/spec-maker.md +2 -2
  28. package/src/claude/hooks/__tests__/spec-gate.test.js +356 -5
  29. package/src/claude/hooks/agent.cjs +7 -14
  30. package/src/claude/hooks/rules.cjs +33 -53
  31. package/src/claude/hooks/spec-gate.cjs +66 -21
  32. package/src/claude/hooks/spec-state.cjs +5 -0
  33. package/src/claude/migration-manifest.json +9 -2
  34. package/src/claude/runtime.json +0 -3
  35. package/src/claude/scripts/generate-skill-catalog.cjs +1 -1
  36. package/src/claude/scripts/scan-staged-secrets.cjs +170 -0
  37. package/src/claude/scripts/spec-ground.cjs +189 -83
  38. package/src/claude/scripts/validate-spec-output.cjs +294 -30
  39. package/src/claude/scripts/workflow-policy.cjs +609 -0
  40. package/src/claude/skills/ai-multimodal/SKILL.md +2 -2
  41. package/src/claude/skills/ai-multimodal/references/audio-processing.md +6 -6
  42. package/src/claude/skills/ai-multimodal/references/video-analysis.md +1 -1
  43. package/src/claude/skills/backend-development/SKILL.md +4 -4
  44. package/src/claude/skills/backend-development/references/backend-architecture.md +1 -1
  45. package/src/claude/skills/backend-development/references/backend-devops.md +2 -2
  46. package/src/claude/skills/backend-development/references/backend-mindset.md +3 -3
  47. package/src/claude/skills/backend-development/references/backend-performance.md +5 -5
  48. package/src/claude/skills/backend-development/references/backend-security.md +55 -3
  49. package/src/claude/skills/backend-development/references/backend-technologies.md +5 -5
  50. package/src/claude/skills/backend-development/references/backend-testing.md +2 -2
  51. package/src/claude/skills/brainstorm/SKILL.md +2 -3
  52. package/src/claude/skills/code-review/SKILL.md +12 -8
  53. package/src/claude/skills/code-review/references/verification-gate.md +4 -4
  54. package/src/claude/skills/debug/SKILL.md +2 -0
  55. package/src/claude/skills/develop/SKILL.md +100 -52
  56. package/src/claude/skills/develop/references/parallel-waves.md +105 -26
  57. package/src/claude/skills/develop/references/quality-gate.md +78 -38
  58. package/src/claude/skills/develop/references/subagent-patterns.md +2 -2
  59. package/src/claude/skills/git/SKILL.md +44 -50
  60. package/src/claude/skills/hotfix/SKILL.md +10 -8
  61. package/src/claude/skills/hotfix/references/parallel-patterns.md +2 -2
  62. package/src/claude/skills/hotfix/references/review-cycle.md +26 -21
  63. package/src/claude/skills/inspect/SKILL.md +12 -30
  64. package/src/claude/skills/mobile-development/SKILL.md +12 -12
  65. package/src/claude/skills/mobile-development/references/mobile-android.md +7 -7
  66. package/src/claude/skills/mobile-development/references/mobile-best-practices.md +22 -23
  67. package/src/claude/skills/mobile-development/references/mobile-frameworks.md +13 -13
  68. package/src/claude/skills/mobile-development/references/mobile-ios.md +1 -1
  69. package/src/claude/skills/mobile-development/references/mobile-mindset.md +17 -19
  70. package/src/claude/skills/question/SKILL.md +13 -13
  71. package/src/claude/skills/research/SKILL.md +2 -2
  72. package/src/claude/skills/specs/SKILL.md +32 -15
  73. package/src/claude/skills/specs/references/review.md +3 -3
  74. package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
  75. package/src/claude/skills/specs/templates/spec-state.json +7 -3
  76. package/src/claude/skills/sync/SKILL.md +26 -1
  77. package/src/claude/skills/sync/references/sync-protocols.md +3 -3
  78. package/src/claude/skills/test/SKILL.md +12 -4
  79. package/src/claude/skills/test/references/failure-triage.md +17 -17
  80. package/src/claude/skills/test/references/test-memory.md +2 -2
  81. package/src/codex/AGENTS.md +16 -24
  82. package/src/codex/hooks/lib/spec-receipt.cjs +57 -4
  83. package/src/codex/hooks/rules.cjs +27 -26
  84. package/src/codex/hooks/spec-gate.cjs +19 -8
  85. package/src/codex/hooks/spec-state.cjs +10 -1
  86. package/src/common/AGENTS.md +43 -0
  87. package/src/opencode/AGENTS.md +21 -121
  88. package/src/opencode/plugins/rules.ts +26 -34
  89. package/src/opencode/plugins/spec-gate.ts +283 -0
  90. package/src/claude/skills/inspect/references/external-gemini-inspection.md +0 -169
@@ -20,6 +20,9 @@ const REQUIRED_REGISTRY_KEYS = [
20
20
  'completed_at',
21
21
  'last_updated_at',
22
22
  ];
23
+ const APPROVAL_SCHEMA_VERSION = '2.0';
24
+ const LEGACY_APPROVAL_ERROR =
25
+ `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.`;
23
26
 
24
27
  function usage() {
25
28
  console.error('Usage: node .claude/scripts/validate-spec-output.cjs specs/<feature>');
@@ -63,6 +66,17 @@ function hasHeading(content, heading) {
63
66
  return new RegExp(`^##\\s+${escaped}\\s*$`, 'm').test(content);
64
67
  }
65
68
 
69
+ function extractSection(content, heading) {
70
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
71
+ const re = new RegExp(`^##\\s+${escaped}\\s*$`, 'im');
72
+ const match = content.match(re);
73
+ if (!match) return null;
74
+ const start = match.index + match[0].length;
75
+ const after = content.slice(start);
76
+ const next = after.match(/^##\s+/m);
77
+ return next ? after.slice(0, next.index) : after;
78
+ }
79
+
66
80
  function extractRequirementIds(requirementsText) {
67
81
  const ids = new Set();
68
82
  const headingRe = /^#{2,4}\s+(?:(?:Requirement)\s+)?((?:REQ-\d+)|(?:R\d+))\b/gim;
@@ -88,10 +102,8 @@ function extractRequirementIds(requirementsText) {
88
102
  /**
89
103
  * Extract sub-criteria IDs (e.g. R3.4) ONLY when requirements.md declares them
90
104
  * as explicit literals — bold `**R3.4**` or a line-leading `R3.4`. Specs that
91
- * write acceptance criteria as a plain numbered list under a heading declare no
92
- * such literals, so this returns empty and per-criterion coverage is skipped
93
- * for them (no behaviour change). This is a progressive check: it rewards an
94
- * unambiguous format, it never penalises the legacy one.
105
+ * write acceptance criteria as a plain numbered list declare no such literals.
106
+ * Every declared literal must have an explicit numeric task mapping.
95
107
  */
96
108
  function extractSubCriteriaIds(requirementsText) {
97
109
  const ids = new Set();
@@ -125,11 +137,39 @@ function validateTaskSections(taskPath, content, errors) {
125
137
  if (!hasSteps) errors.push(`${taskPath}: missing Steps/Implementation Steps`);
126
138
  if (!hasRequirements) errors.push(`${taskPath}: missing Requirements mapping`);
127
139
  if (!hasRelatedFiles) errors.push(`${taskPath}: missing Related Files`);
140
+ else if (relatedFilesSection(content).rows.length === 0) errors.push(`${taskPath}: Related Files section must not be empty`);
128
141
  if (!hasCompletionCriteria) errors.push(`${taskPath}: missing Completion Criteria`);
129
142
  if (!hasEvidence) errors.push(`${taskPath}: missing Evidence or task test plan`);
130
143
  if (!hasRiskAssessment) errors.push(`${taskPath}: missing Risk Assessment`);
131
- if (hasEvidence && !/Runtime reachability verification/i.test(content)) {
132
- errors.push(`${taskPath}: missing Runtime reachability verification`);
144
+ if (hasEvidence) {
145
+ const evidenceSection = extractSection(content, 'Evidence')
146
+ || extractSection(content, 'Task Test Plan & Verification Evidence')
147
+ || extractSection(content, 'Verification & Evidence')
148
+ || '';
149
+ if (!/Runtime reachability verification/i.test(evidenceSection)) {
150
+ errors.push(`${taskPath}: missing Runtime reachability verification`);
151
+ } else {
152
+ // Reachability must prove a concrete path/anchor, not just the phrase.
153
+ // Require at least one of: backtick file path, Entrypoint/caller line, or anchor.
154
+ const hasConcreteAnchor =
155
+ /`[^`]*\.[a-z]{1,4}`/i.test(evidenceSection) ||
156
+ /Entrypoint\/caller\s*:/i.test(evidenceSection) ||
157
+ /Route is registered|import.*from|anchor:\s*`/i.test(evidenceSection);
158
+ if (!hasConcreteAnchor) {
159
+ errors.push(`${taskPath}: Runtime reachability verification must reference a concrete file path or anchor (e.g. \`src/...\` or Entrypoint/caller)`);
160
+ } else {
161
+ // Cross-check against declared Related Files paths
162
+ const relatedPaths = new Set(relatedFilesSection(content).rows.map((r) => r.path));
163
+ const referencedPaths = [...evidenceSection.matchAll(/`([^`]*\.[a-z0-9]{1,4})`/gi)].map((m) => m[1].replace(/^\.\//, ''));
164
+ // At least one referenced path should correspond to a declared Related Files entry (or be clearly external)
165
+ if (referencedPaths.length > 0 && relatedPaths.size > 0) {
166
+ const overlaps = referencedPaths.some((p) => [...relatedPaths].some((rp) => p === rp || p.endsWith(rp) || rp.endsWith(p) || p.includes(rp.split('/').pop())));
167
+ if (!overlaps && !/Entrypoint\/caller/i.test(evidenceSection)) {
168
+ errors.push(`${taskPath}: Runtime reachability verification references no Related Files path — must anchor to a declared file`);
169
+ }
170
+ }
171
+ }
172
+ }
133
173
  }
134
174
  }
135
175
 
@@ -227,6 +267,222 @@ function extractTaskContracts(taskText) {
227
267
  return { names, blocks, firstBlock };
228
268
  }
229
269
 
270
+ const RELATED_FILE_ACTIONS = new Set(['create', 'modify', 'delete', 'read']);
271
+
272
+ function relatedFilesSection(content) {
273
+ const lines = content.split('\n');
274
+ const headingIndex = lines.findIndex((line) => /^##+\s+Related Files\s*$/i.test(line));
275
+ if (headingIndex < 0) return { present: false, rows: [] };
276
+ const rows = [];
277
+ for (let index = headingIndex + 1; index < lines.length; index += 1) {
278
+ const line = lines[index];
279
+ if (/^##+\s+/.test(line)) break;
280
+ if (!line.trim().startsWith('|')) continue;
281
+ const cells = line.split('|').map((cell) => cell.trim());
282
+ const filePath = (cells[1] || '').replace(/`/g, '').trim();
283
+ const action = (cells[2] || '').replace(/`/g, '').trim().toLowerCase();
284
+ if (!filePath || filePath.toLowerCase() === 'path' || /^-+$/.test(filePath)) continue;
285
+ rows.push({ path: filePath, action });
286
+ }
287
+ return { present: true, rows };
288
+ }
289
+
290
+ function unsafeRelatedPath(filePath) {
291
+ return path.isAbsolute(filePath)
292
+ || /^[A-Za-z]:[\\/]/.test(filePath)
293
+ || filePath.split(/[\\/]+/).includes('..');
294
+ }
295
+
296
+ function validateRelatedFiles(taskPath, content, errors) {
297
+ const section = relatedFilesSection(content);
298
+ if (!section.present || section.rows.length === 0) return;
299
+ for (const row of section.rows) {
300
+ if (!RELATED_FILE_ACTIONS.has(row.action)) {
301
+ errors.push(`${taskPath}: unsupported Related Files action "${row.action}" for ${row.path}`);
302
+ }
303
+ if (unsafeRelatedPath(row.path)) {
304
+ errors.push(`${taskPath}: Related Files path must be relative and stay within work root: ${row.path}`);
305
+ }
306
+ }
307
+ }
308
+
309
+ function validateStateTransitions(spec, errors) {
310
+ // Approval schema v2: generated, agent_validated, user_approved independent.
311
+ // Legacy "approved" field is rejected fail-closed with migration guidance.
312
+ const approvals = spec.approvals || {};
313
+ let hasLegacy = false;
314
+ for (const [stage, approval] of Object.entries(approvals)) {
315
+ if (!approval || typeof approval !== 'object') {
316
+ errors.push(`spec.json.approvals.${stage}: must be an object`);
317
+ continue;
318
+ }
319
+ if ('approved' in approval) {
320
+ errors.push(`spec.json.approvals.${stage}: ${LEGACY_APPROVAL_ERROR}`);
321
+ hasLegacy = true;
322
+ }
323
+ // Unsupported/ambiguous: if schema_version missing when approvals use v2 shape, warn but not fail? B2 says fail closed.
324
+ // We fail closed when version is unsupported or ambiguous and ready_for_implementation is true.
325
+ }
326
+
327
+ // Schema version validation
328
+ if (spec.schema_version !== undefined && spec.schema_version !== APPROVAL_SCHEMA_VERSION) {
329
+ errors.push(`spec.json.schema_version: unsupported "${spec.schema_version}", expected "${APPROVAL_SCHEMA_VERSION}" — migration required`);
330
+ }
331
+ if (spec.approval_schema_version !== undefined && spec.approval_schema_version !== APPROVAL_SCHEMA_VERSION) {
332
+ errors.push(`spec.json.approval_schema_version: unsupported "${spec.approval_schema_version}", expected "${APPROVAL_SCHEMA_VERSION}"`);
333
+ }
334
+ // If approvals use v2 fields but no schema_version, treat as ambiguous — fail closed when ready flag is set
335
+ const usesV2 = Object.values(approvals).some((a) => a && typeof a === 'object' && ('agent_validated' in a || 'user_approved' in a));
336
+ if (usesV2 && spec.schema_version === undefined && spec.approval_schema_version === undefined) {
337
+ // Only warn now; will be error if ready_for_implementation is true (checked below)
338
+ // Provide guidance
339
+ if (spec.ready_for_implementation === true) {
340
+ errors.push(`spec.json.schema_version: missing — spec uses v2 approvals (agent_validated/user_approved) but has no schema_version "${APPROVAL_SCHEMA_VERSION}"`);
341
+ }
342
+ }
343
+
344
+ if (spec.validation?.status === 'completed' && !spec.timestamps?.validation_done) {
345
+ errors.push('spec.json.validation: completed transition requires timestamps.validation_done');
346
+ }
347
+
348
+ if (spec.ready_for_implementation === true) {
349
+ if (hasLegacy) {
350
+ errors.push(`spec.json.ready_for_implementation: cannot be true with legacy approval fields — migrate to agent_validated/user_approved`);
351
+ }
352
+ for (const stage of ['requirements', 'design', 'tasks']) {
353
+ const approval = spec.approvals?.[stage];
354
+ if (!approval || approval?.generated !== true || approval?.agent_validated !== true || approval?.user_approved !== true) {
355
+ errors.push(`spec.json.ready_for_implementation: requires generated and agent_validated and user_approved ${stage} evidence (v2)`);
356
+ }
357
+ }
358
+ if (spec.validation?.status === 'in_progress' || spec.validation?.status === 'not-run') {
359
+ errors.push('spec.json.ready_for_implementation: cannot be true while validation evidence is incomplete');
360
+ }
361
+ }
362
+ }
363
+
364
+ function validateDependencyTopology(spec, taskFiles, registry, taskRecords, errors) {
365
+ const dependencies = new Map(taskFiles.map((taskFile) => [taskFile, new Set(registry?.[taskFile]?.dependencies || [])]));
366
+ if (dependencies.size === 0) return;
367
+
368
+ const visiting = new Set();
369
+ const visited = new Set();
370
+ function visit(taskFile, stack = []) {
371
+ if (visiting.has(taskFile)) {
372
+ const cycleStart = stack.indexOf(taskFile);
373
+ errors.push(`spec.json.task_registry.dependencies: dependency cycle detected (${stack.slice(cycleStart).concat(taskFile).join(' -> ')})`);
374
+ return;
375
+ }
376
+ if (visited.has(taskFile)) return;
377
+ visiting.add(taskFile);
378
+ for (const dependency of dependencies.get(taskFile) || []) visit(dependency, [...stack, taskFile]);
379
+ visiting.delete(taskFile);
380
+ visited.add(taskFile);
381
+ }
382
+ for (const taskFile of taskFiles) visit(taskFile);
383
+
384
+ const roots = taskFiles.filter((taskFile) => (dependencies.get(taskFile) || new Set()).size === 0);
385
+ const reachable = new Set();
386
+ const dependents = new Map(taskFiles.map((taskFile) => [taskFile, []]));
387
+ for (const [taskFile, deps] of dependencies) {
388
+ for (const dependency of deps) dependents.get(dependency)?.push(taskFile);
389
+ }
390
+ const queue = [...roots];
391
+ while (queue.length > 0) {
392
+ const taskFile = queue.shift();
393
+ if (reachable.has(taskFile)) continue;
394
+ reachable.add(taskFile);
395
+ queue.push(...(dependents.get(taskFile) || []));
396
+ }
397
+ for (const taskFile of taskFiles) {
398
+ if (!reachable.has(taskFile)) errors.push(`spec.json.task_registry.${taskFile}: orphan or unreachable task in dependency graph`);
399
+ }
400
+
401
+ function dependsOn(taskFile, ancestor, seen = new Set()) {
402
+ if (taskFile === ancestor) return true;
403
+ if (seen.has(taskFile)) return false;
404
+ seen.add(taskFile);
405
+ return [...(dependencies.get(taskFile) || [])].some((dependency) => dependsOn(dependency, ancestor, seen));
406
+ }
407
+
408
+ const producers = new Map();
409
+ for (const [taskFile, record] of taskRecords) {
410
+ for (const [index, row] of record.rows.entries()) {
411
+ if (row.action !== 'create') continue;
412
+ const prior = producers.get(row.path);
413
+ if (prior && prior.taskFile !== taskFile) {
414
+ errors.push(`${taskFile}: Related Files path ${row.path} is created by multiple tasks (${prior.taskFile}, ${taskFile})`);
415
+ } else {
416
+ producers.set(row.path, { taskFile, index });
417
+ }
418
+ }
419
+ }
420
+ // Lifecycle ordering: Create must precede Modify/Delete/Read within task for same path
421
+ for (const [taskFile, record] of taskRecords) {
422
+ const createIndexByPath = new Map();
423
+ for (const [index, row] of record.rows.entries()) {
424
+ if (row.action === 'create' && !createIndexByPath.has(row.path)) {
425
+ createIndexByPath.set(row.path, index);
426
+ }
427
+ }
428
+ for (const [index, row] of record.rows.entries()) {
429
+ if (!['modify', 'delete', 'read'].includes(row.action)) continue;
430
+ if (!createIndexByPath.has(row.path)) continue;
431
+ const createIdx = createIndexByPath.get(row.path);
432
+ if (createIdx > index) {
433
+ errors.push(`${taskFile}: Create must precede ${row.action} for ${row.path} within Related Files order`);
434
+ }
435
+ }
436
+ }
437
+ // Cross-task lifecycle: Modify/Delete/Read of a path created by another task must depend on creator
438
+ for (const [taskFile, record] of taskRecords) {
439
+ for (const [index, row] of record.rows.entries()) {
440
+ if (!['modify', 'delete', 'read'].includes(row.action)) continue;
441
+ const producer = producers.get(row.path);
442
+ if (!producer) continue;
443
+ if (producer.taskFile === taskFile) continue;
444
+ if (!dependsOn(taskFile, producer.taskFile)) {
445
+ errors.push(`${taskFile}: ${row.action} of ${row.path} must depend on creator ${producer.taskFile}`);
446
+ }
447
+ }
448
+ }
449
+
450
+ // Existing implementation: Modify/Delete on non-produced paths must have a Read before them.
451
+ // This ensures lifecycle Read is not skipped for existing files.
452
+ for (const [taskFile, record] of taskRecords) {
453
+ for (const [index, row] of record.rows.entries()) {
454
+ if (!['modify', 'delete'].includes(row.action)) continue;
455
+ if (producers.has(row.path)) continue; // produced by spec, not existing
456
+ // Check if there's a Read for same path preceding this Modify/Delete
457
+ let hasPrecedingRead = false;
458
+ // Same task earlier row
459
+ for (let i = 0; i < index; i += 1) {
460
+ const r = record.rows[i];
461
+ if (r.action === 'read' && r.path === row.path) { hasPrecedingRead = true; break; }
462
+ }
463
+ if (hasPrecedingRead) continue;
464
+ // In any dependency ancestor
465
+ const deps = dependencies.get(taskFile) || new Set();
466
+ // Walk transitive deps for Read
467
+ const stack = [...deps];
468
+ const seen = new Set();
469
+ while (stack.length > 0) {
470
+ const dep = stack.pop();
471
+ if (seen.has(dep)) continue;
472
+ seen.add(dep);
473
+ const depRecord = taskRecords.get(dep);
474
+ if (depRecord && depRecord.rows.some((r) => r.action === 'read' && r.path === row.path)) {
475
+ hasPrecedingRead = true; break;
476
+ }
477
+ for (const d of dependencies.get(dep) || []) stack.push(d);
478
+ }
479
+ if (!hasPrecedingRead) {
480
+ errors.push(`${taskFile}: ${row.action} of ${row.path} targets existing implementation but has no preceding Read — add a Read for ${row.path} before modifying it (lifecycle ordering)`);
481
+ }
482
+ }
483
+ }
484
+ }
485
+
230
486
  function validateSpec(specDir) {
231
487
  const errors = [];
232
488
  const warnings = [];
@@ -256,6 +512,7 @@ function validateSpec(specDir) {
256
512
  }
257
513
 
258
514
  validateTimestamps(spec, errors);
515
+ validateStateTransitions(spec, errors);
259
516
 
260
517
  const taskFiles = listTaskFiles(specDir);
261
518
  const taskFileSet = new Set(taskFiles);
@@ -361,14 +618,15 @@ function validateSpec(specDir) {
361
618
 
362
619
  const coveredRequirementIds = new Set();
363
620
  const coveredSubCriteriaIds = new Set();
621
+ const taskRecords = new Map();
364
622
  // Cross-layer contract defs (opt-in): empty unless design.md uses
365
623
  // <!-- contract:NAME --> markers, so legacy specs are unaffected.
366
624
  const contractDefs = fs.existsSync(designPath)
367
625
  ? extractContractDefs(fs.readFileSync(designPath, 'utf8'))
368
626
  : new Map();
369
627
  if (taskFiles.length >= 5 && contractDefs.size === 0) {
370
- warnings.push(
371
- 'design.md declares no contract blocks; specs spanning BE/FE must declare shared shapes (<!-- contract:NAME -->)',
628
+ errors.push(
629
+ 'design.md: 5+ task spec requires contract blocks for BE/FE shared shapes (<!-- contract:NAME -->) — fail closed, not warning',
372
630
  );
373
631
  }
374
632
  for (const taskFile of taskFiles) {
@@ -376,16 +634,20 @@ function validateSpec(specDir) {
376
634
  const content = fs.readFileSync(fullPath, 'utf8');
377
635
  validateTaskSections(taskFile, content, errors);
378
636
  validateTaskPlaceholders(taskFile, content, errors, warnings);
379
-
380
- const idRe = /\b((?:REQ-\d+)|(?:R\d+))\b/gi;
381
- let match;
382
- while ((match = idRe.exec(content)) !== null) {
383
- const id = match[1].toUpperCase();
384
- if (id !== 'R0') coveredRequirementIds.add(id);
385
- }
386
-
637
+ validateRelatedFiles(taskFile, content, errors);
638
+ taskRecords.set(taskFile, { rows: relatedFilesSection(content).rows });
639
+
640
+ // Requirement traceability: only structured _Requirements: ..._ mappings inside Steps/Requirements sections count.
641
+ // Incidental mentions elsewhere (prose Context, Constraints) must not be counted.
642
+ const stepsSection = extractSection(content, 'Steps') || extractSection(content, 'Implementation Steps') || '';
643
+ const reqSectionInTask = extractSection(content, 'Requirements') || '';
644
+ const mappingSource = `${stepsSection}\n${reqSectionInTask}`;
645
+ // Also consider inline _Requirements: inside Steps bullet lines that may be outside section extraction edge cases,
646
+ // so fallback to whole content scanning only if sections are null (e.g., malformed heading). But prefer scoped.
647
+ const effectiveMappingSource = mappingSource.trim() ? mappingSource : content;
387
648
  const numericMappingRe = /_Requirements:\s*([^_\n]+)_/gi;
388
- while ((match = numericMappingRe.exec(content)) !== null) {
649
+ let match;
650
+ while ((match = numericMappingRe.exec(effectiveMappingSource)) !== null) {
389
651
  for (const token of match[1].split(',')) {
390
652
  const trimmed = token.trim();
391
653
  const major = trimmed.match(/^(\d+)(?:\.\d+)?$/);
@@ -397,12 +659,16 @@ function validateSpec(specDir) {
397
659
  }
398
660
 
399
661
  // Cross-layer contract check (opt-in via design.md markers). When a task
400
- // claims `Contracts: NAME`, that name must exist in design.md and the task's
401
- // local copy of the block must match the canonical definition byte-for-byte
402
- // (after whitespace normalization). This catches BE/FE drift like
403
- // user_name vs userName before integration.
662
+ // claims `Contracts: NAME`, design.md must define the name and the task's
663
+ // local copy must match the canonical definition byte-for-byte (after
664
+ // whitespace normalization). This catches BE/FE drift like user_name vs
665
+ // userName before integration, and rejects declarations with no canonical
666
+ // source instead of silently accepting an orphan contract.
667
+ const { names, blocks, firstBlock } = extractTaskContracts(content);
668
+ if (names.length > 0 && contractDefs.size === 0) {
669
+ errors.push(`${taskFile}: declares contract(s) but design.md defines no canonical contract blocks`);
670
+ }
404
671
  if (contractDefs.size > 0) {
405
- const { names, blocks, firstBlock } = extractTaskContracts(content);
406
672
  for (const name of names) {
407
673
  if (!contractDefs.has(name)) {
408
674
  errors.push(`${taskFile}: declares unknown contract "${name}" (not defined in design.md)`);
@@ -414,9 +680,7 @@ function validateSpec(specDir) {
414
680
  ? blocks.get(name)
415
681
  : (names.length === 1 ? firstBlock : null);
416
682
  if (localCopy === null) {
417
- if (names.length > 1) {
418
- errors.push(`${taskFile}: contract "${name}" has no <!-- contract:${name} --> tagged block (multi-contract tasks must tag each copy)`);
419
- }
683
+ errors.push(`${taskFile}: contract "${name}" is missing a copied canonical block`);
420
684
  continue;
421
685
  }
422
686
  if (localCopy !== contractDefs.get(name)) {
@@ -432,17 +696,17 @@ function validateSpec(specDir) {
432
696
  }
433
697
  }
434
698
 
699
+ validateDependencyTopology(spec, taskFiles, spec.task_registry, taskRecords, errors);
700
+
435
701
  for (const requirementId of requirementIds) {
436
702
  if (!coveredRequirementIds.has(requirementId)) {
437
703
  errors.push(`requirements.md:${requirementId}: not covered by any task`);
438
704
  }
439
705
  }
440
706
 
441
- // Per-criterion coverage: only enforced when requirements.md declares explicit
442
- // R{N}.{M} literals AND tasks use the numeric `_Requirements: x.y_` mapping.
443
- // If a spec declares sub-criteria but no task maps any at sub-level, that is the
444
- // legacy coarse format (major-only) — skip silently to avoid false failures.
445
- if (subCriteriaIds.length > 0 && coveredSubCriteriaIds.size > 0) {
707
+ // Per-criterion coverage: every explicit R{N}.{M} literal in requirements.md
708
+ // must appear in a numeric `_Requirements: x.y_` task mapping.
709
+ if (subCriteriaIds.length > 0) {
446
710
  for (const subId of subCriteriaIds) {
447
711
  if (!coveredSubCriteriaIds.has(subId)) {
448
712
  errors.push(`requirements.md:${subId}: acceptance criterion not covered by any task`);