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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/bin/__tests__/codex-hooks.test.js +444 -14
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +615 -185
  4. package/bin/__tests__/installer-safety.test.js +39 -0
  5. package/bin/__tests__/opencode-spec-gate.test.js +24 -16
  6. package/bin/__tests__/package-inventory.test.js +687 -13
  7. package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
  8. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
  9. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
  10. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  11. package/bin/__tests__/validator-grounding.test.js +1914 -221
  12. package/bin/lib/managed-writer.js +12 -3
  13. package/bin/phases/codex-runtime.js +17 -1
  14. package/package.json +1 -1
  15. package/src/claude/CLAUDE.md +6 -0
  16. package/src/claude/agents/code-auditor.md +20 -0
  17. package/src/claude/agents/spec-maker.md +143 -99
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +291 -58
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
  20. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  21. package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
  22. package/src/claude/hooks/completion-authority-check.cjs +37 -288
  23. package/src/claude/hooks/completion-authority-state.cjs +10 -8
  24. package/src/claude/hooks/completion-authority.cjs +13 -7
  25. package/src/claude/hooks/docs-sync.cjs +5 -2
  26. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  27. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  28. package/src/claude/hooks/privacy-block.cjs +77 -30
  29. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  30. package/src/claude/hooks/spec-gate.cjs +47 -117
  31. package/src/claude/hooks/spec-state.cjs +8 -2
  32. package/src/claude/migration-manifest.json +9 -1
  33. package/src/claude/rules/state-sync.md +52 -21
  34. package/src/claude/rules/workflow.md +17 -9
  35. package/src/claude/scripts/spec-final-state.cjs +244 -0
  36. package/src/claude/scripts/spec-ground.cjs +535 -173
  37. package/src/claude/scripts/spec-readiness.cjs +118 -0
  38. package/src/claude/scripts/spec-receipt.cjs +162 -0
  39. package/src/claude/scripts/spec-resolver.cjs +67 -16
  40. package/src/claude/scripts/spec-scaffold.cjs +1354 -139
  41. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  42. package/src/claude/scripts/validate-spec-output.cjs +2782 -142
  43. package/src/claude/scripts/workflow-policy.cjs +501 -183
  44. package/src/claude/settings/settings.json +4 -0
  45. package/src/claude/skills/code-review/SKILL.md +12 -3
  46. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  47. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  48. package/src/claude/skills/develop/SKILL.md +47 -60
  49. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  50. package/src/claude/skills/develop/references/quality-gate.md +19 -12
  51. package/src/claude/skills/specs/SKILL.md +183 -177
  52. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  53. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  54. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  55. package/src/claude/skills/specs/references/review.md +200 -260
  56. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  57. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  58. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  59. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  60. package/src/claude/skills/specs/rules/design-review.md +42 -97
  61. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  62. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  63. package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
  64. package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
  65. package/src/claude/skills/specs/templates/design.md +49 -263
  66. package/src/claude/skills/specs/templates/requirements.md +26 -36
  67. package/src/claude/skills/specs/templates/research.md +9 -98
  68. package/src/claude/skills/specs/templates/spec-state.json +23 -85
  69. package/src/claude/skills/specs/templates/task.md +30 -102
  70. package/src/claude/skills/sync/SKILL.md +33 -10
  71. package/src/claude/skills/sync/references/sync-protocols.md +31 -13
  72. package/src/claude/skills/test/SKILL.md +17 -7
  73. package/src/codex/AGENTS.md +6 -0
  74. package/src/codex/hooks/completion-authority-check.cjs +43 -240
  75. package/src/codex/hooks/completion-authority-state.cjs +10 -8
  76. package/src/codex/hooks/completion-authority.cjs +13 -7
  77. package/src/codex/hooks/lib/hook-context.cjs +16 -2
  78. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  79. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  80. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  81. package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
  82. package/src/codex/hooks/lib/spec-utils.cjs +56 -5
  83. package/src/codex/hooks/privacy-approval.cjs +1 -1
  84. package/src/codex/hooks/privacy-block.cjs +67 -11
  85. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  86. package/src/codex/hooks/spec-gate.cjs +49 -34
  87. package/src/codex/hooks/spec-state.cjs +10 -3
  88. package/src/codex/hooks/state.cjs +2 -0
  89. package/src/codex/hooks.json +6 -1
  90. package/src/codex/rules/state-sync.md +47 -27
  91. package/src/common/AGENTS.md +1 -1
@@ -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);
42
39
  }
43
40
 
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: [] };
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
+ }
48
60
 
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) });
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 };
59
82
  }
60
- return { present: true, rows };
61
83
  }
62
84
 
63
- function unsafePath(filePath) {
64
- return path.isAbsolute(filePath)
65
- || /^[A-Za-z]:[\\/]/.test(filePath)
66
- || filePath.split(/[\\/]+/).includes('..');
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
+ }
93
+
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;
108
+ }
109
+ return [];
110
+ }
111
+
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,424 @@ 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 (_) {}
323
+ }
324
+ return scripts;
325
+ }
326
+
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 groundPathTarget(anchor, root, createdTargets, errors) {
347
+ const prefix = `${anchor.label}: ${anchor.id || anchor.type}`;
348
+ const inspected = inspectPath(root, anchor.target);
349
+ if (!inspected.valid) {
350
+ errors.push(`${prefix}: ${anchor.type} target ${anchor.target} ${inspected.reason}`);
351
+ return;
171
352
  }
353
+ const action = intendedAction(anchor);
354
+ if (action === 'create') {
355
+ const parent = inspectPath(root, path.dirname(anchor.target));
356
+ if (!parent.valid || !parent.exists || !parent.stat.isDirectory()) {
357
+ errors.push(`${prefix}: create target parent is not grounded for ${anchor.target}`);
358
+ } else if (inspected.exists) errors.push(`${prefix}: create target already exists: ${anchor.target}`);
359
+ else createdTargets.add(anchor.target);
360
+ } else if (!inspected.exists && !createdTargets.has(anchor.target)) {
361
+ errors.push(`${prefix}: ${action} target not found in work tree: ${anchor.target}`);
362
+ }
363
+ }
172
364
 
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}`);
365
+ function symbolParts(target) {
366
+ const separator = target.lastIndexOf('#');
367
+ if (separator <= 0 || separator === target.length - 1) return null;
368
+ return {
369
+ file: target.slice(0, separator),
370
+ symbol: target.slice(separator + 1).replace(/\(.*\)$/, ''),
371
+ };
372
+ }
373
+
374
+ function sourceBodies(root, excludedDirectories = []) {
375
+ const bodies = [];
376
+ const excluded = excludedDirectories.map((directory) => path.resolve(directory));
377
+ const ignored = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', 'vendor', '.next']);
378
+ function visit(directory, relative, depth) {
379
+ if (depth > 8) return;
380
+ let entries;
381
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch (_) { return; }
382
+ for (const entry of entries) {
383
+ if (entry.isSymbolicLink() || ignored.has(entry.name)) continue;
384
+ const nextRelative = relative ? `${relative}/${entry.name}` : entry.name;
385
+ const target = path.join(directory, entry.name);
386
+ if (excluded.some((excludedDirectory) => target === excludedDirectory || inside(excludedDirectory, target))) continue;
387
+ if (entry.isDirectory()) visit(target, nextRelative, depth + 1);
388
+ else if (/\.(?:[cm]?[jt]sx?|py|go|rs|swift|java|kt|rb|php|sql|graphql|ya?ml|json)$/i.test(entry.name)) {
389
+ try {
390
+ if (fs.statSync(target).size <= 1024 * 1024) bodies.push([nextRelative, fs.readFileSync(target, 'utf8')]);
391
+ } catch (_) {}
219
392
  }
220
393
  }
221
394
  }
395
+ visit(root, '', 0);
396
+ return bodies;
397
+ }
398
+
399
+ function semanticTargetExists(anchor, bodies) {
400
+ const needle = anchor.type === 'route'
401
+ ? anchor.target.replace(/^\s*(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+/i, '')
402
+ : anchor.target;
403
+ return bodies.some(([, body]) => body.includes(needle));
404
+ }
405
+
406
+ function groundCommand(anchor, root, scripts, errors, warnings) {
407
+ const prefix = `${anchor.label}: ${anchor.id || anchor.type}`;
408
+ const script = commandScript(anchor.target);
409
+ if (script === '') errors.push(`${prefix}: command is missing a package script name`);
410
+ else if (script !== null && !scripts.has(script)) errors.push(`${prefix}: package script "${script}" is not declared by project tooling`);
411
+ else if (script === null && !/^(?:node|npx|git|gh|make|pytest|jest|vitest|go|cargo|swift)\b/.test(anchor.target)) {
412
+ errors.push(`${prefix}: command tooling is not deterministically reachable: ${anchor.target}`);
413
+ }
414
+ if (script !== null) return;
415
+ const pathTokens = anchor.target.split(/\s+/).map((token) => token.replace(/^['"]|['";,]$/g, ''))
416
+ .filter((token) => /[\\/]/.test(token) && !token.startsWith('-') && !/^https?:/.test(token));
417
+ for (const candidate of pathTokens) {
418
+ const inspected = inspectPath(root, candidate);
419
+ if (!inspected.valid || !inspected.exists) errors.push(`${prefix}: command target not found in work tree: ${candidate}`);
420
+ }
421
+ void warnings;
422
+ }
423
+
424
+ function groundAnchor(anchor, root, scripts, bodies, createdTargets, reachableAnchors, errors, warnings) {
425
+ const prefix = `${anchor.label}: ${anchor.id || anchor.type}`;
426
+ if (!ANCHOR_TYPES.has(anchor.type)) return;
427
+ if (anchor.type === 'file' || anchor.type === 'artifact') return groundPathTarget(anchor, root, createdTargets, errors);
428
+ if (anchor.type === 'symbol') {
429
+ const parts = symbolParts(anchor.target);
430
+ if (!parts) {
431
+ errors.push(`${prefix}: symbol target must use path/to/file#Symbol`);
432
+ return;
433
+ }
434
+ const inspected = inspectPath(root, parts.file);
435
+ if (!inspected.valid || !inspected.exists || !inspected.stat.isFile()) {
436
+ if (!(intendedAction(anchor) === 'create' && createdTargets.has(parts.file))) {
437
+ errors.push(`${prefix}: containing file not found for symbol ${anchor.target}`);
438
+ }
439
+ return;
440
+ }
441
+ const body = fs.readFileSync(inspected.target, 'utf8');
442
+ const escaped = parts.symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
443
+ const exists = new RegExp(`(?:^|[^A-Za-z0-9_$])${escaped}(?:$|[^A-Za-z0-9_$])`, 'm').test(body);
444
+ if (intendedAction(anchor) === 'create' && exists) errors.push(`${prefix}: create symbol already exists: ${anchor.target}`);
445
+ else if (intendedAction(anchor) !== 'create' && !exists) {
446
+ errors.push(`${prefix}: symbol ${parts.symbol} not found in containing file ${parts.file}`);
447
+ }
448
+ return;
449
+ }
450
+ if (anchor.type === 'command') return groundCommand(anchor, root, scripts, errors, warnings);
451
+ if (['route', 'schema', 'contract'].includes(anchor.type)) {
452
+ const exists = semanticTargetExists(anchor, bodies);
453
+ if (intendedAction(anchor) === 'create' && !reachableAnchors.has(anchor.id)) {
454
+ errors.push(`${prefix}: create ${anchor.type} requires repository entrypoint registration reachability`);
455
+ } else if (intendedAction(anchor) === 'create' && exists) errors.push(`${prefix}: create ${anchor.type} already exists: ${anchor.target}`);
456
+ else if (intendedAction(anchor) !== 'create' && !exists) errors.push(`${prefix}: ${anchor.type} target is not semantically reachable: ${anchor.target}`);
457
+ return;
458
+ }
459
+ if (anchor.type === 'external' && !/^https?:\/\/[^\s]+$/.test(anchor.target)) {
460
+ errors.push(`${prefix}: external target must be one exact http(s) URL`);
461
+ }
462
+ }
463
+
464
+ function canonicalProjectRoot(specDir, requestedRoot = null) {
465
+ const canonicalSpecDir = fs.realpathSync(specDir);
466
+ if (requestedRoot) return fs.realpathSync(requestedRoot);
467
+ const cwd = fs.realpathSync(process.cwd());
468
+ return inside(cwd, canonicalSpecDir) ? cwd : fs.realpathSync(path.resolve(canonicalSpecDir, '..', '..'));
469
+ }
470
+
471
+ function groundSpec({ specDir: inputSpecDir, root: inputRoot = null, spec: inputSpec = null }) {
472
+ const errors = [];
473
+ const warnings = [];
474
+ const specDir = inspectRoot(path.resolve(inputSpecDir), 'spec directory', errors);
475
+ if (!specDir) return { errors, warnings, checked: 0, specDir: null, root: null };
476
+ let root;
477
+ try { root = inspectRoot(canonicalProjectRoot(specDir, inputRoot), 'work context', errors); } catch (error) {
478
+ errors.push(`work context: cannot be resolved (${error.message})`);
479
+ }
480
+ if (!root) return { errors, warnings, checked: 0, specDir, root: null };
481
+ if (!inside(root, specDir)) errors.push('spec directory: must be inside canonical work context');
222
482
 
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;
483
+ let spec = inputSpec;
484
+ if (!isPlainObject(spec)) {
485
+ try { spec = JSON.parse(fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8')); } catch (_) { spec = null; }
486
+ }
487
+ let policy = null;
488
+ if (isPlainObject(spec) && Object.prototype.hasOwnProperty.call(spec, 'workflow_policy')) {
489
+ const validation = POLICY.validateWorkflowPolicySnapshot(spec.workflow_policy);
490
+ if (!validation.valid) errors.push(...validation.errors.map((error) => `spec.json: ${error}`));
491
+ else try { policy = POLICY.readWorkflowPolicySnapshot(spec); } catch (error) { errors.push(`spec.json: ${error.message}`); }
492
+ }
493
+ let designText = '';
494
+ try { designText = fs.readFileSync(path.join(specDir, 'design.md'), 'utf8'); } catch (error) {
495
+ if (spec?.workflow_policy?.version === '2' || spec?.schema_version === '2.1') errors.push(`design.md: cannot be read (${error.message})`);
496
+ }
497
+ const tasks = listTasks(specDir);
498
+ const isV2 = ['2', '2.1'].includes(spec?.workflow_policy?.version);
499
+ const is21 = spec?.schema_version === '2.1';
500
+ let anchors = isV2 ? parseTypedAnchors(designText, 'design.md', true, errors, is21) : [];
501
+ let verificationDefinitions = is21 ? parseVerificationDefinitions(designText, errors) : new Map();
502
+ const taskContents = new Map();
503
+ for (const task of tasks) {
504
+ const content = fs.readFileSync(task.path, 'utf8');
505
+ taskContents.set(task.relative, content);
506
+ if (isV2) anchors.push(...parseTypedAnchors(content, task.relative, false, errors, is21));
507
+ else {
508
+ const legacy = parseLegacyRelatedFiles(content, task.relative);
509
+ if (!legacy.present) errors.push(`${task.relative}: missing Related Files section`);
510
+ else if (legacy.anchors.length === 0) errors.push(`${task.relative}: Related Files section must not be empty`);
511
+ anchors.push(...legacy.anchors);
512
+ }
513
+ }
514
+ if (is21) {
515
+ const projection = SEMANTIC.modelFromMarkdown(specDir, spec);
516
+ errors.push(...projection.errors);
517
+ if (!spec.semantic_model || typeof spec.semantic_model !== 'object' || Array.isArray(spec.semantic_model)) {
518
+ errors.push('spec.json: schema 2.1 grounding requires promoted semantic_model authority');
519
+ } else {
520
+ errors.push(...SEMANTIC.validateSemanticModel(spec.semantic_model, spec));
521
+ if (SEMANTIC.stableJson(spec.semantic_model) !== SEMANTIC.stableJson(projection.model)) {
522
+ errors.push('spec.json: semantic_model differs from authored Markdown projection; rerun spec-readiness with the current semantic review result');
523
+ }
524
+ const labelByTask = new Map(Object.entries(spec.task_registry || {}).map(([taskPath, entry]) => [entry?.id, taskPath]));
525
+ anchors = (spec.semantic_model.anchors || []).map((anchor) => {
526
+ const taskId = anchor.id.match(/^A-(R\d+-\d{2})-/)?.[1];
527
+ return { ...anchor, label: taskId ? (labelByTask.get(taskId) || taskId) : 'design.md', design: !taskId, schema21: true };
528
+ });
529
+ verificationDefinitions = new Map((spec.semantic_model.verification_definitions || []).map((definition) => [definition.id, definition]));
231
530
  }
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;
531
+ }
532
+ if (policy?.planning_depth === 'None') errors.push('spec.json: None planning depth cannot be grounded as a durable spec');
533
+ if (isV2) {
534
+ const tasksRequired = is21 ? tasks.length > 0 : spec?.coordination?.tasks_required === true;
535
+ if (tasksRequired !== (tasks.length > 0)) errors.push('spec.json: coordination.tasks_required must match physical task inventory');
536
+ const byId = new Map();
537
+ const designTargets = new Map();
538
+ for (const anchor of anchors) {
539
+ if (byId.has(anchor.id)) errors.push(`${anchor.label}: duplicate anchor ID ${anchor.id}`);
540
+ else byId.set(anchor.id, anchor);
541
+ if (anchor.design) designTargets.set(`${anchor.type}\0${anchor.target}`, anchor.id);
542
+ else if (designTargets.has(`${anchor.type}\0${anchor.target}`)) {
543
+ errors.push(`${anchor.label}: anchor ${anchor.id} duplicates canonical design target ${designTargets.get(`${anchor.type}\0${anchor.target}`)}; reference the A-D anchor instead`);
544
+ }
235
545
  }
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}`);
546
+ for (const [taskPath, content] of taskContents) for (const reference of content.match(/\bA-D-\d{2}\b/g) || []) {
547
+ if (!byId.has(reference)) errors.push(`${taskPath}: dangling canonical design anchor reference ${reference}`);
239
548
  }
549
+ for (const reference of designText.match(/\bA-D-\d{2}\b/g) || []) if (!byId.has(reference)) {
550
+ errors.push(`design.md: dangling canonical design anchor reference ${reference}`);
551
+ }
552
+ for (const definition of verificationDefinitions.values()) {
553
+ for (const reference of definition.reachability?.anchor_refs || []) {
554
+ if (!byId.has(reference)) errors.push(`design.md: ${definition.id} has dangling reachability anchor ${reference}`);
555
+ }
556
+ }
557
+ }
558
+ const scripts = packageScripts(root);
559
+ const bodies = sourceBodies(root, [specDir]);
560
+ const createdTargets = new Set();
561
+ const reachableAnchors = new Set();
562
+ if (is21) for (const definition of verificationDefinitions.values()) {
563
+ const entrypoint = inspectPath(root, definition.reachability?.entrypoint);
564
+ if (!entrypoint.valid || !entrypoint.exists || !entrypoint.stat.isFile()) {
565
+ errors.push(`design.md: ${definition.id} repository entrypoint not found: ${definition.reachability?.entrypoint || '(empty)'}`);
566
+ } else for (const anchorId of definition.reachability.anchor_refs) reachableAnchors.add(anchorId);
567
+ if (definition.method?.kind === 'command') {
568
+ groundCommand({
569
+ id: definition.id,
570
+ label: 'design.md',
571
+ type: 'command',
572
+ target: definition.method.value,
573
+ }, root, scripts, errors, warnings);
574
+ }
575
+ }
576
+ if (isV2) for (const anchor of anchors) {
577
+ groundAnchor(anchor, root, scripts, bodies, createdTargets, reachableAnchors, errors, warnings);
240
578
  }
579
+ else groundLegacy(anchors, spec?.task_registry, root, errors);
580
+ return { errors, warnings, checked: anchors.length, specDir, root };
581
+ }
241
582
 
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}`);
583
+ function main() {
584
+ let args;
585
+ try { args = parseArgs(process.argv); } catch (error) {
586
+ usage();
587
+ console.error(`- ${error.message}`);
588
+ process.exit(2);
589
+ }
590
+ if (!args.specDir) { usage(); process.exit(2); }
591
+
592
+ const result = groundSpec({
593
+ specDir: path.resolve(process.cwd(), args.specDir),
594
+ root: args.root ? path.resolve(process.cwd(), args.root) : null,
595
+ });
596
+ for (const warning of result.warnings) console.warn(`[WARN] ${warning}`);
597
+ const relativeSpec = result.specDir ? path.relative(process.cwd(), result.specDir) || result.specDir : args.specDir;
598
+ if (result.errors.length > 0) {
599
+ console.error(`GROUNDING_FAIL ${relativeSpec}${result.root ? ` (root: ${result.root})` : ''}`);
600
+ for (const error of result.errors) console.error(`- ${error}`);
247
601
  process.exit(1);
248
602
  }
249
- console.log(`GROUNDED ${relativeSpec} (${checked} path(s) verified against ${root})`);
603
+ console.log(`GROUNDED ${relativeSpec} (${result.checked} typed/legacy anchor(s) checked; semantic and execution proof not claimed)`);
250
604
  }
251
605
 
252
- main();
606
+ module.exports = {
607
+ canonicalProjectRoot,
608
+ groundSpec,
609
+ normalizeCanonicalAccessAction,
610
+ parseCanonicalAnchors,
611
+ parseVerificationDefinitions,
612
+ };
613
+
614
+ if (require.main === module) main();