@haposoft/cafekit 0.16.0-rc.4 → 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 (28) hide show
  1. package/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
  2. package/bin/__tests__/codex-hooks.test.js +136 -64
  3. package/bin/__tests__/develop-contract.test.js +55 -17
  4. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
  5. package/bin/__tests__/package-inventory.test.js +29 -11
  6. package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
  7. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
  8. package/bin/__tests__/specs-v2-execution-closeout.test.js +59 -12
  9. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +78 -14
  10. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +222 -42
  11. package/bin/__tests__/validator-grounding.test.js +103 -4
  12. package/package.json +5 -2
  13. package/src/claude/hooks/__tests__/completion-authority.test.js +31 -1
  14. package/src/claude/hooks/__tests__/privacy-block.test.js +111 -29
  15. package/src/claude/hooks/privacy-block.cjs +38 -14
  16. package/src/claude/scripts/change-firewall.cjs +1748 -0
  17. package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
  18. package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
  19. package/src/claude/scripts/spec-final-state.cjs +46 -0
  20. package/src/claude/scripts/spec-ground.cjs +46 -10
  21. package/src/claude/scripts/spec-readiness.cjs +324 -17
  22. package/src/claude/scripts/spec-scaffold.cjs +268 -28
  23. package/src/claude/scripts/validate-spec-output.cjs +262 -10
  24. package/src/claude/skills/specs/references/review.md +14 -2
  25. package/src/claude/skills/specs/templates/spec-state.json +14 -2
  26. package/src/codex/hooks/lib/privacy-state.cjs +23 -22
  27. package/src/codex/hooks/privacy-approval.cjs +7 -3
  28. package/src/codex/hooks/privacy-block.cjs +45 -22
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ // Shared C16 freshness primitive (D13). Owned by R0-01. This module is the sole
4
+ // place the raw-byte / stable-JSON digest algorithm for authoring artifacts is
5
+ // implemented; R1-01's spec-authoring-validation.cjs and validate-spec-output.cjs's
6
+ // authorized_evolution/finalizer freshness checks reuse it unmodified (I20).
7
+ //
8
+ // Non-circular: every digest here is computed only from an authored artifact's own
9
+ // bytes (requirements.md, design.md, research.md, task files). None of these ever
10
+ // hash semantic_digest, review_receipt_digest, or the C16 receipt's own bytes.
11
+
12
+ const crypto = require('node:crypto');
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const RESEARCH_ABSENT_DIGEST = 'RESEARCH_ABSENT_DIGEST';
17
+ const TASKS_ABSENT_DIGEST = 'TASKS_ABSENT_DIGEST';
18
+
19
+ function asciiOrdinalCompare(a, b) {
20
+ return a < b ? -1 : a > b ? 1 : 0;
21
+ }
22
+
23
+ // Canonical stable-JSON bytes: object keys sorted by the same ordinal rule
24
+ // (JS's default Array.prototype.sort() on strings already compares by UTF-16
25
+ // code unit, i.e. ASCII ordinal for the ASCII-restricted keys/values in scope
26
+ // here), arrays kept in caller-supplied order. Matches the stableJson
27
+ // convention already used by spec-semantic-model.cjs / validate-spec-output.cjs.
28
+ function stableJson(value) {
29
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
30
+ if (value && typeof value === 'object') {
31
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
32
+ }
33
+ return JSON.stringify(value);
34
+ }
35
+
36
+ function sha256Hex(bytes) {
37
+ return crypto.createHash('sha256').update(bytes).digest('hex');
38
+ }
39
+
40
+ function sha256Tag(bytes) {
41
+ return `sha256:${sha256Hex(bytes)}`;
42
+ }
43
+
44
+ // Raw-byte sha256 of an exact file's current bytes, no normalization. Throws on
45
+ // a missing/non-regular file so callers cannot silently digest a symlink or a
46
+ // directory as if it were authored content.
47
+ function digestFileBytes(absolutePath) {
48
+ const stat = fs.lstatSync(absolutePath);
49
+ if (!stat.isFile()) throw new Error(`${absolutePath} is not a regular file`);
50
+ return sha256Tag(fs.readFileSync(absolutePath));
51
+ }
52
+
53
+ function listTaskFiles(specDir) {
54
+ const tasksDir = path.join(specDir, 'tasks');
55
+ let entries;
56
+ try {
57
+ entries = fs.readdirSync(tasksDir, { withFileTypes: true });
58
+ } catch (error) {
59
+ if (error.code === 'ENOENT') return [];
60
+ throw error;
61
+ }
62
+ return entries
63
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
64
+ .map((entry) => `tasks/${entry.name}`);
65
+ }
66
+
67
+ // sha256 over the stable-JSON canonical bytes of an array of {path, digest}
68
+ // objects, one per task file, sorted by path with the exact ASCII ordinal
69
+ // comparator (never localeCompare) — or the exact sentinel when taskless.
70
+ function computeTasksBundleDigest(specDir) {
71
+ const taskPaths = listTaskFiles(specDir);
72
+ if (taskPaths.length === 0) return TASKS_ABSENT_DIGEST;
73
+ const bundle = taskPaths
74
+ .map((relativePath) => ({
75
+ path: relativePath,
76
+ digest: digestFileBytes(path.join(specDir, relativePath)),
77
+ }))
78
+ .sort((a, b) => asciiOrdinalCompare(a.path, b.path));
79
+ return sha256Tag(Buffer.from(stableJson(bundle), 'utf8'));
80
+ }
81
+
82
+ // Raw-byte sha256 of research.md, or the exact sentinel when absent. Absence is
83
+ // read from the file itself (the durable, unambiguous signal), not from the
84
+ // authoring.research enum, so this never depends on a caller-supplied claim.
85
+ function computeResearchDigest(specDir) {
86
+ const researchPath = path.join(specDir, 'research.md');
87
+ try {
88
+ const stat = fs.lstatSync(researchPath);
89
+ if (!stat.isFile()) return RESEARCH_ABSENT_DIGEST;
90
+ } catch (error) {
91
+ if (error.code === 'ENOENT') return RESEARCH_ABSENT_DIGEST;
92
+ throw error;
93
+ }
94
+ return digestFileBytes(researchPath);
95
+ }
96
+
97
+ // Fresh digests recomputed, at read time, over a spec's exact current authoring
98
+ // bytes — the four C16 stages.
99
+ function computeCurrentDigests(specDir) {
100
+ return {
101
+ requirements: digestFileBytes(path.join(specDir, 'requirements.md')),
102
+ design: digestFileBytes(path.join(specDir, 'design.md')),
103
+ research: computeResearchDigest(specDir),
104
+ tasks: computeTasksBundleDigest(specDir),
105
+ };
106
+ }
107
+
108
+ // I20 exact freshness algorithm: a stage is fresh iff its authoring.<field>
109
+ // enum reads "validated" AND the C16 receipt is present with a stored digest
110
+ // for that field that exactly equals a digest freshly recomputed over the
111
+ // field's current bytes. Every other combination reads as draft.
112
+ function isFieldFresh({ authoringValue, receiptEntry, currentDigest }) {
113
+ if (authoringValue !== 'validated') return false;
114
+ if (!receiptEntry || typeof receiptEntry !== 'object') return false;
115
+ if (typeof receiptEntry.digest !== 'string') return false;
116
+ return receiptEntry.digest === currentDigest;
117
+ }
118
+
119
+ // Convenience wrapper: given a spec.json's own `authoring` and
120
+ // `validation.authoring_validation` (may be absent/null), report freshness for
121
+ // the requested stages against the spec's current on-disk bytes.
122
+ function assertAuthoringFresh({ specDir, authoring, receipt, fields = ['requirements', 'design'] }) {
123
+ const currentDigests = computeCurrentDigests(specDir);
124
+ const report = {};
125
+ for (const field of fields) {
126
+ const authoringValue = authoring && typeof authoring === 'object' ? authoring[field] : undefined;
127
+ const receiptEntry = receipt && typeof receipt === 'object' ? receipt[field] : undefined;
128
+ report[field] = {
129
+ digest: currentDigests[field],
130
+ authoringValue,
131
+ fresh: isFieldFresh({ authoringValue, receiptEntry, currentDigest: currentDigests[field] }),
132
+ };
133
+ }
134
+ return report;
135
+ }
136
+
137
+ module.exports = {
138
+ RESEARCH_ABSENT_DIGEST,
139
+ TASKS_ABSENT_DIGEST,
140
+ asciiOrdinalCompare,
141
+ stableJson,
142
+ sha256Hex,
143
+ sha256Tag,
144
+ digestFileBytes,
145
+ listTaskFiles,
146
+ computeTasksBundleDigest,
147
+ computeResearchDigest,
148
+ computeCurrentDigests,
149
+ isFieldFresh,
150
+ assertAuthoringFresh,
151
+ };
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // C16/D13 sole atomic writer (I21): the only code path permitted to set an
5
+ // authoring stage to `validated` or to write `validation.authoring_validation`.
6
+ // It builds an in-memory candidate spec.json (never persisted mid-run), proves
7
+ // it clean via the canonical validate-spec-output.cjs + spec-ground.cjs pass
8
+ // over that exact candidate, computes the C16 digests via the shared
9
+ // R0-01-owned spec-authoring-digest.cjs primitive, and only on a fully clean
10
+ // pass atomically replaces spec.json (write-temp-then-rename) with the updated
11
+ // `authoring.*` enum values and the fresh receipt written together. A failed,
12
+ // partial, or exceptional run leaves spec.json completely unchanged.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { validateSpec } = require('./validate-spec-output.cjs');
17
+ const { groundSpec } = require('./spec-ground.cjs');
18
+ const DIGEST = require('./spec-authoring-digest.cjs');
19
+
20
+ const AUTHORING_FIELDS = ['requirements', 'design', 'research', 'tasks'];
21
+
22
+ function usage() {
23
+ console.error('Usage: node spec-authoring-validation.cjs specs/<feature> [--root <path>]');
24
+ }
25
+
26
+ function resolveSpecDir(input) {
27
+ const cwd = process.cwd();
28
+ const direct = path.resolve(cwd, input);
29
+ if (fs.existsSync(direct)) return direct;
30
+ const viaSpecs = path.resolve(cwd, 'specs', input);
31
+ if (fs.existsSync(viaSpecs)) return viaSpecs;
32
+ return direct;
33
+ }
34
+
35
+ function isPlainObject(value) {
36
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
37
+ }
38
+
39
+ function cloneJson(value) {
40
+ return JSON.parse(JSON.stringify(value));
41
+ }
42
+
43
+ // In-memory candidate: every non-absent authoring field flips to `validated`
44
+ // and a fresh C16 receipt (all four stages, always present, non-circular —
45
+ // computed only from the authored artifacts' own current bytes) is attached.
46
+ // Both writes happen together in the same candidate object (I21).
47
+ function buildCandidate(currentSpec, specDir, nowIso) {
48
+ if (!isPlainObject(currentSpec.authoring)) throw new Error('spec.json.authoring must be an object');
49
+ const currentDigests = DIGEST.computeCurrentDigests(specDir);
50
+ const candidate = cloneJson(currentSpec);
51
+ const authoring = { ...candidate.authoring };
52
+ for (const field of AUTHORING_FIELDS) {
53
+ if (authoring[field] !== 'absent') authoring[field] = 'validated';
54
+ }
55
+ candidate.authoring = authoring;
56
+ candidate.updated_at = nowIso;
57
+ candidate.validation = {
58
+ ...candidate.validation,
59
+ authoring_validation: {
60
+ schema_version: '1',
61
+ requirements: { digest: currentDigests.requirements, validated_at: nowIso },
62
+ design: { digest: currentDigests.design, validated_at: nowIso },
63
+ research: { digest: currentDigests.research, validated_at: nowIso },
64
+ tasks: { digest: currentDigests.tasks, validated_at: nowIso },
65
+ },
66
+ };
67
+ return candidate;
68
+ }
69
+
70
+ function atomicReplaceSpec(specJsonPath, candidate) {
71
+ const body = `${JSON.stringify(candidate, null, 2)}\n`;
72
+ const tempPath = `${specJsonPath}.spec-authoring-validation-${process.pid}-${Date.now()}.tmp`;
73
+ fs.writeFileSync(tempPath, body);
74
+ fs.renameSync(tempPath, specJsonPath);
75
+ }
76
+
77
+ // Runs the coordinator over one spec directory. Never partially persists: a
78
+ // clean validate+ground pass on the candidate is required before any write.
79
+ function runAuthoringValidation({ specDir, root = null }) {
80
+ const specJsonPath = path.join(specDir, 'spec.json');
81
+ const currentSpec = JSON.parse(fs.readFileSync(specJsonPath, 'utf8'));
82
+ const nowIso = new Date().toISOString();
83
+ const candidate = buildCandidate(currentSpec, specDir, nowIso);
84
+
85
+ const validation = validateSpec(specDir, candidate);
86
+ const grounding = groundSpec({ specDir, root, spec: candidate });
87
+ const errors = [
88
+ ...validation.errors.map((error) => `validate-spec-output: ${error}`),
89
+ ...grounding.errors.map((error) => `spec-ground: ${error}`),
90
+ ];
91
+ if (errors.length > 0) return { ok: false, errors, candidate: null };
92
+
93
+ atomicReplaceSpec(specJsonPath, candidate);
94
+ return { ok: true, errors: [], candidate };
95
+ }
96
+
97
+ function main() {
98
+ const args = process.argv.slice(2);
99
+ const rootFlagIndex = args.indexOf('--root');
100
+ const root = rootFlagIndex >= 0 ? path.resolve(process.cwd(), args[rootFlagIndex + 1] || '') : null;
101
+ const positional = args.filter((arg, index) => arg !== '--root' && (rootFlagIndex < 0 || index !== rootFlagIndex + 1));
102
+ if (positional.length !== 1) { usage(); process.exit(2); }
103
+ const specDir = resolveSpecDir(positional[0]);
104
+ if (!fs.existsSync(path.join(specDir, 'spec.json'))) {
105
+ console.error(`spec-authoring-validation: no spec.json found at ${specDir}`);
106
+ process.exit(2);
107
+ }
108
+ let result;
109
+ try {
110
+ result = runAuthoringValidation({ specDir, root });
111
+ } catch (error) {
112
+ console.error(`AUTHORING_VALIDATION_ERROR ${specDir}`);
113
+ console.error(`- ${error.message}`);
114
+ process.exit(2);
115
+ }
116
+ if (!result.ok) {
117
+ console.error(`AUTHORING_VALIDATION_FAIL ${specDir}`);
118
+ for (const error of result.errors) console.error(`- ${error}`);
119
+ process.exit(1);
120
+ }
121
+ console.log(`AUTHORING_VALIDATED ${specDir}`);
122
+ }
123
+
124
+ module.exports = { runAuthoringValidation, buildCandidate };
125
+
126
+ if (require.main === module) main();
@@ -3,6 +3,7 @@
3
3
  const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const SEMANTIC = require('./spec-semantic-model.cjs');
6
7
 
7
8
  const SHA256_RE = /^sha256:[a-f0-9]{64}$/;
8
9
 
@@ -49,6 +50,49 @@ function validatorApi(validatorPath) {
49
50
  return imported;
50
51
  }
51
52
 
53
+ function stableDigest(value) {
54
+ return `sha256:${crypto.createHash('sha256').update(SEMANTIC.stableJson(value), 'utf8').digest('hex')}`;
55
+ }
56
+
57
+ function validateTerminalSemanticReview(spec, semanticDigest) {
58
+ const review = spec.validation?.semantic_review;
59
+ if (!plain(review) || review.status !== 'completed') {
60
+ return { ok: false, reason: 'canonical schema 2.1 final state requires a completed semantic review receipt' };
61
+ }
62
+ if (review.verdict !== 'PASS' || review.lifecycle_disposition !== 'CONTINUE') {
63
+ return { ok: false, reason: 'canonical schema 2.1 final state requires semantic review PASS CONTINUE' };
64
+ }
65
+ if (!Array.isArray(review.findings) || !Array.isArray(review.unresolved_decisions)) {
66
+ return { ok: false, reason: 'canonical schema 2.1 final state requires canonical semantic review findings and decisions' };
67
+ }
68
+ const blockingCount = review.findings.filter((finding) => finding?.blocking === true).length
69
+ + review.unresolved_decisions.filter((decision) => decision?.blocking === true).length;
70
+ if (blockingCount !== 0) {
71
+ return { ok: false, reason: 'canonical schema 2.1 final state requires zero blocking findings or decisions' };
72
+ }
73
+
74
+ const entries = spec.validation?.semantic_review_history?.entries;
75
+ if (!Array.isArray(entries) || entries.length === 0) {
76
+ return { ok: false, reason: 'canonical schema 2.1 final state requires a latest semantic review history entry' };
77
+ }
78
+ const latest = entries[entries.length - 1];
79
+ if (latest?.verdict !== 'PASS'
80
+ || latest?.lifecycle_disposition !== 'CONTINUE'
81
+ || latest?.blocking_count !== 0) {
82
+ return { ok: false, reason: 'canonical schema 2.1 final state requires latest history PASS CONTINUE with blocking_count 0' };
83
+ }
84
+ if (latest.semantic_digest !== semanticDigest || latest.semantic_digest !== review.semantic_digest) {
85
+ return { ok: false, reason: 'canonical schema 2.1 final state requires latest history and receipt semantic_digest to match' };
86
+ }
87
+ if (!Number.isInteger(review.repair_round) || review.repair_round !== latest.attempt_index) {
88
+ return { ok: false, reason: 'canonical schema 2.1 final state requires receipt repair_round to equal latest history attempt_index' };
89
+ }
90
+ if (latest.review_receipt_digest !== stableDigest(review)) {
91
+ return { ok: false, reason: 'canonical schema 2.1 final state requires latest history review_receipt_digest to match the canonical receipt' };
92
+ }
93
+ return { ok: true };
94
+ }
95
+
52
96
  function isDurableCloseout(spec) {
53
97
  if (!plain(spec)) return false;
54
98
  if (spec.schema_version === '2.1') return spec.status === 'done';
@@ -99,6 +143,8 @@ function validateCanonicalFinalState({ policy, projectRoot, candidate, dependenc
99
143
  return { ok: false, reason: `canonical semantic digest could not be recomputed (${error.message})` };
100
144
  }
101
145
  if (digest !== review.semantic_digest) return { ok: false, reason: 'canonical semantic digest is stale for the current final-state artifacts' };
146
+ const terminalReview = validateTerminalSemanticReview(candidate.spec, digest);
147
+ if (!terminalReview.ok) return { ok: false, reason: terminalReview.reason };
102
148
  if (policy.readWorkflowPolicySnapshot(candidate.spec).assurance_level === 'Strict') {
103
149
  const observed = apis.semanticAuthority.verifyAttestation(projectRoot, candidate.specFile, candidate.featureName, digest);
104
150
  if (!observed?.ok) return { ok: false, reason: `Strict semantic authority requires an allowlisted host-hook-observed reviewer PASS event (${observed?.reason || 'observation unavailable'})` };
@@ -343,7 +343,11 @@ function intendedAction(anchor) {
343
343
  return anchor.schema21 ? anchor.action : legacyIntendedAction(anchor);
344
344
  }
345
345
 
346
- function groundPathTarget(anchor, root, createdTargets, errors) {
346
+ function createTargetMayAlreadyExist(anchor) {
347
+ return anchor.schema21 && ['in_progress', 'done'].includes(anchor.taskStatus);
348
+ }
349
+
350
+ function groundPathTarget(anchor, root, createdTargets, plannedDependencyReads, errors) {
347
351
  const prefix = `${anchor.label}: ${anchor.id || anchor.type}`;
348
352
  const inspected = inspectPath(root, anchor.target);
349
353
  if (!inspected.valid) {
@@ -355,9 +359,11 @@ function groundPathTarget(anchor, root, createdTargets, errors) {
355
359
  const parent = inspectPath(root, path.dirname(anchor.target));
356
360
  if (!parent.valid || !parent.exists || !parent.stat.isDirectory()) {
357
361
  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}`);
362
+ } else if (inspected.exists && !createTargetMayAlreadyExist(anchor)) {
363
+ errors.push(`${prefix}: create target already exists: ${anchor.target}`);
364
+ }
359
365
  else createdTargets.add(anchor.target);
360
- } else if (!inspected.exists && !createdTargets.has(anchor.target)) {
366
+ } else if (!inspected.exists && !plannedDependencyReads.has(`${anchor.label}\0${anchor.target}`)) {
361
367
  errors.push(`${prefix}: ${action} target not found in work tree: ${anchor.target}`);
362
368
  }
363
369
  }
@@ -421,10 +427,12 @@ function groundCommand(anchor, root, scripts, errors, warnings) {
421
427
  void warnings;
422
428
  }
423
429
 
424
- function groundAnchor(anchor, root, scripts, bodies, createdTargets, reachableAnchors, errors, warnings) {
430
+ function groundAnchor(anchor, root, scripts, bodies, createdTargets, plannedDependencyReads, reachableAnchors, errors, warnings) {
425
431
  const prefix = `${anchor.label}: ${anchor.id || anchor.type}`;
426
432
  if (!ANCHOR_TYPES.has(anchor.type)) return;
427
- if (anchor.type === 'file' || anchor.type === 'artifact') return groundPathTarget(anchor, root, createdTargets, errors);
433
+ if (anchor.type === 'file' || anchor.type === 'artifact') {
434
+ return groundPathTarget(anchor, root, createdTargets, plannedDependencyReads, errors);
435
+ }
428
436
  if (anchor.type === 'symbol') {
429
437
  const parts = symbolParts(anchor.target);
430
438
  if (!parts) {
@@ -441,7 +449,9 @@ function groundAnchor(anchor, root, scripts, bodies, createdTargets, reachableAn
441
449
  const body = fs.readFileSync(inspected.target, 'utf8');
442
450
  const escaped = parts.symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
443
451
  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}`);
452
+ if (intendedAction(anchor) === 'create' && exists && !createTargetMayAlreadyExist(anchor)) {
453
+ errors.push(`${prefix}: create symbol already exists: ${anchor.target}`);
454
+ }
445
455
  else if (intendedAction(anchor) !== 'create' && !exists) {
446
456
  errors.push(`${prefix}: symbol ${parts.symbol} not found in containing file ${parts.file}`);
447
457
  }
@@ -452,7 +462,9 @@ function groundAnchor(anchor, root, scripts, bodies, createdTargets, reachableAn
452
462
  const exists = semanticTargetExists(anchor, bodies);
453
463
  if (intendedAction(anchor) === 'create' && !reachableAnchors.has(anchor.id)) {
454
464
  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}`);
465
+ } else if (intendedAction(anchor) === 'create' && exists && !createTargetMayAlreadyExist(anchor)) {
466
+ errors.push(`${prefix}: create ${anchor.type} already exists: ${anchor.target}`);
467
+ }
456
468
  else if (intendedAction(anchor) !== 'create' && !exists) errors.push(`${prefix}: ${anchor.type} target is not semantically reachable: ${anchor.target}`);
457
469
  return;
458
470
  }
@@ -521,10 +533,20 @@ function groundSpec({ specDir: inputSpecDir, root: inputRoot = null, spec: input
521
533
  if (SEMANTIC.stableJson(spec.semantic_model) !== SEMANTIC.stableJson(projection.model)) {
522
534
  errors.push('spec.json: semantic_model differs from authored Markdown projection; rerun spec-readiness with the current semantic review result');
523
535
  }
524
- const labelByTask = new Map(Object.entries(spec.task_registry || {}).map(([taskPath, entry]) => [entry?.id, taskPath]));
536
+ const taskById = new Map(Object.entries(spec.task_registry || {}).map(([taskPath, entry]) => [entry?.id, {
537
+ path: taskPath,
538
+ status: entry?.status,
539
+ }]));
525
540
  anchors = (spec.semantic_model.anchors || []).map((anchor) => {
526
541
  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 };
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
+ };
528
550
  });
529
551
  verificationDefinitions = new Map((spec.semantic_model.verification_definitions || []).map((definition) => [definition.id, definition]));
530
552
  }
@@ -558,6 +580,20 @@ function groundSpec({ specDir: inputSpecDir, root: inputRoot = null, spec: input
558
580
  const scripts = packageScripts(root);
559
581
  const bodies = sourceBodies(root, [specDir]);
560
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
+ }
561
597
  const reachableAnchors = new Set();
562
598
  if (is21) for (const definition of verificationDefinitions.values()) {
563
599
  const entrypoint = inspectPath(root, definition.reachability?.entrypoint);
@@ -574,7 +610,7 @@ function groundSpec({ specDir: inputSpecDir, root: inputRoot = null, spec: input
574
610
  }
575
611
  }
576
612
  if (isV2) for (const anchor of anchors) {
577
- groundAnchor(anchor, root, scripts, bodies, createdTargets, reachableAnchors, errors, warnings);
613
+ groundAnchor(anchor, root, scripts, bodies, createdTargets, plannedDependencyReads, reachableAnchors, errors, warnings);
578
614
  }
579
615
  else groundLegacy(anchors, spec?.task_registry, root, errors);
580
616
  return { errors, warnings, checked: anchors.length, specDir, root };