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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
  2. package/bin/__tests__/codex-hooks.test.js +531 -29
  3. package/bin/__tests__/codex-native.test.js +228 -7
  4. package/bin/__tests__/develop-contract.test.js +653 -185
  5. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
  6. package/bin/__tests__/installer-safety.test.js +39 -0
  7. package/bin/__tests__/opencode-spec-gate.test.js +24 -16
  8. package/bin/__tests__/package-inventory.test.js +705 -13
  9. package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
  10. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
  11. package/bin/__tests__/specs-v2-execution-closeout.test.js +468 -0
  12. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +665 -0
  13. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +1132 -0
  14. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  15. package/bin/__tests__/validator-grounding.test.js +2013 -221
  16. package/bin/lib/managed-writer.js +12 -3
  17. package/bin/phases/codex-runtime.js +17 -1
  18. package/package.json +5 -2
  19. package/src/claude/CLAUDE.md +6 -0
  20. package/src/claude/agents/code-auditor.md +20 -0
  21. package/src/claude/agents/spec-maker.md +143 -99
  22. package/src/claude/hooks/__tests__/completion-authority.test.js +321 -58
  23. package/src/claude/hooks/__tests__/privacy-block.test.js +254 -0
  24. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  25. package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
  26. package/src/claude/hooks/completion-authority-check.cjs +37 -288
  27. package/src/claude/hooks/completion-authority-state.cjs +10 -8
  28. package/src/claude/hooks/completion-authority.cjs +13 -7
  29. package/src/claude/hooks/docs-sync.cjs +5 -2
  30. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  31. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  32. package/src/claude/hooks/privacy-block.cjs +99 -28
  33. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  34. package/src/claude/hooks/spec-gate.cjs +47 -117
  35. package/src/claude/hooks/spec-state.cjs +8 -2
  36. package/src/claude/migration-manifest.json +9 -1
  37. package/src/claude/rules/state-sync.md +52 -21
  38. package/src/claude/rules/workflow.md +17 -9
  39. package/src/claude/scripts/change-firewall.cjs +1748 -0
  40. package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
  41. package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
  42. package/src/claude/scripts/spec-final-state.cjs +290 -0
  43. package/src/claude/scripts/spec-ground.cjs +571 -173
  44. package/src/claude/scripts/spec-readiness.cjs +425 -0
  45. package/src/claude/scripts/spec-receipt.cjs +162 -0
  46. package/src/claude/scripts/spec-resolver.cjs +67 -16
  47. package/src/claude/scripts/spec-scaffold.cjs +1594 -139
  48. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  49. package/src/claude/scripts/validate-spec-output.cjs +3110 -218
  50. package/src/claude/scripts/workflow-policy.cjs +501 -183
  51. package/src/claude/settings/settings.json +4 -0
  52. package/src/claude/skills/code-review/SKILL.md +12 -3
  53. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  54. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  55. package/src/claude/skills/develop/SKILL.md +47 -60
  56. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  57. package/src/claude/skills/develop/references/quality-gate.md +19 -12
  58. package/src/claude/skills/specs/SKILL.md +183 -177
  59. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  60. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  61. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  62. package/src/claude/skills/specs/references/review.md +214 -262
  63. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  64. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  65. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  66. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  67. package/src/claude/skills/specs/rules/design-review.md +42 -97
  68. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  69. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  70. package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
  71. package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
  72. package/src/claude/skills/specs/templates/design.md +49 -263
  73. package/src/claude/skills/specs/templates/requirements.md +26 -36
  74. package/src/claude/skills/specs/templates/research.md +9 -98
  75. package/src/claude/skills/specs/templates/spec-state.json +35 -85
  76. package/src/claude/skills/specs/templates/task.md +30 -102
  77. package/src/claude/skills/sync/SKILL.md +33 -10
  78. package/src/claude/skills/sync/references/sync-protocols.md +31 -13
  79. package/src/claude/skills/test/SKILL.md +17 -7
  80. package/src/codex/AGENTS.md +6 -0
  81. package/src/codex/hooks/completion-authority-check.cjs +43 -240
  82. package/src/codex/hooks/completion-authority-state.cjs +10 -8
  83. package/src/codex/hooks/completion-authority.cjs +13 -7
  84. package/src/codex/hooks/lib/hook-context.cjs +16 -2
  85. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  86. package/src/codex/hooks/lib/privacy-state.cjs +61 -38
  87. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  88. package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
  89. package/src/codex/hooks/lib/spec-utils.cjs +56 -5
  90. package/src/codex/hooks/privacy-approval.cjs +6 -2
  91. package/src/codex/hooks/privacy-block.cjs +94 -15
  92. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  93. package/src/codex/hooks/spec-gate.cjs +49 -34
  94. package/src/codex/hooks/spec-state.cjs +10 -3
  95. package/src/codex/hooks/state.cjs +2 -0
  96. package/src/codex/hooks.json +6 -1
  97. package/src/codex/rules/state-sync.md +47 -27
  98. package/src/common/AGENTS.md +1 -1
@@ -1,19 +1,17 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * CafeKit spec GROUNDING checker (Specs v2, Layer 2).
4
- *
5
- * Verifies every task Related Files declaration against the real work tree.
6
- * Paths are relative, actions are explicit, and globs must match at least one
7
- * filesystem entry. Create on existing paths is an error. Lifecycle ordering
8
- * (Create before Modify/Delete/Read) is enforced within task and via
9
- * dependency DAG for cross-task references; cross-task producer/consumer
10
- * requires a valid spec.json task_registry (fail-closed).
11
- */
2
+ 'use strict';
3
+
4
+ /** CafeKit Specs v2 grounding checker (Layer 2, no command execution). */
12
5
 
13
6
  const fs = require('fs');
14
7
  const path = require('path');
8
+ const POLICY = require('./workflow-policy.cjs');
9
+ const SEMANTIC = require('./spec-semantic-model.cjs');
15
10
 
16
- const ACTIONS = new Set(['create', 'modify', 'delete', 'read']);
11
+ const ANCHOR_TYPES = new Set([
12
+ 'file', 'symbol', 'command', 'route', 'schema', 'contract', 'artifact', 'external',
13
+ ]);
14
+ const TASK_PATH_RE = /^task-(R\d+-\d{2})-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
17
15
 
18
16
  function usage() {
19
17
  console.error('Usage: node spec-ground.cjs <specDir> [--root <work-context>]');
@@ -21,49 +19,165 @@ function usage() {
21
19
 
22
20
  function parseArgs(argv) {
23
21
  const args = { specDir: null, root: null };
24
- for (let i = 2; i < argv.length; i += 1) {
25
- if (argv[i] === '--root') {
26
- args.root = argv[++i];
22
+ for (let index = 2; index < argv.length; index += 1) {
23
+ if (argv[index] === '--root') {
24
+ args.root = argv[++index];
27
25
  if (!args.root) throw new Error('--root requires a value');
28
- } else if (!args.specDir) args.specDir = argv[i];
29
- else throw new Error(`Unknown argument: ${argv[i]}`);
26
+ } else if (!args.specDir) args.specDir = argv[index];
27
+ else throw new Error(`unknown argument: ${argv[index]}`);
30
28
  }
31
29
  return args;
32
30
  }
33
31
 
34
- function listTaskFiles(specDir) {
35
- const tasksDir = path.join(specDir, 'tasks');
36
- if (!fs.existsSync(tasksDir)) return [];
37
- return fs
38
- .readdirSync(tasksDir, { withFileTypes: true })
39
- .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
40
- .map((entry) => path.join(tasksDir, entry.name))
41
- .sort();
32
+ function isPlainObject(value) {
33
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
34
+ }
35
+
36
+ function inside(root, target) {
37
+ const relative = path.relative(path.resolve(root), path.resolve(target));
38
+ return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
39
+ }
40
+
41
+ function unsafeRelative(value) {
42
+ return typeof value !== 'string' || value.trim() === '' || value !== value.trim()
43
+ || path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)
44
+ || value.split(/[\\/]+/).includes('..');
45
+ }
46
+
47
+ function inspectRoot(directory, label, errors) {
48
+ try {
49
+ const stat = fs.lstatSync(directory);
50
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
51
+ errors.push(`${label}: must be a real directory, not a symlink`);
52
+ return null;
53
+ }
54
+ return fs.realpathSync(directory);
55
+ } catch (error) {
56
+ errors.push(`${label}: cannot be inspected (${error.message})`);
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function inspectPath(root, relativePath) {
62
+ if (unsafeRelative(relativePath)) return { valid: false, reason: 'must be a safe relative path' };
63
+ const target = path.resolve(root, relativePath);
64
+ if (!inside(root, target)) return { valid: false, reason: 'escapes work root' };
65
+ let current = path.resolve(root);
66
+ for (const segment of path.relative(root, target).split(path.sep).filter(Boolean)) {
67
+ current = path.join(current, segment);
68
+ try {
69
+ const stat = fs.lstatSync(current);
70
+ if (stat.isSymbolicLink()) return { valid: false, reason: `contains symlink ${path.relative(root, current)}` };
71
+ } catch (error) {
72
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return { valid: true, exists: false, target };
73
+ return { valid: false, reason: error.message };
74
+ }
75
+ }
76
+ try {
77
+ const real = fs.realpathSync(target);
78
+ if (!inside(root, real)) return { valid: false, reason: 'canonical path escapes work root' };
79
+ return { valid: true, exists: true, target, stat: fs.statSync(target) };
80
+ } catch (error) {
81
+ return { valid: false, reason: error.message };
82
+ }
42
83
  }
43
84
 
44
- function parseRelatedFiles(content, taskFile) {
45
- const lines = content.split('\n');
46
- const headingIndex = lines.findIndex((line) => /^##+\s+Related Files\s*$/i.test(line));
47
- if (headingIndex < 0) return { present: false, rows: [] };
85
+ function extractSection(content, heading) {
86
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
+ const match = content.match(new RegExp(`^##\\s+${escaped}\\s*$`, 'im'));
88
+ if (!match) return null;
89
+ const after = content.slice(match.index + match[0].length);
90
+ const next = after.match(/^##\s+/m);
91
+ return next ? after.slice(0, next.index) : after;
92
+ }
48
93
 
49
- const rows = [];
50
- for (let index = headingIndex + 1; index < lines.length; index += 1) {
51
- const line = lines[index];
52
- if (/^##+\s+/.test(line)) break;
53
- if (!line.trim().startsWith('|')) continue;
54
- const cells = line.split('|').map((cell) => cell.trim());
55
- const filePath = (cells[1] || '').replace(/`/g, '').trim();
56
- const action = (cells[2] || '').replace(/`/g, '').trim().toLowerCase();
57
- if (!filePath || filePath.toLowerCase() === 'path' || /^-+$/.test(filePath)) continue;
58
- rows.push({ path: filePath, action, taskFile: path.basename(taskFile) });
94
+ function parseTable(section, expectedHeaders) {
95
+ if (typeof section !== 'string') return [];
96
+ const lines = section.split('\n');
97
+ for (let index = 0; index + 1 < lines.length; index += 1) {
98
+ if (!/^\s*\|/.test(lines[index]) || !/^\s*\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(lines[index + 1])) continue;
99
+ const headers = lines[index].split('|').slice(1, -1).map((cell) => cell.trim().toLowerCase());
100
+ if (JSON.stringify(headers) !== JSON.stringify(expectedHeaders.map((header) => header.toLowerCase()))) continue;
101
+ const rows = [];
102
+ for (let row = index + 2; row < lines.length && /^\s*\|/.test(lines[row]); row += 1) {
103
+ const cells = lines[row].split('|').slice(1, -1)
104
+ .map((cell) => cell.trim().replace(/^`|`$/g, '').trim());
105
+ if (cells.some(Boolean)) rows.push(cells);
106
+ }
107
+ return rows;
59
108
  }
60
- return { present: true, rows };
109
+ return [];
61
110
  }
62
111
 
63
- function unsafePath(filePath) {
64
- return path.isAbsolute(filePath)
65
- || /^[A-Za-z]:[\\/]/.test(filePath)
66
- || filePath.split(/[\\/]+/).includes('..');
112
+ function normalizeCanonicalAccessAction(rawAccess, rawAction) {
113
+ return SEMANTIC.normalizeAccessAction(rawAccess, rawAction);
114
+ }
115
+
116
+ function listTasks(specDir) {
117
+ const directory = path.join(specDir, 'tasks');
118
+ if (!fs.existsSync(directory)) return [];
119
+ return fs.readdirSync(directory, { withFileTypes: true })
120
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
121
+ .map((entry) => ({
122
+ path: path.join(directory, entry.name),
123
+ relative: `tasks/${entry.name}`,
124
+ basename: entry.name,
125
+ id: entry.name.match(TASK_PATH_RE)?.[1] || null,
126
+ }))
127
+ .sort((left, right) => left.relative.localeCompare(right.relative));
128
+ }
129
+
130
+ function parseTypedAnchors(content, label, design, errors, schema21 = false) {
131
+ const section = extractSection(content, design ? 'Typed Anchors' : (schema21 ? 'Anchors and Ownership' : 'Scope and Typed Anchors')) || '';
132
+ const rows = parseTable(section, schema21
133
+ ? ['ID', 'Type', 'Target', 'Role', 'Access', 'Action']
134
+ : ['ID', 'Type', 'Target', 'Role']);
135
+ const consumesDesign = !design && /\bA-D-\d{2}\b/.test(section);
136
+ if (rows.length === 0 && !consumesDesign) errors.push(`${label}: typed anchor table is missing or empty`);
137
+ return rows.map(([id = '', rawType = '', target = '', role = '', access = '', action = '']) => {
138
+ const type = rawType.toLowerCase();
139
+ if (!ANCHOR_TYPES.has(type)) errors.push(`${label}: anchor ${id || '(missing id)'} has unknown Type ${rawType || '(empty)'}`);
140
+ if (schema21) {
141
+ const normalized = normalizeCanonicalAccessAction(access, action);
142
+ access = normalized.access;
143
+ action = normalized.action;
144
+ if (normalized.error) errors.push(`${label}: anchor ${id} ${normalized.error}`);
145
+ if (unsafeRelative(target) || /[{}*?\[\]]/.test(target) || /[\\/]$/.test(target)) errors.push(`${label}: anchor ${id} requires one exact grounded target`);
146
+ }
147
+ const namespace = design ? /^A-D-\d{2}$/ : new RegExp(`^A-${path.basename(label).match(TASK_PATH_RE)?.[1] || 'INVALID'}-\\d{2}$`);
148
+ if (schema21 && !namespace.test(id)) errors.push(`${label}: anchor ${id || '(missing id)'} has invalid ${design ? 'design' : 'task'} namespace`);
149
+ if (!target || unsafeRelative(target) || /[{}*?\[\]]/.test(target) || /[\\/]$/.test(target)) {
150
+ errors.push(`${label}: anchor ${id || '(missing id)'} requires one exact grounded target`);
151
+ }
152
+ if (!role) errors.push(`${label}: anchor ${id || '(missing id)'} Role must be concrete`);
153
+ return { id, type, target, role: role.toLowerCase(), access, action, label, design, schema21 };
154
+ });
155
+ }
156
+
157
+ function parseCanonicalAnchors(content, options, errors) {
158
+ return SEMANTIC.parseAnchors(content, options, errors).map((anchor) => ({
159
+ ...anchor,
160
+ label: options.label,
161
+ design: options.design === true,
162
+ schema21: true,
163
+ }));
164
+ }
165
+
166
+ function parseVerificationDefinitions(designText, errors) {
167
+ return SEMANTIC.parseVerificationDefinitions(designText, errors);
168
+ }
169
+
170
+ function parseLegacyRelatedFiles(content, label) {
171
+ const section = extractSection(content, 'Related Files');
172
+ if (section === null) return { present: false, anchors: [] };
173
+ return { present: true, anchors: parseTable(section, ['Path', 'Action', 'Description']).map(([target, action], index) => ({
174
+ id: `${label}:${index}:${target}`,
175
+ type: 'file',
176
+ target,
177
+ role: action,
178
+ label,
179
+ legacy: true,
180
+ })) };
67
181
  }
68
182
 
69
183
  function expandBraces(pattern) {
@@ -77,176 +191,460 @@ function expandBraces(pattern) {
77
191
  function globRegExp(pattern) {
78
192
  let source = '^';
79
193
  for (let index = 0; index < pattern.length; index += 1) {
80
- const char = pattern[index];
81
- if (char === '*') {
194
+ const character = pattern[index];
195
+ if (character === '*') {
82
196
  if (pattern[index + 1] === '*') {
83
197
  index += 1;
84
198
  if (pattern[index + 1] === '/') index += 1;
85
199
  source += '.*';
86
200
  } else source += '[^/]*';
87
- } else if (char === '?') source += '[^/]';
88
- else source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
201
+ } else if (character === '?') source += '[^/]';
202
+ else source += character.replace(/[.+^${}()|[\]\\]/g, '\\$&');
89
203
  }
90
204
  return new RegExp(`${source}$`);
91
205
  }
92
206
 
93
207
  function relativeEntries(root) {
94
208
  const entries = [];
95
- function walk(current, relative) {
209
+ function visit(directory, relative) {
96
210
  let children;
97
- try { children = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
98
- for (const entry of children) {
99
- const nextRelative = relative ? `${relative}/${entry.name}` : entry.name;
100
- const next = path.join(current, entry.name);
211
+ try { children = fs.readdirSync(directory, { withFileTypes: true }); } catch (_) { return; }
212
+ for (const child of children) {
213
+ const nextRelative = relative ? `${relative}/${child.name}` : child.name;
101
214
  entries.push(nextRelative);
102
- if (entry.isDirectory() && !entry.isSymbolicLink()) walk(next, nextRelative);
215
+ if (child.isDirectory() && !child.isSymbolicLink()) visit(path.join(directory, child.name), nextRelative);
103
216
  }
104
217
  }
105
- walk(root, '');
218
+ visit(root, '');
106
219
  return entries;
107
220
  }
108
221
 
109
- function matchingEntries(root, filePath) {
110
- if (!/[{}*?]/.test(filePath)) return fs.existsSync(path.join(root, filePath)) ? [filePath] : [];
111
- const candidates = relativeEntries(root);
222
+ function matchingEntries(root, target) {
223
+ if (!/[{}*?]/.test(target)) return fs.existsSync(path.join(root, target)) ? [target] : [];
224
+ const entries = relativeEntries(root);
112
225
  const matches = new Set();
113
- for (const expanded of expandBraces(filePath)) {
114
- const matcher = globRegExp(expanded.replaceAll('\\', '/'));
115
- for (const candidate of candidates) if (matcher.test(candidate)) matches.add(candidate);
226
+ for (const expanded of expandBraces(target)) {
227
+ const pattern = globRegExp(expanded.replaceAll('\\', '/'));
228
+ for (const entry of entries) if (pattern.test(entry)) matches.add(entry);
116
229
  }
117
230
  return [...matches];
118
231
  }
119
232
 
120
- function main() {
121
- let args;
122
- try { args = parseArgs(process.argv); } catch (error) {
123
- usage();
124
- console.error(`- ${error.message}`);
125
- process.exit(2);
233
+ function groundLegacy(anchors, taskRegistry, root, errors) {
234
+ const allowed = new Set(['create', 'modify', 'delete', 'read']);
235
+ const byTask = new Map();
236
+ for (const anchor of anchors) {
237
+ if (!byTask.has(anchor.label)) byTask.set(anchor.label, []);
238
+ byTask.get(anchor.label).push(anchor);
239
+ if (!allowed.has(anchor.role.toLowerCase())) errors.push(`${anchor.label}: unsupported Related Files action "${anchor.role}" for ${anchor.target}`);
240
+ if (unsafeRelative(anchor.target)) errors.push(`${anchor.label}: Related Files path must be relative and stay within work root: ${anchor.target}`);
241
+ }
242
+ for (const [taskFile, rows] of byTask) {
243
+ const firstCreate = new Map();
244
+ rows.forEach((row, index) => {
245
+ if (row.role.toLowerCase() === 'create' && !firstCreate.has(row.target)) firstCreate.set(row.target, index);
246
+ });
247
+ rows.forEach((row, index) => {
248
+ const action = row.role.toLowerCase();
249
+ if (!['modify', 'delete', 'read'].includes(action) || !firstCreate.has(row.target)) return;
250
+ if (firstCreate.get(row.target) > index) errors.push(`${taskFile}: Create must precede ${action} for ${row.target}`);
251
+ });
126
252
  }
127
- if (!args.specDir) { usage(); process.exit(2); }
128
253
 
129
- const specDir = path.resolve(process.cwd(), args.specDir);
130
- if (!fs.existsSync(specDir)) {
131
- console.error(`FAIL ${args.specDir}\n- spec directory does not exist`);
132
- process.exit(1);
254
+ const producers = new Map();
255
+ for (const anchor of anchors) if (anchor.role.toLowerCase() === 'create' && !producers.has(anchor.target)) producers.set(anchor.target, anchor.label);
256
+ const consumers = anchors.filter((anchor) => ['modify', 'delete', 'read'].includes(anchor.role.toLowerCase())
257
+ && producers.has(anchor.target) && producers.get(anchor.target) !== anchor.label);
258
+ if (consumers.length > 0 && !isPlainObject(taskRegistry)) {
259
+ errors.push('spec.json task_registry is missing or invalid for a cross-task producer/consumer dependency (fail-closed)');
260
+ } else if (consumers.length > 0) {
261
+ const dependencies = new Map(Object.entries(taskRegistry).map(([taskFile, entry]) => [
262
+ taskFile,
263
+ new Set(Array.isArray(entry?.dependencies) ? entry.dependencies : []),
264
+ ]));
265
+ function dependsOn(taskFile, ancestor, seen = new Set()) {
266
+ if (taskFile === ancestor) return true;
267
+ if (seen.has(taskFile)) return false;
268
+ seen.add(taskFile);
269
+ return [...(dependencies.get(taskFile) || [])].some((dependency) => dependsOn(dependency, ancestor, seen));
270
+ }
271
+ for (const consumer of consumers) {
272
+ if (!dependsOn(consumer.label, producers.get(consumer.target))) {
273
+ errors.push(`${consumer.label}: ${consumer.role.toLowerCase()} of ${consumer.target} must depend on creator ${producers.get(consumer.target)}`);
274
+ }
275
+ }
133
276
  }
134
- const root = args.root
135
- ? path.resolve(process.cwd(), args.root)
136
- : path.resolve(specDir, '..', '..');
137
277
 
138
- const taskFiles = listTaskFiles(specDir);
139
- const allRows = [];
140
- const rowsByTask = new Map();
141
- const errors = [];
142
- const warnings = [];
143
- for (const taskFile of taskFiles) {
144
- const section = parseRelatedFiles(fs.readFileSync(taskFile, 'utf8'), taskFile);
145
- const basename = path.basename(taskFile);
146
- if (!rowsByTask.has(basename)) rowsByTask.set(basename, []);
147
- if (!section.present) errors.push(`${basename}: missing Related Files section`);
148
- else if (section.rows.length === 0) errors.push(`${basename}: Related Files section must not be empty`);
149
- for (const row of section.rows) {
150
- if (!ACTIONS.has(row.action)) errors.push(`${row.taskFile}: unsupported Related Files action "${row.action}" for ${row.path}`);
151
- if (unsafePath(row.path)) errors.push(`${row.taskFile}: Related Files path must be relative and stay within work root: ${row.path}`);
152
- allRows.push(row);
153
- rowsByTask.get(basename).push(row);
154
- }
155
- }
156
-
157
- // Lifecycle ordering: Create must precede Modify/Delete/Read within same task
158
- for (const [taskFile, rows] of rowsByTask.entries()) {
159
- const createIndexByPath = new Map();
160
- rows.forEach((row, idx) => {
161
- if (row.action === 'create' && !createIndexByPath.has(row.path)) createIndexByPath.set(row.path, idx);
162
- });
163
- rows.forEach((row, idx) => {
164
- if (!['modify', 'delete', 'read'].includes(row.action)) return;
165
- if (!createIndexByPath.has(row.path)) return;
166
- const createIdx = createIndexByPath.get(row.path);
167
- if (createIdx > idx) {
168
- errors.push(`${taskFile}: Create must precede ${row.action} for ${row.path} within Related Files order`);
278
+ const created = new Set(anchors.filter((anchor) => anchor.role.toLowerCase() === 'create').map((anchor) => anchor.target));
279
+ for (const anchor of anchors) {
280
+ const action = anchor.role.toLowerCase();
281
+ if (!allowed.has(action) || unsafeRelative(anchor.target)) continue;
282
+ const matches = matchingEntries(root, anchor.target);
283
+ if (/[{}*?]/.test(anchor.target) && matches.length === 0) errors.push(`${anchor.label}: glob matches no paths in work tree: ${anchor.target}`);
284
+ else if (action === 'create' && matches.length > 0) errors.push(`${anchor.label}: Create path already exists in work tree: ${anchor.target}`);
285
+ else if (action !== 'create' && matches.length === 0 && !created.has(anchor.target)) errors.push(`${anchor.label}: ${action} path not found in work tree: ${anchor.target}`);
286
+ }
287
+ }
288
+
289
+ function designContracts(designText) {
290
+ return new Set([
291
+ ...[...designText.matchAll(/^#{3,6}\s+(C\d+)\s+(?:—|-)\s+.+$/gim)].map((match) => match[1].toUpperCase()),
292
+ ...[...designText.matchAll(/<!--\s*contract:([A-Za-z0-9_.-]+)\s*-->/g)].map((match) => match[1]),
293
+ ]);
294
+ }
295
+
296
+ function packageJsonFiles(root) {
297
+ const found = [];
298
+ function visit(directory, depth) {
299
+ if (depth > 5) return;
300
+ let entries;
301
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch (_) { return; }
302
+ for (const entry of entries) {
303
+ if (entry.isSymbolicLink() || ['node_modules', '.git', 'dist', 'build', 'coverage'].includes(entry.name)) continue;
304
+ const target = path.join(directory, entry.name);
305
+ if (entry.isDirectory()) visit(target, depth + 1);
306
+ else if (entry.name === 'package.json') found.push(target);
307
+ }
308
+ }
309
+ visit(root, 0);
310
+ return found;
311
+ }
312
+
313
+ function packageScripts(root) {
314
+ const scripts = new Map();
315
+ for (const file of packageJsonFiles(root)) {
316
+ try {
317
+ const json = JSON.parse(fs.readFileSync(file, 'utf8'));
318
+ for (const name of Object.keys(json.scripts || {})) {
319
+ if (!scripts.has(name)) scripts.set(name, []);
320
+ scripts.get(name).push(path.relative(root, file));
169
321
  }
170
- });
322
+ } catch (_) {}
171
323
  }
324
+ return scripts;
325
+ }
172
326
 
173
- // Cross-task lifecycle via spec.json dependency DAG (fail-closed when registry missing)
174
- let taskRegistry = null;
175
- try {
176
- const specJson = JSON.parse(fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8'));
177
- if (specJson.task_registry && typeof specJson.task_registry === 'object' && !Array.isArray(specJson.task_registry)) taskRegistry = specJson.task_registry;
178
- } catch {}
179
- const producers = new Map();
180
- for (const [taskFile, rows] of rowsByTask.entries()) {
181
- for (const row of rows) {
182
- if (row.action !== 'create') continue;
183
- if (!producers.has(row.path)) producers.set(row.path, { taskFile });
184
- }
185
- }
186
- const crossTaskConsumers = [];
187
- for (const [taskFile, rows] of rowsByTask.entries()) {
188
- for (const row of rows) {
189
- if (!['modify', 'delete', 'read'].includes(row.action)) continue;
190
- const producer = producers.get(row.path);
191
- if (!producer) continue;
192
- if (producer.taskFile === taskFile) continue;
193
- crossTaskConsumers.push({ taskFile, row, producer });
194
- }
195
- }
196
- if (crossTaskConsumers.length > 0 && !taskRegistry) {
197
- for (const { taskFile, row, producer } of crossTaskConsumers) {
198
- errors.push(`${taskFile}: ${row.action} of ${row.path} has producer ${producer.taskFile} but spec.json task_registry is missing or invalid — cannot verify cross-task dependency (fail-closed)`);
199
- }
200
- } else if (taskRegistry) {
201
- // Build basename-indexed dependencies
202
- const dependencies = new Map();
203
- for (const [key, entry] of Object.entries(taskRegistry)) {
204
- const base = path.basename(key);
205
- const deps = new Set((entry.dependencies || []).map((d) => path.basename(d)));
206
- dependencies.set(base, deps);
207
- }
208
- function dependsOn(taskBase, ancestorBase, seen = new Set()) {
209
- if (taskBase === ancestorBase) return true;
210
- if (seen.has(taskBase)) return false;
211
- seen.add(taskBase);
212
- const deps = dependencies.get(taskBase) || new Set();
213
- for (const dep of deps) if (dependsOn(dep, ancestorBase, seen)) return true;
214
- return false;
215
- }
216
- for (const { taskFile, row, producer } of crossTaskConsumers) {
217
- if (!dependsOn(taskFile, producer.taskFile)) {
218
- errors.push(`${taskFile}: ${row.action} of ${row.path} must depend on creator ${producer.taskFile}`);
327
+ function commandScript(command) {
328
+ const tokens = command.trim().split(/\s+/);
329
+ const manager = tokens[0];
330
+ if (!['npm', 'pnpm', 'yarn', 'bun'].includes(manager)) return null;
331
+ const runIndex = tokens.indexOf('run');
332
+ if (runIndex >= 0) return tokens[runIndex + 1] || '';
333
+ const filtered = tokens.filter((token, index) => index > 0 && !token.startsWith('-')
334
+ && tokens[index - 1] !== '--filter' && tokens[index - 1] !== '-C' && tokens[index - 1] !== '--dir');
335
+ return filtered[0] || '';
336
+ }
337
+
338
+ function legacyIntendedAction(anchor) {
339
+ return /\b(?:create|planned|new|produce|output)\b/i.test(anchor.role) ? 'create' : 'read';
340
+ }
341
+
342
+ function intendedAction(anchor) {
343
+ return anchor.schema21 ? anchor.action : legacyIntendedAction(anchor);
344
+ }
345
+
346
+ function createTargetMayAlreadyExist(anchor) {
347
+ return anchor.schema21 && ['in_progress', 'done'].includes(anchor.taskStatus);
348
+ }
349
+
350
+ function groundPathTarget(anchor, root, createdTargets, plannedDependencyReads, errors) {
351
+ const prefix = `${anchor.label}: ${anchor.id || anchor.type}`;
352
+ const inspected = inspectPath(root, anchor.target);
353
+ if (!inspected.valid) {
354
+ errors.push(`${prefix}: ${anchor.type} target ${anchor.target} ${inspected.reason}`);
355
+ return;
356
+ }
357
+ const action = intendedAction(anchor);
358
+ if (action === 'create') {
359
+ const parent = inspectPath(root, path.dirname(anchor.target));
360
+ if (!parent.valid || !parent.exists || !parent.stat.isDirectory()) {
361
+ errors.push(`${prefix}: create target parent is not grounded for ${anchor.target}`);
362
+ } else if (inspected.exists && !createTargetMayAlreadyExist(anchor)) {
363
+ errors.push(`${prefix}: create target already exists: ${anchor.target}`);
364
+ }
365
+ else createdTargets.add(anchor.target);
366
+ } else if (!inspected.exists && !plannedDependencyReads.has(`${anchor.label}\0${anchor.target}`)) {
367
+ errors.push(`${prefix}: ${action} target not found in work tree: ${anchor.target}`);
368
+ }
369
+ }
370
+
371
+ function symbolParts(target) {
372
+ const separator = target.lastIndexOf('#');
373
+ if (separator <= 0 || separator === target.length - 1) return null;
374
+ return {
375
+ file: target.slice(0, separator),
376
+ symbol: target.slice(separator + 1).replace(/\(.*\)$/, ''),
377
+ };
378
+ }
379
+
380
+ function sourceBodies(root, excludedDirectories = []) {
381
+ const bodies = [];
382
+ const excluded = excludedDirectories.map((directory) => path.resolve(directory));
383
+ const ignored = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', 'vendor', '.next']);
384
+ function visit(directory, relative, depth) {
385
+ if (depth > 8) return;
386
+ let entries;
387
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch (_) { return; }
388
+ for (const entry of entries) {
389
+ if (entry.isSymbolicLink() || ignored.has(entry.name)) continue;
390
+ const nextRelative = relative ? `${relative}/${entry.name}` : entry.name;
391
+ const target = path.join(directory, entry.name);
392
+ if (excluded.some((excludedDirectory) => target === excludedDirectory || inside(excludedDirectory, target))) continue;
393
+ if (entry.isDirectory()) visit(target, nextRelative, depth + 1);
394
+ else if (/\.(?:[cm]?[jt]sx?|py|go|rs|swift|java|kt|rb|php|sql|graphql|ya?ml|json)$/i.test(entry.name)) {
395
+ try {
396
+ if (fs.statSync(target).size <= 1024 * 1024) bodies.push([nextRelative, fs.readFileSync(target, 'utf8')]);
397
+ } catch (_) {}
219
398
  }
220
399
  }
221
400
  }
401
+ visit(root, '', 0);
402
+ return bodies;
403
+ }
404
+
405
+ function semanticTargetExists(anchor, bodies) {
406
+ const needle = anchor.type === 'route'
407
+ ? anchor.target.replace(/^\s*(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+/i, '')
408
+ : anchor.target;
409
+ return bodies.some(([, body]) => body.includes(needle));
410
+ }
411
+
412
+ function groundCommand(anchor, root, scripts, errors, warnings) {
413
+ const prefix = `${anchor.label}: ${anchor.id || anchor.type}`;
414
+ const script = commandScript(anchor.target);
415
+ if (script === '') errors.push(`${prefix}: command is missing a package script name`);
416
+ else if (script !== null && !scripts.has(script)) errors.push(`${prefix}: package script "${script}" is not declared by project tooling`);
417
+ else if (script === null && !/^(?:node|npx|git|gh|make|pytest|jest|vitest|go|cargo|swift)\b/.test(anchor.target)) {
418
+ errors.push(`${prefix}: command tooling is not deterministically reachable: ${anchor.target}`);
419
+ }
420
+ if (script !== null) return;
421
+ const pathTokens = anchor.target.split(/\s+/).map((token) => token.replace(/^['"]|['";,]$/g, ''))
422
+ .filter((token) => /[\\/]/.test(token) && !token.startsWith('-') && !/^https?:/.test(token));
423
+ for (const candidate of pathTokens) {
424
+ const inspected = inspectPath(root, candidate);
425
+ if (!inspected.valid || !inspected.exists) errors.push(`${prefix}: command target not found in work tree: ${candidate}`);
426
+ }
427
+ void warnings;
428
+ }
222
429
 
223
- const createdPaths = new Set(allRows.filter((row) => row.action === 'create').map((row) => row.path));
224
- let checked = 0;
225
- for (const row of allRows) {
226
- if (!ACTIONS.has(row.action) || unsafePath(row.path)) continue;
227
- const matches = matchingEntries(root, row.path);
228
- if (/[{}*?]/.test(row.path) && matches.length === 0) {
229
- errors.push(`${row.taskFile}: glob matches no paths in work tree: ${row.path}`);
230
- continue;
430
+ function groundAnchor(anchor, root, scripts, bodies, createdTargets, plannedDependencyReads, reachableAnchors, errors, warnings) {
431
+ const prefix = `${anchor.label}: ${anchor.id || anchor.type}`;
432
+ if (!ANCHOR_TYPES.has(anchor.type)) return;
433
+ if (anchor.type === 'file' || anchor.type === 'artifact') {
434
+ return groundPathTarget(anchor, root, createdTargets, plannedDependencyReads, errors);
435
+ }
436
+ if (anchor.type === 'symbol') {
437
+ const parts = symbolParts(anchor.target);
438
+ if (!parts) {
439
+ errors.push(`${prefix}: symbol target must use path/to/file#Symbol`);
440
+ return;
231
441
  }
232
- if (row.action === 'create') {
233
- if (matches.length > 0) errors.push(`${row.taskFile}: Create path already exists in work tree (would overwrite): ${row.path} — use Modify instead of Create for existing files`);
234
- continue;
442
+ const inspected = inspectPath(root, parts.file);
443
+ if (!inspected.valid || !inspected.exists || !inspected.stat.isFile()) {
444
+ if (!(intendedAction(anchor) === 'create' && createdTargets.has(parts.file))) {
445
+ errors.push(`${prefix}: containing file not found for symbol ${anchor.target}`);
446
+ }
447
+ return;
235
448
  }
236
- checked += 1;
237
- if (matches.length === 0 && !createdPaths.has(row.path)) {
238
- errors.push(`${row.taskFile}: ${row.action} path not found in work tree: ${row.path}`);
449
+ const body = fs.readFileSync(inspected.target, 'utf8');
450
+ const escaped = parts.symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
451
+ const exists = new RegExp(`(?:^|[^A-Za-z0-9_$])${escaped}(?:$|[^A-Za-z0-9_$])`, 'm').test(body);
452
+ if (intendedAction(anchor) === 'create' && exists && !createTargetMayAlreadyExist(anchor)) {
453
+ errors.push(`${prefix}: create symbol already exists: ${anchor.target}`);
239
454
  }
455
+ else if (intendedAction(anchor) !== 'create' && !exists) {
456
+ errors.push(`${prefix}: symbol ${parts.symbol} not found in containing file ${parts.file}`);
457
+ }
458
+ return;
459
+ }
460
+ if (anchor.type === 'command') return groundCommand(anchor, root, scripts, errors, warnings);
461
+ if (['route', 'schema', 'contract'].includes(anchor.type)) {
462
+ const exists = semanticTargetExists(anchor, bodies);
463
+ if (intendedAction(anchor) === 'create' && !reachableAnchors.has(anchor.id)) {
464
+ errors.push(`${prefix}: create ${anchor.type} requires repository entrypoint registration reachability`);
465
+ } else if (intendedAction(anchor) === 'create' && exists && !createTargetMayAlreadyExist(anchor)) {
466
+ errors.push(`${prefix}: create ${anchor.type} already exists: ${anchor.target}`);
467
+ }
468
+ else if (intendedAction(anchor) !== 'create' && !exists) errors.push(`${prefix}: ${anchor.type} target is not semantically reachable: ${anchor.target}`);
469
+ return;
470
+ }
471
+ if (anchor.type === 'external' && !/^https?:\/\/[^\s]+$/.test(anchor.target)) {
472
+ errors.push(`${prefix}: external target must be one exact http(s) URL`);
240
473
  }
474
+ }
475
+
476
+ function canonicalProjectRoot(specDir, requestedRoot = null) {
477
+ const canonicalSpecDir = fs.realpathSync(specDir);
478
+ if (requestedRoot) return fs.realpathSync(requestedRoot);
479
+ const cwd = fs.realpathSync(process.cwd());
480
+ return inside(cwd, canonicalSpecDir) ? cwd : fs.realpathSync(path.resolve(canonicalSpecDir, '..', '..'));
481
+ }
482
+
483
+ function groundSpec({ specDir: inputSpecDir, root: inputRoot = null, spec: inputSpec = null }) {
484
+ const errors = [];
485
+ const warnings = [];
486
+ const specDir = inspectRoot(path.resolve(inputSpecDir), 'spec directory', errors);
487
+ if (!specDir) return { errors, warnings, checked: 0, specDir: null, root: null };
488
+ let root;
489
+ try { root = inspectRoot(canonicalProjectRoot(specDir, inputRoot), 'work context', errors); } catch (error) {
490
+ errors.push(`work context: cannot be resolved (${error.message})`);
491
+ }
492
+ if (!root) return { errors, warnings, checked: 0, specDir, root: null };
493
+ if (!inside(root, specDir)) errors.push('spec directory: must be inside canonical work context');
241
494
 
242
- for (const warning of warnings) console.warn(`[WARN] ${warning}`);
243
- const relativeSpec = path.relative(process.cwd(), specDir) || specDir;
244
- if (errors.length > 0) {
245
- console.error(`FAIL ${relativeSpec} (root: ${root})`);
246
- for (const error of errors) console.error(`- ${error}`);
495
+ let spec = inputSpec;
496
+ if (!isPlainObject(spec)) {
497
+ try { spec = JSON.parse(fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8')); } catch (_) { spec = null; }
498
+ }
499
+ let policy = null;
500
+ if (isPlainObject(spec) && Object.prototype.hasOwnProperty.call(spec, 'workflow_policy')) {
501
+ const validation = POLICY.validateWorkflowPolicySnapshot(spec.workflow_policy);
502
+ if (!validation.valid) errors.push(...validation.errors.map((error) => `spec.json: ${error}`));
503
+ else try { policy = POLICY.readWorkflowPolicySnapshot(spec); } catch (error) { errors.push(`spec.json: ${error.message}`); }
504
+ }
505
+ let designText = '';
506
+ try { designText = fs.readFileSync(path.join(specDir, 'design.md'), 'utf8'); } catch (error) {
507
+ if (spec?.workflow_policy?.version === '2' || spec?.schema_version === '2.1') errors.push(`design.md: cannot be read (${error.message})`);
508
+ }
509
+ const tasks = listTasks(specDir);
510
+ const isV2 = ['2', '2.1'].includes(spec?.workflow_policy?.version);
511
+ const is21 = spec?.schema_version === '2.1';
512
+ let anchors = isV2 ? parseTypedAnchors(designText, 'design.md', true, errors, is21) : [];
513
+ let verificationDefinitions = is21 ? parseVerificationDefinitions(designText, errors) : new Map();
514
+ const taskContents = new Map();
515
+ for (const task of tasks) {
516
+ const content = fs.readFileSync(task.path, 'utf8');
517
+ taskContents.set(task.relative, content);
518
+ if (isV2) anchors.push(...parseTypedAnchors(content, task.relative, false, errors, is21));
519
+ else {
520
+ const legacy = parseLegacyRelatedFiles(content, task.relative);
521
+ if (!legacy.present) errors.push(`${task.relative}: missing Related Files section`);
522
+ else if (legacy.anchors.length === 0) errors.push(`${task.relative}: Related Files section must not be empty`);
523
+ anchors.push(...legacy.anchors);
524
+ }
525
+ }
526
+ if (is21) {
527
+ const projection = SEMANTIC.modelFromMarkdown(specDir, spec);
528
+ errors.push(...projection.errors);
529
+ if (!spec.semantic_model || typeof spec.semantic_model !== 'object' || Array.isArray(spec.semantic_model)) {
530
+ errors.push('spec.json: schema 2.1 grounding requires promoted semantic_model authority');
531
+ } else {
532
+ errors.push(...SEMANTIC.validateSemanticModel(spec.semantic_model, spec));
533
+ if (SEMANTIC.stableJson(spec.semantic_model) !== SEMANTIC.stableJson(projection.model)) {
534
+ errors.push('spec.json: semantic_model differs from authored Markdown projection; rerun spec-readiness with the current semantic review result');
535
+ }
536
+ const taskById = new Map(Object.entries(spec.task_registry || {}).map(([taskPath, entry]) => [entry?.id, {
537
+ path: taskPath,
538
+ status: entry?.status,
539
+ }]));
540
+ anchors = (spec.semantic_model.anchors || []).map((anchor) => {
541
+ const taskId = anchor.id.match(/^A-(R\d+-\d{2})-/)?.[1];
542
+ const task = taskId ? taskById.get(taskId) : null;
543
+ return {
544
+ ...anchor,
545
+ label: task?.path || taskId || 'design.md',
546
+ taskStatus: task?.status || null,
547
+ design: !taskId,
548
+ schema21: true,
549
+ };
550
+ });
551
+ verificationDefinitions = new Map((spec.semantic_model.verification_definitions || []).map((definition) => [definition.id, definition]));
552
+ }
553
+ }
554
+ if (policy?.planning_depth === 'None') errors.push('spec.json: None planning depth cannot be grounded as a durable spec');
555
+ if (isV2) {
556
+ const tasksRequired = is21 ? tasks.length > 0 : spec?.coordination?.tasks_required === true;
557
+ if (tasksRequired !== (tasks.length > 0)) errors.push('spec.json: coordination.tasks_required must match physical task inventory');
558
+ const byId = new Map();
559
+ const designTargets = new Map();
560
+ for (const anchor of anchors) {
561
+ if (byId.has(anchor.id)) errors.push(`${anchor.label}: duplicate anchor ID ${anchor.id}`);
562
+ else byId.set(anchor.id, anchor);
563
+ if (anchor.design) designTargets.set(`${anchor.type}\0${anchor.target}`, anchor.id);
564
+ else if (designTargets.has(`${anchor.type}\0${anchor.target}`)) {
565
+ errors.push(`${anchor.label}: anchor ${anchor.id} duplicates canonical design target ${designTargets.get(`${anchor.type}\0${anchor.target}`)}; reference the A-D anchor instead`);
566
+ }
567
+ }
568
+ for (const [taskPath, content] of taskContents) for (const reference of content.match(/\bA-D-\d{2}\b/g) || []) {
569
+ if (!byId.has(reference)) errors.push(`${taskPath}: dangling canonical design anchor reference ${reference}`);
570
+ }
571
+ for (const reference of designText.match(/\bA-D-\d{2}\b/g) || []) if (!byId.has(reference)) {
572
+ errors.push(`design.md: dangling canonical design anchor reference ${reference}`);
573
+ }
574
+ for (const definition of verificationDefinitions.values()) {
575
+ for (const reference of definition.reachability?.anchor_refs || []) {
576
+ if (!byId.has(reference)) errors.push(`design.md: ${definition.id} has dangling reachability anchor ${reference}`);
577
+ }
578
+ }
579
+ }
580
+ const scripts = packageScripts(root);
581
+ const bodies = sourceBodies(root, [specDir]);
582
+ const createdTargets = new Set();
583
+ const plannedDependencyReads = new Set();
584
+ if (is21) {
585
+ const creatorsByTarget = new Map();
586
+ for (const anchor of anchors) if (!anchor.design && intendedAction(anchor) === 'create') {
587
+ if (!creatorsByTarget.has(anchor.target)) creatorsByTarget.set(anchor.target, []);
588
+ creatorsByTarget.get(anchor.target).push(anchor.label);
589
+ }
590
+ for (const anchor of anchors) if (!anchor.design && intendedAction(anchor) === 'read') {
591
+ const dependencies = spec.task_registry?.[anchor.label]?.dependencies || [];
592
+ if ((creatorsByTarget.get(anchor.target) || []).some((producer) => producer === anchor.label || dependencies.includes(producer))) {
593
+ plannedDependencyReads.add(`${anchor.label}\0${anchor.target}`);
594
+ }
595
+ }
596
+ }
597
+ const reachableAnchors = new Set();
598
+ if (is21) for (const definition of verificationDefinitions.values()) {
599
+ const entrypoint = inspectPath(root, definition.reachability?.entrypoint);
600
+ if (!entrypoint.valid || !entrypoint.exists || !entrypoint.stat.isFile()) {
601
+ errors.push(`design.md: ${definition.id} repository entrypoint not found: ${definition.reachability?.entrypoint || '(empty)'}`);
602
+ } else for (const anchorId of definition.reachability.anchor_refs) reachableAnchors.add(anchorId);
603
+ if (definition.method?.kind === 'command') {
604
+ groundCommand({
605
+ id: definition.id,
606
+ label: 'design.md',
607
+ type: 'command',
608
+ target: definition.method.value,
609
+ }, root, scripts, errors, warnings);
610
+ }
611
+ }
612
+ if (isV2) for (const anchor of anchors) {
613
+ groundAnchor(anchor, root, scripts, bodies, createdTargets, plannedDependencyReads, reachableAnchors, errors, warnings);
614
+ }
615
+ else groundLegacy(anchors, spec?.task_registry, root, errors);
616
+ return { errors, warnings, checked: anchors.length, specDir, root };
617
+ }
618
+
619
+ function main() {
620
+ let args;
621
+ try { args = parseArgs(process.argv); } catch (error) {
622
+ usage();
623
+ console.error(`- ${error.message}`);
624
+ process.exit(2);
625
+ }
626
+ if (!args.specDir) { usage(); process.exit(2); }
627
+
628
+ const result = groundSpec({
629
+ specDir: path.resolve(process.cwd(), args.specDir),
630
+ root: args.root ? path.resolve(process.cwd(), args.root) : null,
631
+ });
632
+ for (const warning of result.warnings) console.warn(`[WARN] ${warning}`);
633
+ const relativeSpec = result.specDir ? path.relative(process.cwd(), result.specDir) || result.specDir : args.specDir;
634
+ if (result.errors.length > 0) {
635
+ console.error(`GROUNDING_FAIL ${relativeSpec}${result.root ? ` (root: ${result.root})` : ''}`);
636
+ for (const error of result.errors) console.error(`- ${error}`);
247
637
  process.exit(1);
248
638
  }
249
- console.log(`GROUNDED ${relativeSpec} (${checked} path(s) verified against ${root})`);
639
+ console.log(`GROUNDED ${relativeSpec} (${result.checked} typed/legacy anchor(s) checked; semantic and execution proof not claimed)`);
250
640
  }
251
641
 
252
- main();
642
+ module.exports = {
643
+ canonicalProjectRoot,
644
+ groundSpec,
645
+ normalizeCanonicalAccessAction,
646
+ parseCanonicalAnchors,
647
+ parseVerificationDefinitions,
648
+ };
649
+
650
+ if (require.main === module) main();