@haposoft/cafekit 0.16.0-rc.4 → 0.16.0-rc.6
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.
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
- package/bin/__tests__/codex-hooks.test.js +136 -64
- package/bin/__tests__/develop-contract.test.js +55 -17
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
- package/bin/__tests__/package-inventory.test.js +29 -11
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +586 -0
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
- package/bin/__tests__/specs-v2-execution-closeout.test.js +59 -12
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +78 -14
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +222 -42
- package/bin/__tests__/validator-grounding.test.js +103 -4
- package/package.json +3 -2
- package/src/claude/hooks/__tests__/completion-authority.test.js +31 -1
- package/src/claude/hooks/__tests__/privacy-block.test.js +111 -29
- package/src/claude/hooks/privacy-block.cjs +38 -14
- package/src/claude/scripts/change-firewall.cjs +1748 -0
- package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
- package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
- package/src/claude/scripts/spec-final-state.cjs +46 -0
- package/src/claude/scripts/spec-ground.cjs +46 -10
- package/src/claude/scripts/spec-readiness.cjs +324 -17
- package/src/claude/scripts/spec-scaffold.cjs +268 -28
- package/src/claude/scripts/validate-spec-output.cjs +262 -10
- package/src/claude/skills/specs/references/review.md +14 -2
- package/src/claude/skills/specs/templates/spec-state.json +14 -2
- package/src/codex/hooks/lib/privacy-state.cjs +23 -22
- package/src/codex/hooks/privacy-approval.cjs +7 -3
- 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
|
|
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
|
|
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 && !
|
|
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')
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 };
|