@haposoft/cafekit 0.16.0-rc.3 → 0.16.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
- package/bin/__tests__/codex-hooks.test.js +531 -29
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +653 -185
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
- package/bin/__tests__/installer-safety.test.js +39 -0
- package/bin/__tests__/opencode-spec-gate.test.js +24 -16
- package/bin/__tests__/package-inventory.test.js +705 -13
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
- package/bin/__tests__/specs-v2-execution-closeout.test.js +468 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +665 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +1132 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +2013 -221
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +5 -2
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +143 -99
- package/src/claude/hooks/__tests__/completion-authority.test.js +321 -58
- package/src/claude/hooks/__tests__/privacy-block.test.js +254 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
- package/src/claude/hooks/completion-authority-check.cjs +37 -288
- package/src/claude/hooks/completion-authority-state.cjs +10 -8
- package/src/claude/hooks/completion-authority.cjs +13 -7
- package/src/claude/hooks/docs-sync.cjs +5 -2
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
- package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
- package/src/claude/hooks/privacy-block.cjs +99 -28
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/spec-gate.cjs +47 -117
- package/src/claude/hooks/spec-state.cjs +8 -2
- package/src/claude/migration-manifest.json +9 -1
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- 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 +290 -0
- package/src/claude/scripts/spec-ground.cjs +571 -173
- package/src/claude/scripts/spec-readiness.cjs +425 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +67 -16
- package/src/claude/scripts/spec-scaffold.cjs +1594 -139
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +3110 -218
- package/src/claude/scripts/workflow-policy.cjs +501 -183
- package/src/claude/settings/settings.json +4 -0
- package/src/claude/skills/code-review/SKILL.md +12 -3
- package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
- package/src/claude/skills/code-review/references/verification-gate.md +13 -0
- package/src/claude/skills/develop/SKILL.md +47 -60
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +19 -12
- package/src/claude/skills/specs/SKILL.md +183 -177
- package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
- package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
- package/src/claude/skills/specs/references/research-strategy.md +4 -1
- package/src/claude/skills/specs/references/review.md +214 -262
- package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
- package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
- package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
- package/src/claude/skills/specs/rules/design-principles.md +136 -180
- package/src/claude/skills/specs/rules/design-review.md +42 -97
- package/src/claude/skills/specs/rules/ears-format.md +42 -50
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
- package/src/claude/skills/specs/templates/design.md +49 -263
- package/src/claude/skills/specs/templates/requirements.md +26 -36
- package/src/claude/skills/specs/templates/research.md +9 -98
- package/src/claude/skills/specs/templates/spec-state.json +35 -85
- package/src/claude/skills/specs/templates/task.md +30 -102
- package/src/claude/skills/sync/SKILL.md +33 -10
- package/src/claude/skills/sync/references/sync-protocols.md +31 -13
- package/src/claude/skills/test/SKILL.md +17 -7
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +43 -240
- package/src/codex/hooks/completion-authority-state.cjs +10 -8
- package/src/codex/hooks/completion-authority.cjs +13 -7
- package/src/codex/hooks/lib/hook-context.cjs +16 -2
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +61 -38
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
- package/src/codex/hooks/lib/spec-utils.cjs +56 -5
- package/src/codex/hooks/privacy-approval.cjs +6 -2
- package/src/codex/hooks/privacy-block.cjs +94 -15
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/spec-gate.cjs +49 -34
- package/src/codex/hooks/spec-state.cjs +10 -3
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +6 -1
- package/src/codex/rules/state-sync.md +47 -27
- package/src/common/AGENTS.md +1 -1
|
@@ -13,21 +13,53 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Usage:
|
|
15
15
|
* node spec-scaffold.cjs <feature> [--tasks "R0-01-slug,R1-01-slug,..."] \
|
|
16
|
-
* [--
|
|
16
|
+
* [--boundaries '[{"id":"B-OWN","type":"ownership",...}]'] \
|
|
17
|
+
* [--lane Standard|Critical] [--planning-depth None|Compact|Full] \
|
|
18
|
+
* [--assurance-level Routine|Elevated|Strict] [--research] [--risks auth,privacy] \
|
|
17
19
|
* [--lang en] [--title "..."] [--specs-root specs] \
|
|
18
|
-
* [--artifacts '{"R1-01-slug":["dist/output.js"]}']
|
|
20
|
+
* [--artifacts '{"R1-01-slug":["dist/output.js"]}'] \
|
|
21
|
+
* [--dependencies '{"R1-02-consumer":["R1-01-owner"]}']
|
|
19
22
|
*
|
|
20
23
|
* Exit: 0 = scaffolded, 2 = usage/precondition error.
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
const fs = require('fs');
|
|
24
27
|
const path = require('path');
|
|
28
|
+
const crypto = require('crypto');
|
|
25
29
|
const POLICY = require('./workflow-policy.cjs');
|
|
30
|
+
const SEMANTIC = require('./spec-semantic-model.cjs');
|
|
31
|
+
const AUTHORING_DIGEST = require('./spec-authoring-digest.cjs');
|
|
32
|
+
const PATH_SAFETY = require('../hooks/lib/runtime-path-safety.cjs');
|
|
26
33
|
|
|
27
34
|
const TEMPLATES = path.join(__dirname, '..', 'skills', 'specs', 'templates');
|
|
28
|
-
|
|
35
|
+
|
|
36
|
+
function resolveTemplatePath(name) {
|
|
37
|
+
const adapterRoot = path.resolve(__dirname, '..');
|
|
38
|
+
const projectRoot = path.resolve(adapterRoot, '..');
|
|
39
|
+
const candidates = [
|
|
40
|
+
{ file: path.join(TEMPLATES, name), root: adapterRoot },
|
|
41
|
+
// Packed Codex: <project>/.codex/scripts/spec-scaffold.cjs -> <project>/.agents/skills/specs/templates
|
|
42
|
+
{
|
|
43
|
+
file: path.resolve(__dirname, '..', '..', '.agents', 'skills', 'specs', 'templates', name),
|
|
44
|
+
root: projectRoot,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
for (const candidate of candidates) {
|
|
48
|
+
try {
|
|
49
|
+
return PATH_SAFETY.canonicalRegularFile(candidate.root, candidate.file, 'spec template');
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e.code !== 'ENOENT' && !/cannot be inspected \(ENOENT\)/.test(e.message)) {
|
|
52
|
+
throw new Error(`template check failed: ${e.message} (${candidate.file})`);
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`template not found: ${name} (checked ${candidates.map((item) => item.file).join(', ')})`);
|
|
58
|
+
}
|
|
59
|
+
const TASK_ID_RE = /^R(\d+)-(\d{2})-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
29
60
|
const TASK_PATH_RE = /^tasks\/task-(R\d+-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
|
|
30
61
|
const FEATURE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
62
|
+
const EXECUTION_CLOSURE_BLOCK_RE = /<!-- EXECUTION_CLOSURE_START -->[\s\S]*?<!-- EXECUTION_CLOSURE_END -->\n?/g;
|
|
31
63
|
const REGISTRY_KEYS = [
|
|
32
64
|
'id',
|
|
33
65
|
'title',
|
|
@@ -38,11 +70,72 @@ const REGISTRY_KEYS = [
|
|
|
38
70
|
'completed_at',
|
|
39
71
|
'last_updated_at',
|
|
40
72
|
];
|
|
73
|
+
const AUTHORING_FIELDS = ['requirements', 'design', 'research', 'tasks'];
|
|
74
|
+
const AUTHORING_STATES = new Set(['draft', 'validated', 'absent']);
|
|
75
|
+
const SPEC_STATUS_VALUES = new Set(['in_progress', 'paused', 'blocked', 'done']);
|
|
76
|
+
const TASK_STATUS_VALUES = new Set(['pending', 'in_progress', 'blocked', 'done']);
|
|
77
|
+
const VALIDATION_STATUS_VALUES = new Set(['not-run', 'in_progress', 'completed']);
|
|
78
|
+
const CANONICAL_SPEC_FIELDS = new Set([
|
|
79
|
+
'schema_version', 'feature_name', 'created_at', 'updated_at', 'language', 'status',
|
|
80
|
+
'scope_lock', 'authoring', 'coordination', 'validation', 'semantic_model', 'ready_for_implementation',
|
|
81
|
+
'workflow_policy', 'research', 'task_files', 'task_registry', 'decisions',
|
|
82
|
+
]);
|
|
83
|
+
const SCOPE_LOCK_FIELDS = ['source', 'in_scope', 'out_of_scope', 'expansion_policy'];
|
|
84
|
+
const COORDINATION_FIELDS = new Set(['boundaries', 'phases']);
|
|
85
|
+
// Schema 2.0 legacy shapes (pre-Specs-v2.1 migration input) are frozen as-is;
|
|
86
|
+
// they never gain the C2/C13/C16 fields below, which are canonical-2.1-only.
|
|
87
|
+
const LEGACY_VALIDATION_FIELDS = ['status', 'semantic_review'];
|
|
88
|
+
const LEGACY_SEMANTIC_REVIEW_FIELDS = ['status', 'semantic_digest', 'reviewed_criteria', 'counterexamples', 'reviewed_artifact_digest'];
|
|
89
|
+
// Canonical schema 2.1 (R1-01, frozen C2/C13/C16).
|
|
90
|
+
const VALIDATION_FIELDS = ['status', 'semantic_review', 'semantic_review_history', 'authoring_validation'];
|
|
91
|
+
const PRE_C2_SEMANTIC_REVIEW_FIELDS = ['status', 'semantic_digest', 'reviewed_criteria', 'counterexamples'];
|
|
92
|
+
const SEMANTIC_REVIEW_FIELDS = [
|
|
93
|
+
'status', 'semantic_digest', 'verdict', 'lifecycle_disposition', 'findings',
|
|
94
|
+
'unresolved_decisions', 'graph_coverage', 'repair_round', 'reviewed_criteria',
|
|
95
|
+
'counterexamples', 'reviewer_evidence',
|
|
96
|
+
];
|
|
97
|
+
const SEMANTIC_REVIEW_FINDING_FIELDS = ['id', 'severity', 'location', 'summary', 'blocking', 'disposition'];
|
|
98
|
+
const SEMANTIC_REVIEW_DECISION_FIELDS = ['id', 'summary', 'blocking'];
|
|
99
|
+
const SEMANTIC_REVIEW_COVERAGE_FIELDS = ['surface', 'covered', 'notes'];
|
|
100
|
+
const SEMANTIC_REVIEW_EVIDENCE_FIELDS = ['assurance', 'summary'];
|
|
101
|
+
const SEMANTIC_REVIEW_HISTORY_FIELDS = ['lineage_id', 'entries'];
|
|
102
|
+
const SEMANTIC_REVIEW_HISTORY_ENTRY_FIELDS = [
|
|
103
|
+
'sequence', 'semantic_digest', 'review_receipt_digest', 'verdict',
|
|
104
|
+
'lifecycle_disposition', 'blocking_count', 'attempt_index', 'review_epoch',
|
|
105
|
+
];
|
|
106
|
+
const AUTHORING_VALIDATION_FIELDS = ['schema_version', 'requirements', 'design', 'research', 'tasks'];
|
|
107
|
+
const AUTHORING_VALIDATION_STAGE_FIELDS = ['digest', 'validated_at'];
|
|
108
|
+
const LEGACY_SPEC_FIELDS = [
|
|
109
|
+
...CANONICAL_SPEC_FIELDS,
|
|
110
|
+
'approvals', 'current_phase', 'override_receipt', 'timestamps',
|
|
111
|
+
];
|
|
112
|
+
const LEGACY_COORDINATION_FIELDS = [
|
|
113
|
+
...COORDINATION_FIELDS,
|
|
114
|
+
'tasks_required', 'phases_required', 'reason', 'task_triggers',
|
|
115
|
+
];
|
|
116
|
+
const LEGACY_TIMESTAMP_FIELDS = [
|
|
117
|
+
'init', 'requirements_done', 'research_done', 'design_done', 'tasks_done',
|
|
118
|
+
'code_done', 'test_done', 'review_done', 'validation_done',
|
|
119
|
+
];
|
|
120
|
+
const LEGACY_APPROVAL_FIELDS = ['requirements', 'design', 'research', 'tasks'];
|
|
121
|
+
const LEGACY_APPROVAL_STATE_FIELDS = ['generated', 'agent_validated', 'user_approved'];
|
|
122
|
+
const LEGACY_POLICY_FIELDS = [
|
|
123
|
+
'version', 'planning_depth', 'automatic_planning_depth', 'assurance_level',
|
|
124
|
+
'automatic_assurance_level', 'lane', 'automatic_lane', 'risks', 'artifact_profile',
|
|
125
|
+
'planning_obligations', 'proof_obligations', 'actor_needs', 'classified_minimum', 'override_receipt',
|
|
126
|
+
];
|
|
127
|
+
const TRANSACTION_PREFIX = '.cafekit-scaffold-tx-';
|
|
128
|
+
const TRANSACTION_FORMAT = 'cafekit-spec-scaffold-transaction/1';
|
|
41
129
|
|
|
42
130
|
class ScaffoldPreconditionError extends Error {}
|
|
43
131
|
|
|
132
|
+
const USAGE_TEXT = 'Usage: node spec-scaffold.cjs <feature> [--sync-semantic-model] [--tasks "R0-01-slug,R1-01-slug" --boundaries \'[{"id":"B-OWN","type":"ownership",...}]\'] [--lane Standard|Critical] [--planning-depth Compact|Full] [--assurance-level Routine|Elevated|Strict] [--research --uncertainty "..."] [--phases \'[{"id":"phase-1","task_ids":["R1-01"],"entry_condition":"...","exit_condition":"...","owner_boundary":"B-OWN"}]\'] [--risks auth,privacy] [--lang en] [--title "..."] [--specs-root specs]';
|
|
133
|
+
|
|
134
|
+
// Usage reaches stderr when it accompanies a precondition failure, and stdout
|
|
135
|
+
// when the caller asked for it with --help; a requested help text is output,
|
|
136
|
+
// not a diagnostic.
|
|
44
137
|
function usage() {
|
|
45
|
-
console.error(
|
|
138
|
+
console.error(USAGE_TEXT);
|
|
46
139
|
}
|
|
47
140
|
|
|
48
141
|
function failPrecondition(message) {
|
|
@@ -53,17 +146,19 @@ function observeInstalledPolicyBaseline({ args, specDir, spec }) {
|
|
|
53
146
|
const adapterDir = path.dirname(__dirname);
|
|
54
147
|
const adapterName = path.basename(adapterDir);
|
|
55
148
|
if (path.basename(__dirname) !== 'scripts' || (adapterName !== '.claude' && adapterName !== '.codex')) return;
|
|
56
|
-
|
|
57
149
|
const statePath = path.join(adapterDir, 'hooks', 'completion-authority-state.cjs');
|
|
58
150
|
const checkPath = path.join(adapterDir, 'hooks', 'completion-authority-check.cjs');
|
|
59
151
|
if (![statePath, checkPath].every((filePath) => {
|
|
60
152
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
153
|
+
const stat = fs.lstatSync(filePath);
|
|
154
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return false;
|
|
155
|
+
const real = fs.realpathSync(filePath);
|
|
156
|
+
const realDir = fs.realpathSync(path.dirname(filePath));
|
|
157
|
+
const rel = path.relative(realDir, real);
|
|
158
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
|
159
|
+
return true;
|
|
160
|
+
} catch { return false; }
|
|
65
161
|
})) return;
|
|
66
|
-
|
|
67
162
|
let installedRoot;
|
|
68
163
|
let scaffoldCwd;
|
|
69
164
|
let specFile;
|
|
@@ -78,54 +173,93 @@ function observeInstalledPolicyBaseline({ args, specDir, spec }) {
|
|
|
78
173
|
} catch (error) {
|
|
79
174
|
throw new Error(`installed ${adapterName} scaffold authority baseline could not be verified: ${error.message}`);
|
|
80
175
|
}
|
|
81
|
-
|
|
82
176
|
let STATE;
|
|
83
177
|
let check;
|
|
178
|
+
let runtime = {};
|
|
84
179
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
180
|
+
const realStatePath = fs.realpathSync(statePath);
|
|
181
|
+
const realCheckPath = fs.realpathSync(checkPath);
|
|
182
|
+
// Ensure real paths are still inside adapterDir's realpath (no symlink escape)
|
|
183
|
+
const realAdapterDir = fs.realpathSync(adapterDir);
|
|
184
|
+
const relState = path.relative(realAdapterDir, realStatePath);
|
|
185
|
+
const relCheck = path.relative(realAdapterDir, realCheckPath);
|
|
186
|
+
if (relState.startsWith('..') || path.isAbsolute(relState) || relCheck.startsWith('..') || path.isAbsolute(relCheck)) {
|
|
187
|
+
throw new Error('scaffold authority path escapes adapter');
|
|
188
|
+
}
|
|
189
|
+
STATE = require(realStatePath);
|
|
190
|
+
check = require(realCheckPath);
|
|
191
|
+
const runtimePath = path.join(installedRoot, adapterName, 'runtime.json');
|
|
192
|
+
if (fs.existsSync(runtimePath)) runtime = JSON.parse(fs.readFileSync(runtimePath, 'utf8'));
|
|
87
193
|
} catch (error) {
|
|
88
194
|
throw new Error(`installed ${adapterName} scaffold authority hooks are unavailable: ${error.message}`);
|
|
89
195
|
}
|
|
90
|
-
if (typeof check.observePolicyBaseline !== 'function') {
|
|
91
|
-
throw new Error(`installed ${adapterName} scaffold authority check does not expose observePolicyBaseline`);
|
|
92
|
-
}
|
|
93
|
-
|
|
196
|
+
if (typeof check.observePolicyBaseline !== 'function') throw new Error(`installed ${adapterName} scaffold authority check does not expose observePolicyBaseline`);
|
|
94
197
|
let result;
|
|
95
198
|
try {
|
|
96
|
-
result = check.observePolicyBaseline({
|
|
97
|
-
STATE,
|
|
98
|
-
policy: POLICY,
|
|
99
|
-
projectRoot: installedRoot,
|
|
100
|
-
candidate: { spec, specFile, featureName: args.feature },
|
|
101
|
-
});
|
|
199
|
+
result = check.observePolicyBaseline({ STATE, policy: POLICY, projectRoot: installedRoot, candidate: { spec, specFile, featureName: args.feature }, runtime });
|
|
102
200
|
} catch (error) {
|
|
103
201
|
throw new Error(`installed ${adapterName} scaffold authority baseline failed: ${error.message}`);
|
|
104
202
|
}
|
|
105
|
-
if (!result || result.ok !== true) {
|
|
106
|
-
throw new Error(`installed ${adapterName} scaffold authority baseline rejected the spec: ${result?.reason || 'unknown reason'}`);
|
|
107
|
-
}
|
|
203
|
+
if (!result || result.ok !== true) throw new Error(`installed ${adapterName} scaffold authority baseline rejected the spec: ${result?.reason || 'unknown reason'}`);
|
|
108
204
|
}
|
|
109
205
|
|
|
110
206
|
function parseArgs(argv) {
|
|
111
|
-
const a = {
|
|
207
|
+
const a = {
|
|
208
|
+
feature: null,
|
|
209
|
+
tasks: null,
|
|
210
|
+
lane: null,
|
|
211
|
+
risks: null,
|
|
212
|
+
lang: 'en',
|
|
213
|
+
title: null,
|
|
214
|
+
specsRoot: 'specs',
|
|
215
|
+
tasksOnly: false,
|
|
216
|
+
artifacts: null,
|
|
217
|
+
dependencies: null,
|
|
218
|
+
planningDepth: null,
|
|
219
|
+
assuranceLevel: null,
|
|
220
|
+
coordination: false,
|
|
221
|
+
taskTriggers: null,
|
|
222
|
+
boundaries: null,
|
|
223
|
+
phases: null,
|
|
224
|
+
research: false,
|
|
225
|
+
uncertainty: null,
|
|
226
|
+
syncSemanticModel: false,
|
|
227
|
+
};
|
|
228
|
+
let dependenciesSeen = false;
|
|
112
229
|
for (let i = 2; i < argv.length; i++) {
|
|
113
230
|
const v = argv[i];
|
|
114
231
|
if (v === '--tasks') a.tasks = argv[++i];
|
|
115
232
|
else if (v === '--lane') a.lane = argv[++i];
|
|
233
|
+
else if (v === '--planning-depth') a.planningDepth = argv[++i];
|
|
234
|
+
else if (v === '--assurance-level') a.assuranceLevel = argv[++i];
|
|
235
|
+
else if (v === '--coordination') a.coordination = true;
|
|
236
|
+
else if (v === '--task-triggers') a.taskTriggers = argv[++i];
|
|
237
|
+
else if (v === '--boundaries') a.boundaries = argv[++i];
|
|
238
|
+
else if (v === '--phases') a.phases = argv[++i];
|
|
239
|
+
else if (v === '--research') a.research = true;
|
|
240
|
+
else if (v === '--uncertainty') a.uncertainty = argv[++i];
|
|
116
241
|
else if (v === '--risks') a.risks = argv[++i];
|
|
117
242
|
else if (v === '--lang') a.lang = argv[++i];
|
|
118
243
|
else if (v === '--title') a.title = argv[++i];
|
|
119
244
|
else if (v === '--specs-root') a.specsRoot = argv[++i];
|
|
120
245
|
else if (v === '--tasks-only') a.tasksOnly = true;
|
|
246
|
+
else if (v === '--sync-semantic-model') a.syncSemanticModel = true;
|
|
121
247
|
else if (v === '--artifacts') a.artifacts = argv[++i];
|
|
248
|
+
else if (v === '--dependencies') {
|
|
249
|
+
if (dependenciesSeen) failPrecondition('--dependencies may be provided only once');
|
|
250
|
+
dependenciesSeen = true;
|
|
251
|
+
if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) {
|
|
252
|
+
failPrecondition('--dependencies requires one JSON object value');
|
|
253
|
+
}
|
|
254
|
+
a.dependencies = argv[++i];
|
|
255
|
+
}
|
|
122
256
|
else if (!a.feature) a.feature = v;
|
|
123
257
|
}
|
|
124
258
|
return a;
|
|
125
259
|
}
|
|
126
260
|
|
|
127
261
|
function readTemplate(name) {
|
|
128
|
-
const p =
|
|
262
|
+
const p = resolveTemplatePath(name);
|
|
129
263
|
if (!fs.existsSync(p)) {
|
|
130
264
|
failPrecondition(`template not found: ${p}`);
|
|
131
265
|
}
|
|
@@ -137,12 +271,25 @@ function titleFromSlug(slug) {
|
|
|
137
271
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
138
272
|
}
|
|
139
273
|
|
|
140
|
-
function
|
|
274
|
+
function concreteUncertainty(value) {
|
|
275
|
+
return typeof value === 'string'
|
|
276
|
+
&& value === value.trim()
|
|
277
|
+
&& value.length >= 12
|
|
278
|
+
&& !/^(?:tbd|todo|unknown|n\/a|none|placeholder)$/i.test(value);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderResearchTemplate(feature, uncertainty) {
|
|
282
|
+
return readTemplate('research.md')
|
|
283
|
+
.replace(/\{\{FEATURE_NAME\}\}/g, feature)
|
|
284
|
+
.replace(/\{\{UNCERTAINTY\}\}/g, uncertainty);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function registryEntry(t, artifactDeclarations, dependencies = []) {
|
|
141
288
|
const entry = {
|
|
142
289
|
id: `R${t.req}-${t.seq}`,
|
|
143
290
|
title: titleFromSlug(t.slug),
|
|
144
291
|
status: 'pending',
|
|
145
|
-
dependencies: [],
|
|
292
|
+
dependencies: [...dependencies],
|
|
146
293
|
blocker: null,
|
|
147
294
|
started_at: null,
|
|
148
295
|
completed_at: null,
|
|
@@ -153,6 +300,39 @@ function registryEntry(t, artifactDeclarations) {
|
|
|
153
300
|
return entry;
|
|
154
301
|
}
|
|
155
302
|
|
|
303
|
+
function pendingApproval() {
|
|
304
|
+
return 'draft';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// C2 SemanticReviewReceipt (R1-01, frozen): not-run keeps every receipt field
|
|
308
|
+
// null/empty, including the separate verdict and lifecycle_disposition and
|
|
309
|
+
// the finalizer-derived repair_round attempt index.
|
|
310
|
+
function semanticReviewNotRun() {
|
|
311
|
+
return {
|
|
312
|
+
status: 'not-run',
|
|
313
|
+
semantic_digest: null,
|
|
314
|
+
verdict: null,
|
|
315
|
+
lifecycle_disposition: null,
|
|
316
|
+
findings: [],
|
|
317
|
+
unresolved_decisions: [],
|
|
318
|
+
graph_coverage: [],
|
|
319
|
+
repair_round: 0,
|
|
320
|
+
reviewed_criteria: [],
|
|
321
|
+
counterexamples: [],
|
|
322
|
+
reviewer_evidence: null,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// C13 SemanticReviewHistory (R1-01, frozen): lineage_id is derived once, from
|
|
327
|
+
// the immutable feature_name/created_at pair, and never set by a caller.
|
|
328
|
+
function computeLineageId(featureName, createdAt) {
|
|
329
|
+
return `sha256:${crypto.createHash('sha256').update(SEMANTIC.stableJson({ feature_name: featureName, created_at: createdAt })).digest('hex')}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function semanticReviewHistoryInitial(featureName, createdAt) {
|
|
333
|
+
return { lineage_id: computeLineageId(featureName, createdAt), entries: [] };
|
|
334
|
+
}
|
|
335
|
+
|
|
156
336
|
function parseArtifactDeclarations(raw, parsedTasks) {
|
|
157
337
|
if (raw === null || raw === undefined) return new Map();
|
|
158
338
|
let declarations;
|
|
@@ -188,15 +368,344 @@ function parseArtifactDeclarations(raw, parsedTasks) {
|
|
|
188
368
|
return result;
|
|
189
369
|
}
|
|
190
370
|
|
|
191
|
-
function
|
|
192
|
-
return
|
|
371
|
+
function parseDependencyDeclarations(raw, parsedTasks, knownTaskFiles, existingRegistry = {}) {
|
|
372
|
+
if (raw === null || raw === undefined) return new Map();
|
|
373
|
+
let declarations;
|
|
374
|
+
try {
|
|
375
|
+
declarations = JSON.parse(raw);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
failPrecondition(`--dependencies must be valid JSON (${error.message})`);
|
|
378
|
+
}
|
|
379
|
+
if (!isPlainObject(declarations)) {
|
|
380
|
+
failPrecondition('--dependencies must be an object keyed by requested task id or task path');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const requested = new Map();
|
|
384
|
+
for (const task of parsedTasks) {
|
|
385
|
+
requested.set(task.raw, task);
|
|
386
|
+
requested.set(task.file, task);
|
|
387
|
+
}
|
|
388
|
+
const known = new Map();
|
|
389
|
+
for (const taskPath of knownTaskFiles) {
|
|
390
|
+
const identity = parseTaskPath(taskPath, `known task ${taskPath}`);
|
|
391
|
+
known.set(taskPath, taskPath);
|
|
392
|
+
known.set(identity.rawId, taskPath);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const result = new Map();
|
|
396
|
+
const declaredTargets = new Map();
|
|
397
|
+
for (const [key, dependencyRefs] of Object.entries(declarations)) {
|
|
398
|
+
const task = requested.get(key);
|
|
399
|
+
if (!task) failPrecondition(`--dependencies references unknown requested task "${key}"`);
|
|
400
|
+
const previousKey = declaredTargets.get(task.file);
|
|
401
|
+
if (previousKey) {
|
|
402
|
+
failPrecondition(
|
|
403
|
+
`--dependencies declares task "${task.file}" more than once after canonical resolution ` +
|
|
404
|
+
`("${previousKey}" and "${key}")`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
declaredTargets.set(task.file, key);
|
|
408
|
+
if (!Array.isArray(dependencyRefs)) {
|
|
409
|
+
failPrecondition(`--dependencies for "${key}" must be an array`);
|
|
410
|
+
}
|
|
411
|
+
const dependencies = [];
|
|
412
|
+
const seen = new Set();
|
|
413
|
+
for (const dependencyRef of dependencyRefs) {
|
|
414
|
+
if (typeof dependencyRef !== 'string') {
|
|
415
|
+
failPrecondition(`--dependencies for "${key}" must contain only task ids or task paths`);
|
|
416
|
+
}
|
|
417
|
+
const dependency = known.get(dependencyRef);
|
|
418
|
+
if (!dependency) {
|
|
419
|
+
failPrecondition(`--dependencies for "${key}" references unknown task "${dependencyRef}"`);
|
|
420
|
+
}
|
|
421
|
+
if (dependency === task.file) {
|
|
422
|
+
failPrecondition(`--dependencies for "${key}" cannot contain itself`);
|
|
423
|
+
}
|
|
424
|
+
if (seen.has(dependency)) {
|
|
425
|
+
failPrecondition(`--dependencies for "${key}" must not contain duplicate edges`);
|
|
426
|
+
}
|
|
427
|
+
seen.add(dependency);
|
|
428
|
+
dependencies.push(dependency);
|
|
429
|
+
}
|
|
430
|
+
result.set(task.file, dependencies);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const candidateRegistry = {};
|
|
434
|
+
for (const taskPath of knownTaskFiles) {
|
|
435
|
+
candidateRegistry[taskPath] = {
|
|
436
|
+
dependencies: [...(existingRegistry[taskPath]?.dependencies || [])],
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
for (const task of parsedTasks) {
|
|
440
|
+
if (!candidateRegistry[task.file]) candidateRegistry[task.file] = { dependencies: [] };
|
|
441
|
+
}
|
|
442
|
+
for (const [taskPath, dependencies] of result) {
|
|
443
|
+
candidateRegistry[taskPath].dependencies = dependencies;
|
|
444
|
+
}
|
|
445
|
+
validateDependencyCycles(knownTaskFiles, candidateRegistry);
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function parsePhaseDeclarations(raw, knownTaskFiles) {
|
|
450
|
+
if (raw === null) return [];
|
|
451
|
+
let phases;
|
|
452
|
+
try {
|
|
453
|
+
phases = JSON.parse(raw);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
failPrecondition(`--phases must be valid JSON (${error.message})`);
|
|
456
|
+
}
|
|
457
|
+
if (!Array.isArray(phases) || phases.length === 0) {
|
|
458
|
+
failPrecondition('--phases must be a non-empty JSON array');
|
|
459
|
+
}
|
|
460
|
+
const requiredKeys = ['entry_condition', 'exit_condition', 'id', 'owner_boundary', 'task_ids'];
|
|
461
|
+
const knownTaskIds = new Set(knownTaskFiles.map((taskPath) => parseTaskPath(taskPath, `known task ${taskPath}`).id));
|
|
462
|
+
const phaseIds = new Set();
|
|
463
|
+
return phases.map((phase, index) => {
|
|
464
|
+
const label = `--phases[${index}]`;
|
|
465
|
+
if (!isPlainObject(phase) || JSON.stringify(Object.keys(phase).sort()) !== JSON.stringify(requiredKeys)) {
|
|
466
|
+
failPrecondition(`${label} must contain exactly ${requiredKeys.join(', ')}`);
|
|
467
|
+
}
|
|
468
|
+
for (const key of ['id', 'entry_condition', 'exit_condition', 'owner_boundary']) {
|
|
469
|
+
if (typeof phase[key] !== 'string' || phase[key].trim() === '' || phase[key] !== phase[key].trim()) {
|
|
470
|
+
failPrecondition(`${label}.${key} must be a non-empty trimmed string`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(phase.id)) {
|
|
474
|
+
failPrecondition(`${label}.id must be one safe identifier`);
|
|
475
|
+
}
|
|
476
|
+
if (phaseIds.has(phase.id)) failPrecondition(`--phases contains duplicate id ${phase.id}`);
|
|
477
|
+
phaseIds.add(phase.id);
|
|
478
|
+
if (!Array.isArray(phase.task_ids) || phase.task_ids.length === 0) {
|
|
479
|
+
failPrecondition(`${label}.task_ids must be a non-empty array`);
|
|
480
|
+
}
|
|
481
|
+
const taskIds = new Set();
|
|
482
|
+
for (const taskId of phase.task_ids) {
|
|
483
|
+
if (typeof taskId !== 'string' || !knownTaskIds.has(taskId)) {
|
|
484
|
+
failPrecondition(`${label}.task_ids references unknown task ${String(taskId)}`);
|
|
485
|
+
}
|
|
486
|
+
if (taskIds.has(taskId)) failPrecondition(`${label}.task_ids must not contain duplicate task ids`);
|
|
487
|
+
taskIds.add(taskId);
|
|
488
|
+
}
|
|
489
|
+
return cloneJson(phase);
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const BOUNDARY_FIELDS = Object.freeze({
|
|
494
|
+
ownership: ['id', 'type', 'tasks', 'write_sets'],
|
|
495
|
+
dependency: ['id', 'type', 'producer', 'consumer', 'deliverable'],
|
|
496
|
+
transition: ['id', 'type', 'design_ref', 'owner', 'consumers', 'precondition', 'postcondition', 'failure', 'recovery'],
|
|
497
|
+
proof: ['id', 'type', 'subject', 'verifier', 'verification_ref', 'artifact_anchor'],
|
|
498
|
+
parallel: ['id', 'type', 'tasks', 'resources'],
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
function exactTarget(value, label) {
|
|
502
|
+
safeRelativePath(value, label);
|
|
503
|
+
if (/[*?{}[\]]/.test(value) || /[\\/]$/.test(value)) {
|
|
504
|
+
failPrecondition(`${label} must be one exact grounded target, not a glob or parent directory`);
|
|
505
|
+
}
|
|
506
|
+
return value;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function targetsContend(left, right) {
|
|
510
|
+
const a = left.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
511
|
+
const b = right.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
512
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function boundaryTaskList(value, label, knownTaskIds, minimum = 1) {
|
|
516
|
+
if (!Array.isArray(value) || value.length < minimum) {
|
|
517
|
+
failPrecondition(`${label} must contain at least ${minimum} task id(s)`);
|
|
518
|
+
}
|
|
519
|
+
const seen = new Set();
|
|
520
|
+
for (const taskId of value) {
|
|
521
|
+
if (!knownTaskIds.has(taskId)) failPrecondition(`${label} references unknown task ${String(taskId)}`);
|
|
522
|
+
if (seen.has(taskId)) failPrecondition(`${label} must not contain duplicate task ids`);
|
|
523
|
+
seen.add(taskId);
|
|
524
|
+
}
|
|
525
|
+
return [...value];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function boundaryTargetMap(value, tasks, label) {
|
|
529
|
+
if (!isPlainObject(value) || JSON.stringify(Object.keys(value).sort()) !== JSON.stringify([...tasks].sort())) {
|
|
530
|
+
failPrecondition(`${label} must be an object keyed exactly by its task ids`);
|
|
531
|
+
}
|
|
532
|
+
const result = {};
|
|
533
|
+
for (const taskId of tasks) {
|
|
534
|
+
if (!Array.isArray(value[taskId]) || value[taskId].length === 0) {
|
|
535
|
+
failPrecondition(`${label}.${taskId} must be a non-empty exact target array`);
|
|
536
|
+
}
|
|
537
|
+
result[taskId] = value[taskId].map((target, index) => exactTarget(target, `${label}.${taskId}[${index}]`));
|
|
538
|
+
if (new Set(result[taskId]).size !== result[taskId].length) {
|
|
539
|
+
failPrecondition(`${label}.${taskId} must not contain duplicate targets`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
for (let left = 0; left < tasks.length; left += 1) {
|
|
543
|
+
for (let right = left + 1; right < tasks.length; right += 1) {
|
|
544
|
+
for (const a of result[tasks[left]]) {
|
|
545
|
+
for (const b of result[tasks[right]]) {
|
|
546
|
+
if (targetsContend(a, b)) {
|
|
547
|
+
failPrecondition(`${label} has resource contention between ${tasks[left]}:${a} and ${tasks[right]}:${b}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return result;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function parseBoundaryDeclarations(raw, knownTaskFiles) {
|
|
557
|
+
if (raw === null || raw === undefined) return [];
|
|
558
|
+
let boundaries;
|
|
559
|
+
try { boundaries = JSON.parse(raw); } catch (error) {
|
|
560
|
+
failPrecondition(`--boundaries must be valid JSON (${error.message})`);
|
|
561
|
+
}
|
|
562
|
+
if (!Array.isArray(boundaries) || boundaries.length === 0) {
|
|
563
|
+
failPrecondition('--boundaries must be a non-empty JSON array');
|
|
564
|
+
}
|
|
565
|
+
const taskPathById = new Map(knownTaskFiles.map((taskPath) => {
|
|
566
|
+
const identity = parseTaskPath(taskPath, `known task ${taskPath}`);
|
|
567
|
+
return [identity.id, taskPath];
|
|
568
|
+
}));
|
|
569
|
+
const knownTaskIds = new Set(taskPathById.keys());
|
|
570
|
+
const ids = new Set();
|
|
571
|
+
const result = boundaries.map((boundary, index) => {
|
|
572
|
+
const label = `--boundaries[${index}]`;
|
|
573
|
+
if (!isPlainObject(boundary) || !Object.prototype.hasOwnProperty.call(BOUNDARY_FIELDS, boundary.type)) {
|
|
574
|
+
failPrecondition(`${label}.type must be ownership, dependency, transition, proof, or parallel`);
|
|
575
|
+
}
|
|
576
|
+
if (JSON.stringify(Object.keys(boundary).sort()) !== JSON.stringify([...BOUNDARY_FIELDS[boundary.type]].sort())) {
|
|
577
|
+
failPrecondition(`${label} ${boundary.type} fields must be exactly ${BOUNDARY_FIELDS[boundary.type].join(', ')}`);
|
|
578
|
+
}
|
|
579
|
+
if (typeof boundary.id !== 'string' || !/^B-[A-Z0-9][A-Z0-9._-]*$/i.test(boundary.id)) {
|
|
580
|
+
failPrecondition(`${label}.id must be a safe B-* identifier`);
|
|
581
|
+
}
|
|
582
|
+
if (ids.has(boundary.id)) failPrecondition(`--boundaries contains duplicate id ${boundary.id}`);
|
|
583
|
+
ids.add(boundary.id);
|
|
584
|
+
if (boundary.type === 'ownership') {
|
|
585
|
+
const tasks = boundaryTaskList(boundary.tasks, `${label}.tasks`, knownTaskIds, 2);
|
|
586
|
+
boundaryTargetMap(boundary.write_sets, tasks, `${label}.write_sets`);
|
|
587
|
+
} else if (boundary.type === 'dependency') {
|
|
588
|
+
boundaryTaskList([boundary.producer, boundary.consumer], label, knownTaskIds, 2);
|
|
589
|
+
if (boundary.producer === boundary.consumer) failPrecondition(`${label} producer and consumer must differ`);
|
|
590
|
+
exactTarget(boundary.deliverable, `${label}.deliverable`);
|
|
591
|
+
} else if (boundary.type === 'transition') {
|
|
592
|
+
boundaryTaskList([boundary.owner], `${label}.owner`, knownTaskIds);
|
|
593
|
+
boundaryTaskList(boundary.consumers, `${label}.consumers`, knownTaskIds);
|
|
594
|
+
if (boundary.consumers.includes(boundary.owner)) failPrecondition(`${label}.consumers cannot include transition owner`);
|
|
595
|
+
if (!/^T\d+(?:\.\d+)?$/i.test(boundary.design_ref)) failPrecondition(`${label}.design_ref must be a T id`);
|
|
596
|
+
for (const field of ['precondition', 'postcondition', 'failure', 'recovery']) {
|
|
597
|
+
if (typeof boundary[field] !== 'string' || boundary[field].trim().length < 8) {
|
|
598
|
+
failPrecondition(`${label}.${field} must be a concrete definition`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
} else if (boundary.type === 'proof') {
|
|
602
|
+
boundaryTaskList([boundary.subject, boundary.verifier], label, knownTaskIds, 2);
|
|
603
|
+
if (boundary.subject === boundary.verifier) failPrecondition(`${label} subject and verifier must differ`);
|
|
604
|
+
if (!/^V\d+(?:\.\d+)?$/i.test(boundary.verification_ref)) failPrecondition(`${label}.verification_ref must be a V id`);
|
|
605
|
+
if (!new RegExp(`^A-${boundary.verifier}-\\d{2}$`).test(boundary.artifact_anchor)) {
|
|
606
|
+
failPrecondition(`${label}.artifact_anchor must be owned by verifier ${boundary.verifier}`);
|
|
607
|
+
}
|
|
608
|
+
} else if (boundary.type === 'parallel') {
|
|
609
|
+
const tasks = boundaryTaskList(boundary.tasks, `${label}.tasks`, knownTaskIds, 2);
|
|
610
|
+
boundaryTargetMap(boundary.resources, tasks, `${label}.resources`);
|
|
611
|
+
}
|
|
612
|
+
return cloneJson(boundary);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const dependencies = result.filter((boundary) => boundary.type === 'dependency');
|
|
616
|
+
const graph = new Map([...knownTaskIds].map((taskId) => [taskId, []]));
|
|
617
|
+
for (const boundary of dependencies) graph.get(boundary.producer).push(boundary.consumer);
|
|
618
|
+
const visiting = new Set();
|
|
619
|
+
const visited = new Set();
|
|
620
|
+
const visit = (taskId, stack = []) => {
|
|
621
|
+
if (visiting.has(taskId)) {
|
|
622
|
+
const start = stack.indexOf(taskId);
|
|
623
|
+
failPrecondition(`coordination.boundaries dependency cycle: ${stack.slice(start).concat(taskId).join(' -> ')}`);
|
|
624
|
+
}
|
|
625
|
+
if (visited.has(taskId)) return;
|
|
626
|
+
visiting.add(taskId);
|
|
627
|
+
for (const consumer of graph.get(taskId)) visit(consumer, [...stack, taskId]);
|
|
628
|
+
visiting.delete(taskId);
|
|
629
|
+
visited.add(taskId);
|
|
630
|
+
};
|
|
631
|
+
for (const taskId of knownTaskIds) visit(taskId);
|
|
632
|
+
const pathExists = (from, to, seen = new Set()) => {
|
|
633
|
+
if (from === to) return true;
|
|
634
|
+
if (seen.has(from)) return false;
|
|
635
|
+
seen.add(from);
|
|
636
|
+
return graph.get(from).some((next) => pathExists(next, to, seen));
|
|
637
|
+
};
|
|
638
|
+
for (const boundary of result.filter((candidate) => candidate.type === 'parallel')) {
|
|
639
|
+
for (let left = 0; left < boundary.tasks.length; left += 1) {
|
|
640
|
+
for (let right = left + 1; right < boundary.tasks.length; right += 1) {
|
|
641
|
+
if (pathExists(boundary.tasks[left], boundary.tasks[right])
|
|
642
|
+
|| pathExists(boundary.tasks[right], boundary.tasks[left])) {
|
|
643
|
+
failPrecondition(`${boundary.id}: parallel tasks must have no dependency path`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const authorities = result.filter((candidate) => ['transition', 'proof'].includes(candidate.type));
|
|
648
|
+
for (const authority of authorities) {
|
|
649
|
+
const owners = authority.type === 'transition'
|
|
650
|
+
? [authority.owner, ...authority.consumers]
|
|
651
|
+
: [authority.subject, authority.verifier];
|
|
652
|
+
if (boundary.tasks.filter((taskId) => owners.includes(taskId)).length > 1) {
|
|
653
|
+
failPrecondition(`${boundary.id}: parallel tasks cannot share ${authority.type} authority ${authority.id}`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return result;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function dependenciesFromBoundaries(boundaries, knownTaskFiles) {
|
|
661
|
+
const pathById = new Map(knownTaskFiles.map((taskPath) => [parseTaskPath(taskPath, taskPath).id, taskPath]));
|
|
662
|
+
const result = new Map(knownTaskFiles.map((taskPath) => [taskPath, []]));
|
|
663
|
+
for (const boundary of boundaries) {
|
|
664
|
+
if (boundary.type !== 'dependency') continue;
|
|
665
|
+
result.get(pathById.get(boundary.consumer)).push(pathById.get(boundary.producer));
|
|
666
|
+
}
|
|
667
|
+
for (const [taskPath, dependencies] of result) result.set(taskPath, [...new Set(dependencies)].sort());
|
|
668
|
+
return result;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function validatePhaseBoundaryRefs(phases, boundaries) {
|
|
672
|
+
const boundaryIds = new Set(boundaries.map((boundary) => boundary.id));
|
|
673
|
+
for (const phase of phases) {
|
|
674
|
+
if (!boundaryIds.has(phase.owner_boundary)) {
|
|
675
|
+
failPrecondition(`phase ${phase.id}.owner_boundary references unknown boundary ${phase.owner_boundary}`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function applyExecutionClosureProfile(content, includeExecutionClosure) {
|
|
681
|
+
if (!includeExecutionClosure) return content.replace(EXECUTION_CLOSURE_BLOCK_RE, '');
|
|
682
|
+
return content
|
|
683
|
+
.replace('<!-- EXECUTION_CLOSURE_START -->\n', '')
|
|
684
|
+
.replace('<!-- EXECUTION_CLOSURE_END -->\n', '');
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function declaredImplementationObligations(spec) {
|
|
688
|
+
return Array.isArray(spec?.implementation_obligations)
|
|
689
|
+
? spec.implementation_obligations.filter(isPlainObject)
|
|
690
|
+
: [];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function taskHasDeclaredImplementationObligation(spec, taskPath) {
|
|
694
|
+
return declaredImplementationObligations(spec).some((obligation) => (
|
|
695
|
+
obligation.owner_task === taskPath
|
|
696
|
+
|| (Array.isArray(obligation.consumer_tasks) && obligation.consumer_tasks.includes(taskPath))
|
|
697
|
+
));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function fillTask(taskTpl, t, feature, dependencies = [], includeExecutionClosure = false) {
|
|
701
|
+
return applyExecutionClosureProfile(taskTpl, includeExecutionClosure)
|
|
193
702
|
.replace(/\{\{REQ_NUMBER\}\}/g, t.req)
|
|
194
703
|
.replace(/\{\{SEQ\}\}/g, t.seq)
|
|
195
704
|
.replace(/\{\{TITLE\}\}/g, titleFromSlug(t.slug))
|
|
196
705
|
.replace(/\{\{FEATURE_NAME\}\}/g, feature)
|
|
197
706
|
.replace(/\{\{PRIORITY\}\}/g, 'P2')
|
|
198
707
|
.replace(/\{\{EFFORT\}\}/g, 'TBD')
|
|
199
|
-
.replace(/\{\{DEPENDENCIES\}\}/g, 'none');
|
|
708
|
+
.replace(/\{\{DEPENDENCIES\}\}/g, dependencies.length > 0 ? dependencies.join(', ') : 'none');
|
|
200
709
|
}
|
|
201
710
|
|
|
202
711
|
function parseRiskNames(raw) {
|
|
@@ -236,6 +745,199 @@ function cloneJson(value) {
|
|
|
236
745
|
return JSON.parse(JSON.stringify(value));
|
|
237
746
|
}
|
|
238
747
|
|
|
748
|
+
function assertExactKeys(value, expected, label) {
|
|
749
|
+
if (!isPlainObject(value)) failPrecondition(`${label} must be an object`);
|
|
750
|
+
const actual = Object.keys(value).sort();
|
|
751
|
+
const wanted = [...expected].sort();
|
|
752
|
+
if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
|
|
753
|
+
const unknown = actual.filter((key) => !wanted.includes(key));
|
|
754
|
+
const missing = wanted.filter((key) => !actual.includes(key));
|
|
755
|
+
failPrecondition(
|
|
756
|
+
`${label} must be closed-world; unknown=[${unknown.join(', ')}], missing=[${missing.join(', ')}]`,
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function canonicalRegistryFields(entry) {
|
|
762
|
+
return hasOwn(entry, 'artifacts') ? [...REGISTRY_KEYS, 'artifacts'] : REGISTRY_KEYS;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function assertCanonicalSpecInputClosedWorld(spec, { allowLegacyReview = false } = {}) {
|
|
766
|
+
if (!isPlainObject(spec) || spec.schema_version !== '2.1') return;
|
|
767
|
+
const topLevel = [...CANONICAL_SPEC_FIELDS].filter((field) => {
|
|
768
|
+
if (field === 'research') return hasOwn(spec, 'research');
|
|
769
|
+
if (field === 'task_files' || field === 'task_registry') return hasOwn(spec, field);
|
|
770
|
+
if (field === 'decisions') return hasOwn(spec, 'decisions');
|
|
771
|
+
return true;
|
|
772
|
+
});
|
|
773
|
+
assertExactKeys(spec, topLevel, 'canonical schema 2.1 spec.json');
|
|
774
|
+
assertExactKeys(spec.scope_lock, SCOPE_LOCK_FIELDS, 'canonical schema 2.1 scope_lock');
|
|
775
|
+
assertExactKeys(spec.authoring, AUTHORING_FIELDS, 'canonical schema 2.1 authoring');
|
|
776
|
+
const coordinationFields = hasOwn(spec.coordination, 'phases')
|
|
777
|
+
? [...COORDINATION_FIELDS] : ['boundaries'];
|
|
778
|
+
assertExactKeys(spec.coordination, coordinationFields, 'canonical schema 2.1 coordination');
|
|
779
|
+
const reviewKeys = isPlainObject(spec.validation?.semantic_review)
|
|
780
|
+
? Object.keys(spec.validation.semantic_review).sort().join(',') : '';
|
|
781
|
+
const legacyReviewFields = reviewKeys === [...LEGACY_SEMANTIC_REVIEW_FIELDS].sort().join(',')
|
|
782
|
+
? LEGACY_SEMANTIC_REVIEW_FIELDS
|
|
783
|
+
: reviewKeys === [...PRE_C2_SEMANTIC_REVIEW_FIELDS].sort().join(',')
|
|
784
|
+
? PRE_C2_SEMANTIC_REVIEW_FIELDS : null;
|
|
785
|
+
const legacyReview = allowLegacyReview
|
|
786
|
+
&& isPlainObject(spec.validation)
|
|
787
|
+
&& Object.keys(spec.validation).sort().join(',') === ['semantic_review', 'status'].sort().join(',')
|
|
788
|
+
&& legacyReviewFields !== null;
|
|
789
|
+
// semantic_review_history and authoring_validation (C13/C16) are required
|
|
790
|
+
// going forward, but a pre-existing 2.1 spec authored before this feature's
|
|
791
|
+
// cutover may not carry them yet; accept their absence here so scaffold can
|
|
792
|
+
// migrate such a spec forward (normalizeSpec21Candidate forces a fresh reset
|
|
793
|
+
// whenever either is missing) instead of hard-failing on an old file.
|
|
794
|
+
const validationFields = legacyReview ? ['status', 'semantic_review'] : [...VALIDATION_FIELDS].filter((field) => {
|
|
795
|
+
if (field === 'semantic_review_history' || field === 'authoring_validation') return hasOwn(spec.validation, field);
|
|
796
|
+
return true;
|
|
797
|
+
});
|
|
798
|
+
assertExactKeys(spec.validation, validationFields, 'canonical schema 2.1 validation');
|
|
799
|
+
assertExactKeys(
|
|
800
|
+
spec.validation.semantic_review,
|
|
801
|
+
legacyReview ? legacyReviewFields : SEMANTIC_REVIEW_FIELDS,
|
|
802
|
+
'canonical schema 2.1 validation.semantic_review',
|
|
803
|
+
);
|
|
804
|
+
if (Array.isArray(spec.validation.semantic_review?.counterexamples)) {
|
|
805
|
+
const fields = ['criterion', 'case_kind', 'scenario', 'expected', 'decision_refs', 'verification_ref'];
|
|
806
|
+
for (const [index, counterexample] of spec.validation.semantic_review.counterexamples.entries()) {
|
|
807
|
+
assertExactKeys(
|
|
808
|
+
counterexample,
|
|
809
|
+
fields,
|
|
810
|
+
`canonical schema 2.1 validation.semantic_review.counterexamples[${index}]`,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
for (const [index, finding] of (spec.validation.semantic_review?.findings || []).entries()) {
|
|
815
|
+
assertExactKeys(finding, SEMANTIC_REVIEW_FINDING_FIELDS, `canonical schema 2.1 validation.semantic_review.findings[${index}]`);
|
|
816
|
+
}
|
|
817
|
+
for (const [index, decision] of (spec.validation.semantic_review?.unresolved_decisions || []).entries()) {
|
|
818
|
+
assertExactKeys(decision, SEMANTIC_REVIEW_DECISION_FIELDS, `canonical schema 2.1 validation.semantic_review.unresolved_decisions[${index}]`);
|
|
819
|
+
}
|
|
820
|
+
for (const [index, coverage] of (spec.validation.semantic_review?.graph_coverage || []).entries()) {
|
|
821
|
+
assertExactKeys(coverage, SEMANTIC_REVIEW_COVERAGE_FIELDS, `canonical schema 2.1 validation.semantic_review.graph_coverage[${index}]`);
|
|
822
|
+
}
|
|
823
|
+
if (!legacyReview && spec.validation.semantic_review?.reviewer_evidence !== null) {
|
|
824
|
+
assertExactKeys(
|
|
825
|
+
spec.validation.semantic_review?.reviewer_evidence,
|
|
826
|
+
SEMANTIC_REVIEW_EVIDENCE_FIELDS,
|
|
827
|
+
'canonical schema 2.1 validation.semantic_review.reviewer_evidence',
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
if (hasOwn(spec.validation, 'semantic_review_history')) {
|
|
831
|
+
assertExactKeys(spec.validation.semantic_review_history, SEMANTIC_REVIEW_HISTORY_FIELDS, 'canonical schema 2.1 validation.semantic_review_history');
|
|
832
|
+
for (const [index, entry] of (spec.validation.semantic_review_history?.entries || []).entries()) {
|
|
833
|
+
assertExactKeys(entry, SEMANTIC_REVIEW_HISTORY_ENTRY_FIELDS, `canonical schema 2.1 validation.semantic_review_history.entries[${index}]`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (hasOwn(spec.validation, 'authoring_validation') && spec.validation.authoring_validation !== null) {
|
|
837
|
+
assertExactKeys(spec.validation.authoring_validation, AUTHORING_VALIDATION_FIELDS, 'canonical schema 2.1 validation.authoring_validation');
|
|
838
|
+
for (const stage of ['requirements', 'design', 'research', 'tasks']) {
|
|
839
|
+
assertExactKeys(
|
|
840
|
+
spec.validation.authoring_validation?.[stage],
|
|
841
|
+
AUTHORING_VALIDATION_STAGE_FIELDS,
|
|
842
|
+
`canonical schema 2.1 validation.authoring_validation.${stage}`,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (hasOwn(spec, 'decisions')) {
|
|
847
|
+
if (!Array.isArray(spec.decisions) || spec.decisions.length === 0) failPrecondition('canonical schema 2.1 decisions must be omitted or a non-empty array');
|
|
848
|
+
for (const [index, decision] of spec.decisions.entries()) {
|
|
849
|
+
assertExactKeys(decision, ['id', 'classification', 'statement', 'status', 'evidence'], `canonical schema 2.1 decisions[${index}]`);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (Array.isArray(spec.coordination?.boundaries)) {
|
|
853
|
+
for (const [index, boundary] of spec.coordination.boundaries.entries()) {
|
|
854
|
+
if (!isPlainObject(boundary) || !Object.prototype.hasOwnProperty.call(BOUNDARY_FIELDS, boundary.type)) {
|
|
855
|
+
failPrecondition(`canonical schema 2.1 coordination.boundaries[${index}] has an unknown type`);
|
|
856
|
+
}
|
|
857
|
+
assertExactKeys(
|
|
858
|
+
boundary,
|
|
859
|
+
BOUNDARY_FIELDS[boundary.type],
|
|
860
|
+
`canonical schema 2.1 coordination.boundaries[${index}]`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (Array.isArray(spec.coordination?.phases)) {
|
|
865
|
+
const fields = ['entry_condition', 'exit_condition', 'id', 'owner_boundary', 'task_ids'];
|
|
866
|
+
for (const [index, phase] of spec.coordination.phases.entries()) {
|
|
867
|
+
assertExactKeys(phase, fields, `canonical schema 2.1 coordination.phases[${index}]`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (hasOwn(spec, 'task_registry') && isPlainObject(spec.task_registry)) {
|
|
871
|
+
for (const [taskPath, entry] of Object.entries(spec.task_registry)) {
|
|
872
|
+
assertExactKeys(entry, canonicalRegistryFields(entry), `canonical schema 2.1 task_registry.${taskPath}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function rejectUnknownKeys(value, allowed, label) {
|
|
878
|
+
if (!isPlainObject(value)) return;
|
|
879
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
880
|
+
if (unknown.length) {
|
|
881
|
+
failPrecondition(`${label} contains unsupported legacy authority field(s): ${unknown.join(', ')}; remove or explicitly migrate them before schema 2.1 promotion`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function assertLegacyMigrationClosedWorld(spec) {
|
|
886
|
+
rejectUnknownKeys(spec, LEGACY_SPEC_FIELDS, 'schema 2.0 spec.json');
|
|
887
|
+
rejectUnknownKeys(spec.scope_lock, SCOPE_LOCK_FIELDS, 'schema 2.0 scope_lock');
|
|
888
|
+
rejectUnknownKeys(spec.authoring, AUTHORING_FIELDS, 'schema 2.0 authoring');
|
|
889
|
+
rejectUnknownKeys(spec.coordination, LEGACY_COORDINATION_FIELDS, 'schema 2.0 coordination');
|
|
890
|
+
rejectUnknownKeys(spec.validation, LEGACY_VALIDATION_FIELDS, 'schema 2.0 validation');
|
|
891
|
+
rejectUnknownKeys(spec.validation?.semantic_review, LEGACY_SEMANTIC_REVIEW_FIELDS, 'schema 2.0 validation.semantic_review');
|
|
892
|
+
for (const [index, counterexample] of (spec.validation?.semantic_review?.counterexamples || []).entries()) {
|
|
893
|
+
rejectUnknownKeys(counterexample, ['criterion', 'case_kind', 'scenario', 'expected', 'decision_refs', 'verification_ref'], `schema 2.0 semantic_review.counterexamples[${index}]`);
|
|
894
|
+
}
|
|
895
|
+
for (const [index, boundary] of (spec.coordination?.boundaries || []).entries()) {
|
|
896
|
+
if (!isPlainObject(boundary) || !hasOwn(BOUNDARY_FIELDS, boundary.type)) {
|
|
897
|
+
failPrecondition(`schema 2.0 coordination.boundaries[${index}] has an unknown authority type`);
|
|
898
|
+
}
|
|
899
|
+
rejectUnknownKeys(boundary, BOUNDARY_FIELDS[boundary.type], `schema 2.0 coordination.boundaries[${index}]`);
|
|
900
|
+
}
|
|
901
|
+
if (isPlainObject(spec.workflow_policy)) {
|
|
902
|
+
rejectUnknownKeys(spec.workflow_policy, LEGACY_POLICY_FIELDS, 'schema 2.0 workflow_policy');
|
|
903
|
+
}
|
|
904
|
+
if (hasOwn(spec, 'current_phase')
|
|
905
|
+
&& (typeof spec.current_phase !== 'string' || spec.current_phase.trim() === '')) {
|
|
906
|
+
failPrecondition('schema 2.0 current_phase must be a non-empty string when present');
|
|
907
|
+
}
|
|
908
|
+
if (hasOwn(spec, 'timestamps')) {
|
|
909
|
+
if (!isPlainObject(spec.timestamps)) failPrecondition('schema 2.0 timestamps must be an object');
|
|
910
|
+
rejectUnknownKeys(spec.timestamps, LEGACY_TIMESTAMP_FIELDS, 'schema 2.0 timestamps');
|
|
911
|
+
for (const [field, value] of Object.entries(spec.timestamps)) {
|
|
912
|
+
if (value !== null && !isIsoTimestamp(value)) {
|
|
913
|
+
failPrecondition(`schema 2.0 timestamps.${field} must be null or a valid ISO 8601 timestamp`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (hasOwn(spec, 'approvals')) {
|
|
918
|
+
if (!isPlainObject(spec.approvals)) failPrecondition('schema 2.0 approvals must be an object');
|
|
919
|
+
rejectUnknownKeys(spec.approvals, LEGACY_APPROVAL_FIELDS, 'schema 2.0 approvals');
|
|
920
|
+
for (const [section, state] of Object.entries(spec.approvals)) {
|
|
921
|
+
if (!isPlainObject(state)) failPrecondition(`schema 2.0 approvals.${section} must be an object`);
|
|
922
|
+
rejectUnknownKeys(state, LEGACY_APPROVAL_STATE_FIELDS, `schema 2.0 approvals.${section}`);
|
|
923
|
+
for (const [field, value] of Object.entries(state)) {
|
|
924
|
+
if (typeof value !== 'boolean') {
|
|
925
|
+
failPrecondition(`schema 2.0 approvals.${section}.${field} must be boolean`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
if (isPlainObject(spec.task_registry)) for (const [taskPath, entry] of Object.entries(spec.task_registry)) {
|
|
931
|
+
rejectUnknownKeys(entry, canonicalRegistryFields(entry), `schema 2.0 task_registry.${taskPath}`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function isIsoTimestamp(value) {
|
|
936
|
+
return typeof value === 'string'
|
|
937
|
+
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
|
|
938
|
+
&& Number.isFinite(Date.parse(value));
|
|
939
|
+
}
|
|
940
|
+
|
|
239
941
|
function isWithin(root, target) {
|
|
240
942
|
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
241
943
|
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
@@ -339,7 +1041,9 @@ function validateRegistryEntry(taskPath, entry, declaredSet) {
|
|
|
339
1041
|
const identity = parseTaskPath(taskPath, `task_registry.${taskPath}`);
|
|
340
1042
|
if (entry.id !== identity.id) failPrecondition(`task_registry.${taskPath}.id conflicts with its task path`);
|
|
341
1043
|
if (typeof entry.title !== 'string' || entry.title.trim() === '') failPrecondition(`task_registry.${taskPath}.title must be a non-empty string`);
|
|
342
|
-
if (
|
|
1044
|
+
if (!TASK_STATUS_VALUES.has(entry.status)) {
|
|
1045
|
+
failPrecondition(`task_registry.${taskPath}.status must be one of ${[...TASK_STATUS_VALUES].join(', ')}`);
|
|
1046
|
+
}
|
|
343
1047
|
if (!Array.isArray(entry.dependencies)) failPrecondition(`task_registry.${taskPath}.dependencies must be an array`);
|
|
344
1048
|
const dependencies = new Set();
|
|
345
1049
|
for (const dependency of entry.dependencies) {
|
|
@@ -350,6 +1054,322 @@ function validateRegistryEntry(taskPath, entry, declaredSet) {
|
|
|
350
1054
|
dependencies.add(dependency);
|
|
351
1055
|
}
|
|
352
1056
|
if (hasOwn(entry, 'artifacts')) validateArtifactList(entry.artifacts, `task_registry.${taskPath}.artifacts`);
|
|
1057
|
+
const label = `task_registry.${taskPath}`;
|
|
1058
|
+
if (entry.blocker !== null && (typeof entry.blocker !== 'string' || entry.blocker.trim() === '')) {
|
|
1059
|
+
failPrecondition(`${label}.blocker must be null or a non-empty string`);
|
|
1060
|
+
}
|
|
1061
|
+
for (const field of ['started_at', 'completed_at', 'last_updated_at']) {
|
|
1062
|
+
if (entry[field] !== null && !isIsoTimestamp(entry[field])) {
|
|
1063
|
+
failPrecondition(`${label}.${field} must be null or an ISO 8601 timestamp`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
if (entry.status === 'pending') {
|
|
1067
|
+
if (entry.blocker !== null || entry.started_at !== null || entry.completed_at !== null) {
|
|
1068
|
+
failPrecondition(`${label} pending lifecycle is inconsistent`);
|
|
1069
|
+
}
|
|
1070
|
+
} else if (entry.status === 'in_progress') {
|
|
1071
|
+
if (entry.blocker !== null || !isIsoTimestamp(entry.started_at)
|
|
1072
|
+
|| entry.completed_at !== null || !isIsoTimestamp(entry.last_updated_at)
|
|
1073
|
+
|| Date.parse(entry.last_updated_at) < Date.parse(entry.started_at)) {
|
|
1074
|
+
failPrecondition(`${label} in_progress lifecycle is inconsistent`);
|
|
1075
|
+
}
|
|
1076
|
+
} else if (entry.status === 'blocked') {
|
|
1077
|
+
if (typeof entry.blocker !== 'string' || entry.blocker.trim() === ''
|
|
1078
|
+
|| entry.completed_at !== null || !isIsoTimestamp(entry.last_updated_at)
|
|
1079
|
+
|| (isIsoTimestamp(entry.started_at) && Date.parse(entry.last_updated_at) < Date.parse(entry.started_at))) {
|
|
1080
|
+
failPrecondition(`${label} blocked lifecycle is inconsistent`);
|
|
1081
|
+
}
|
|
1082
|
+
} else if (entry.status === 'done') {
|
|
1083
|
+
if (entry.blocker !== null
|
|
1084
|
+
|| ![entry.started_at, entry.completed_at, entry.last_updated_at].every(isIsoTimestamp)
|
|
1085
|
+
|| Date.parse(entry.completed_at) < Date.parse(entry.started_at)
|
|
1086
|
+
|| Date.parse(entry.last_updated_at) < Date.parse(entry.completed_at)) {
|
|
1087
|
+
failPrecondition(`${label} done lifecycle is inconsistent`);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function semanticReviewShapeValid(review) {
|
|
1093
|
+
if (!isPlainObject(review) || Object.keys(review).sort().join(',') !== [...SEMANTIC_REVIEW_FIELDS].sort().join(',')) return false;
|
|
1094
|
+
if (!['not-run', 'completed'].includes(review.status)) return false;
|
|
1095
|
+
if (review.semantic_digest !== null && typeof review.semantic_digest !== 'string') return false;
|
|
1096
|
+
if (review.verdict !== null && !['PASS', 'FAIL'].includes(review.verdict)) return false;
|
|
1097
|
+
if (review.lifecycle_disposition !== null && !['CONTINUE', 'BLOCKED'].includes(review.lifecycle_disposition)) return false;
|
|
1098
|
+
if (!Array.isArray(review.findings) || !review.findings.every((finding) => isPlainObject(finding)
|
|
1099
|
+
&& Object.keys(finding).sort().join(',') === [...SEMANTIC_REVIEW_FINDING_FIELDS].sort().join(','))) return false;
|
|
1100
|
+
if (!Array.isArray(review.unresolved_decisions) || !review.unresolved_decisions.every((decision) => isPlainObject(decision)
|
|
1101
|
+
&& Object.keys(decision).sort().join(',') === [...SEMANTIC_REVIEW_DECISION_FIELDS].sort().join(','))) return false;
|
|
1102
|
+
if (!Array.isArray(review.graph_coverage) || !review.graph_coverage.every((coverage) => isPlainObject(coverage)
|
|
1103
|
+
&& Object.keys(coverage).sort().join(',') === [...SEMANTIC_REVIEW_COVERAGE_FIELDS].sort().join(','))) return false;
|
|
1104
|
+
if (!Number.isInteger(review.repair_round) || review.repair_round < 0 || review.repair_round > 2) return false;
|
|
1105
|
+
if (!Array.isArray(review.reviewed_criteria) || !Array.isArray(review.counterexamples)) return false;
|
|
1106
|
+
if (review.reviewer_evidence !== null && (!isPlainObject(review.reviewer_evidence)
|
|
1107
|
+
|| Object.keys(review.reviewer_evidence).sort().join(',') !== [...SEMANTIC_REVIEW_EVIDENCE_FIELDS].sort().join(','))) return false;
|
|
1108
|
+
return true;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function semanticReviewHistoryShapeValid(history) {
|
|
1112
|
+
if (!isPlainObject(history) || Object.keys(history).sort().join(',') !== [...SEMANTIC_REVIEW_HISTORY_FIELDS].sort().join(',')) return false;
|
|
1113
|
+
if (typeof history.lineage_id !== 'string') return false;
|
|
1114
|
+
return Array.isArray(history.entries) && history.entries.every((entry) => isPlainObject(entry)
|
|
1115
|
+
&& Object.keys(entry).sort().join(',') === [...SEMANTIC_REVIEW_HISTORY_ENTRY_FIELDS].sort().join(','));
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function authoringValidationShapeValid(receipt) {
|
|
1119
|
+
if (receipt === null) return true;
|
|
1120
|
+
if (!isPlainObject(receipt) || Object.keys(receipt).sort().join(',') !== [...AUTHORING_VALIDATION_FIELDS].sort().join(',')) return false;
|
|
1121
|
+
return ['requirements', 'design', 'research', 'tasks'].every((stage) => isPlainObject(receipt[stage])
|
|
1122
|
+
&& Object.keys(receipt[stage]).sort().join(',') === [...AUTHORING_VALIDATION_STAGE_FIELDS].sort().join(','));
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function preflightSpec21Candidate(spec, evidence) {
|
|
1126
|
+
if (!isPlainObject(spec)) failPrecondition('schema 2.1 candidate must be an object');
|
|
1127
|
+
assertCanonicalSpecInputClosedWorld(spec);
|
|
1128
|
+
if (hasOwn(spec, 'override_receipt')) {
|
|
1129
|
+
failPrecondition('schema 2.1 candidate override_receipt is unsupported; omit the field');
|
|
1130
|
+
}
|
|
1131
|
+
const required = [
|
|
1132
|
+
'schema_version', 'feature_name', 'created_at', 'updated_at', 'language', 'status',
|
|
1133
|
+
'scope_lock', 'authoring', 'coordination', 'validation', 'semantic_model', 'ready_for_implementation',
|
|
1134
|
+
'workflow_policy',
|
|
1135
|
+
];
|
|
1136
|
+
for (const field of required) if (!hasOwn(spec, field)) {
|
|
1137
|
+
failPrecondition(`schema 2.1 candidate is missing required machine field ${field}`);
|
|
1138
|
+
}
|
|
1139
|
+
if (spec.schema_version !== '2.1') failPrecondition('schema 2.1 candidate has an unsupported schema_version');
|
|
1140
|
+
if (spec.feature_name !== evidence.featureName) failPrecondition('feature_name conflicts with the scaffold feature identity');
|
|
1141
|
+
if (!isIsoTimestamp(spec.created_at) || !isIsoTimestamp(spec.updated_at)
|
|
1142
|
+
|| Date.parse(spec.updated_at) < Date.parse(spec.created_at)) {
|
|
1143
|
+
failPrecondition('schema 2.1 candidate lifecycle timestamps are invalid');
|
|
1144
|
+
}
|
|
1145
|
+
if (typeof spec.language !== 'string' || spec.language.trim() === '') failPrecondition('schema 2.1 candidate language must be non-empty');
|
|
1146
|
+
if (!SPEC_STATUS_VALUES.has(spec.status)) failPrecondition('schema 2.1 candidate status is invalid');
|
|
1147
|
+
if (!isPlainObject(spec.scope_lock)) failPrecondition('schema 2.1 candidate scope_lock must be an object');
|
|
1148
|
+
if (!isPlainObject(spec.authoring)
|
|
1149
|
+
|| Object.keys(spec.authoring).sort().join(',') !== [...AUTHORING_FIELDS].sort().join(',')) {
|
|
1150
|
+
failPrecondition('schema 2.1 candidate authoring must contain exactly requirements, design, research, and tasks');
|
|
1151
|
+
}
|
|
1152
|
+
for (const field of AUTHORING_FIELDS) if (!AUTHORING_STATES.has(spec.authoring[field])) {
|
|
1153
|
+
failPrecondition(`schema 2.1 candidate authoring.${field} is invalid`);
|
|
1154
|
+
}
|
|
1155
|
+
if (!evidence.requirementsExists || !evidence.designExists) {
|
|
1156
|
+
failPrecondition('schema 2.1 migration requires physical requirements.md and design.md files');
|
|
1157
|
+
}
|
|
1158
|
+
if (spec.authoring.requirements === 'absent' || spec.authoring.design === 'absent') {
|
|
1159
|
+
failPrecondition('physical requirements.md and design.md cannot be marked absent');
|
|
1160
|
+
}
|
|
1161
|
+
if (evidence.researchExists) {
|
|
1162
|
+
if (spec.research !== 'research.md' || spec.authoring.research === 'absent') {
|
|
1163
|
+
failPrecondition('research pointer, authoring state, and physical research.md must agree');
|
|
1164
|
+
}
|
|
1165
|
+
} else if (hasOwn(spec, 'research') || spec.authoring.research !== 'absent') {
|
|
1166
|
+
failPrecondition('absent physical research.md requires no pointer and authoring.research absent');
|
|
1167
|
+
}
|
|
1168
|
+
if (!isPlainObject(spec.coordination) || !Array.isArray(spec.coordination.boundaries)) {
|
|
1169
|
+
failPrecondition('schema 2.1 candidate coordination.boundaries must be an array');
|
|
1170
|
+
}
|
|
1171
|
+
if (!isPlainObject(spec.validation) || !VALIDATION_STATUS_VALUES.has(spec.validation.status)
|
|
1172
|
+
|| !semanticReviewShapeValid(spec.validation.semantic_review)
|
|
1173
|
+
|| !semanticReviewHistoryShapeValid(spec.validation.semantic_review_history)
|
|
1174
|
+
|| !authoringValidationShapeValid(spec.validation.authoring_validation)) {
|
|
1175
|
+
failPrecondition('schema 2.1 candidate validation state is invalid');
|
|
1176
|
+
}
|
|
1177
|
+
if (typeof spec.ready_for_implementation !== 'boolean') failPrecondition('schema 2.1 candidate readiness must be boolean');
|
|
1178
|
+
if (spec.semantic_model !== null) {
|
|
1179
|
+
const semanticErrors = SEMANTIC.validateSemanticModel(spec.semantic_model, spec);
|
|
1180
|
+
if (semanticErrors.length) failPrecondition(`schema 2.1 candidate semantic_model is invalid (${semanticErrors.join('; ')})`);
|
|
1181
|
+
} else if (spec.ready_for_implementation === true) {
|
|
1182
|
+
failPrecondition('schema 2.1 readiness requires a promoted semantic_model');
|
|
1183
|
+
}
|
|
1184
|
+
if (isPlainObject(spec.workflow_policy) && hasOwn(spec.workflow_policy, 'override_receipt')) {
|
|
1185
|
+
failPrecondition('schema 2.1 candidate workflow_policy.override_receipt is unsupported; omit the field');
|
|
1186
|
+
}
|
|
1187
|
+
const policyValidation = POLICY.validateWorkflowPolicySnapshot(spec.workflow_policy);
|
|
1188
|
+
if (!policyValidation.valid || spec.workflow_policy.version !== '2.1') {
|
|
1189
|
+
failPrecondition(`schema 2.1 candidate workflow_policy is invalid (${policyValidation.errors.join('; ')})`);
|
|
1190
|
+
}
|
|
1191
|
+
const taskFiles = validateTaskPathList(spec.task_files || [], 'task_files');
|
|
1192
|
+
if (JSON.stringify(taskFiles) !== JSON.stringify([...evidence.taskFiles].sort())) {
|
|
1193
|
+
failPrecondition('schema 2.1 candidate task_files do not match the resulting physical topology');
|
|
1194
|
+
}
|
|
1195
|
+
if (taskFiles.length === 0) {
|
|
1196
|
+
if (hasOwn(spec, 'task_files') || hasOwn(spec, 'task_registry') || spec.authoring.tasks !== 'absent') {
|
|
1197
|
+
failPrecondition('taskless schema 2.1 candidate must omit task topology and mark tasks absent');
|
|
1198
|
+
}
|
|
1199
|
+
} else {
|
|
1200
|
+
if (!isPlainObject(spec.task_registry)
|
|
1201
|
+
|| Object.keys(spec.task_registry).sort().join(',') !== taskFiles.join(',')) {
|
|
1202
|
+
failPrecondition('schema 2.1 candidate task_registry must exactly match task_files');
|
|
1203
|
+
}
|
|
1204
|
+
if (!['draft', 'validated'].includes(spec.authoring.tasks)) {
|
|
1205
|
+
failPrecondition('physical tasks require draft or validated authoring state');
|
|
1206
|
+
}
|
|
1207
|
+
const declared = new Set(taskFiles);
|
|
1208
|
+
for (const taskPath of taskFiles) validateRegistryEntry(taskPath, spec.task_registry[taskPath], declared);
|
|
1209
|
+
validateDependencyCycles(taskFiles, spec.task_registry);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function normalizeSpec21Candidate(candidate, evidence) {
|
|
1214
|
+
try {
|
|
1215
|
+
if (!isPlainObject(candidate)) failPrecondition('migration candidate must be an object');
|
|
1216
|
+
if (!['2.0', '2.1'].includes(candidate.schema_version)) {
|
|
1217
|
+
failPrecondition('migration candidate schema_version must be exactly 2.0 or 2.1');
|
|
1218
|
+
}
|
|
1219
|
+
const isLegacy = candidate.schema_version === '2.0';
|
|
1220
|
+
if (isLegacy) assertLegacyMigrationClosedWorld(candidate);
|
|
1221
|
+
if (!isLegacy) assertCanonicalSpecInputClosedWorld(candidate);
|
|
1222
|
+
const featureName = !hasOwn(candidate, 'feature_name') && isLegacy
|
|
1223
|
+
? evidence.featureName : candidate.feature_name;
|
|
1224
|
+
if (typeof featureName !== 'string' || featureName.trim() === ''
|
|
1225
|
+
|| featureName !== evidence.featureName) {
|
|
1226
|
+
failPrecondition('existing feature_name conflicts with the scaffold feature identity');
|
|
1227
|
+
}
|
|
1228
|
+
if (!isIsoTimestamp(candidate.created_at) || !isIsoTimestamp(candidate.updated_at)) {
|
|
1229
|
+
failPrecondition('existing created_at and updated_at must be valid ISO 8601 timestamps');
|
|
1230
|
+
}
|
|
1231
|
+
if (typeof candidate.language !== 'string' || candidate.language.trim() === '') {
|
|
1232
|
+
failPrecondition('existing language must be a non-empty string');
|
|
1233
|
+
}
|
|
1234
|
+
const status = isLegacy && candidate.status === 'in-progress' ? 'in_progress' : candidate.status;
|
|
1235
|
+
if (!SPEC_STATUS_VALUES.has(status)) failPrecondition('existing spec status is invalid');
|
|
1236
|
+
if (!isPlainObject(candidate.scope_lock)) failPrecondition('existing scope_lock must be an object');
|
|
1237
|
+
const scopeLock = {
|
|
1238
|
+
source: candidate.scope_lock.source,
|
|
1239
|
+
in_scope: candidate.scope_lock.in_scope,
|
|
1240
|
+
out_of_scope: candidate.scope_lock.out_of_scope,
|
|
1241
|
+
expansion_policy: candidate.scope_lock.expansion_policy,
|
|
1242
|
+
};
|
|
1243
|
+
if (typeof scopeLock.source !== 'string' || scopeLock.source.trim() === ''
|
|
1244
|
+
|| !Array.isArray(scopeLock.in_scope) || !Array.isArray(scopeLock.out_of_scope)
|
|
1245
|
+
|| typeof scopeLock.expansion_policy !== 'string' || scopeLock.expansion_policy.trim() === '') {
|
|
1246
|
+
failPrecondition('existing scope_lock canonical fields are invalid');
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// D13/I21/R3.9: spec-scaffold.cjs never *writes* `validated` for any
|
|
1250
|
+
// authoring field — only spec-authoring-validation.cjs's atomic coordinator
|
|
1251
|
+
// may flip a stage to validated, after its own clean validator+grounder
|
|
1252
|
+
// pass. It does, however, carry an existing `validated` reading forward
|
|
1253
|
+
// untouched while that stage's C16 receipt still matches the stage's
|
|
1254
|
+
// current bytes, exactly as it already preserves the C13 history and the
|
|
1255
|
+
// receipt itself below: a still-fresh durable record is not scaffold's to
|
|
1256
|
+
// destroy, and drafting it would force a pointless re-validation. Any
|
|
1257
|
+
// stage whose bytes actually changed no longer matches its receipt and
|
|
1258
|
+
// therefore lands on draft here (and reads as draft everywhere via I20).
|
|
1259
|
+
const receiptForFreshness = authoringValidationShapeValid(candidate.validation?.authoring_validation)
|
|
1260
|
+
? candidate.validation.authoring_validation
|
|
1261
|
+
: null;
|
|
1262
|
+
// One pass over the current bytes for all four stages; assertAuthoringFresh
|
|
1263
|
+
// already treats a non-`validated` enum as not fresh. Any unreadable
|
|
1264
|
+
// artifact fails closed to draft here, and the per-stage preconditions
|
|
1265
|
+
// below still report the missing file as the real error.
|
|
1266
|
+
let authoringFreshness = null;
|
|
1267
|
+
if (receiptForFreshness !== null && typeof evidence.specDir === 'string') {
|
|
1268
|
+
try {
|
|
1269
|
+
authoringFreshness = AUTHORING_DIGEST.assertAuthoringFresh({
|
|
1270
|
+
specDir: evidence.specDir,
|
|
1271
|
+
authoring: candidate.authoring,
|
|
1272
|
+
receipt: receiptForFreshness,
|
|
1273
|
+
fields: ['requirements', 'design', 'research', 'tasks'],
|
|
1274
|
+
});
|
|
1275
|
+
} catch {
|
|
1276
|
+
authoringFreshness = null;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
const carryForward = (field) => (authoringFreshness?.[field]?.fresh ? 'validated' : 'draft');
|
|
1280
|
+
const authoring = {};
|
|
1281
|
+
for (const field of ['requirements', 'design']) {
|
|
1282
|
+
if (!evidence[`${field}Exists`]) failPrecondition(`physical ${field}.md is required for schema 2.1 migration`);
|
|
1283
|
+
authoring[field] = carryForward(field);
|
|
1284
|
+
}
|
|
1285
|
+
if (evidence.researchExists) {
|
|
1286
|
+
const pointer = isPlainObject(candidate.research) ? candidate.research.path : candidate.research;
|
|
1287
|
+
if (pointer !== undefined && pointer !== null && pointer !== 'research.md') {
|
|
1288
|
+
failPrecondition('legacy research pointer is ambiguous; expected canonical research.md');
|
|
1289
|
+
}
|
|
1290
|
+
authoring.research = carryForward('research');
|
|
1291
|
+
} else {
|
|
1292
|
+
if (hasOwn(candidate, 'research') && candidate.research !== null) {
|
|
1293
|
+
failPrecondition('research pointer exists without a physical research.md artifact');
|
|
1294
|
+
}
|
|
1295
|
+
authoring.research = 'absent';
|
|
1296
|
+
}
|
|
1297
|
+
if (evidence.taskFiles.length > 0) {
|
|
1298
|
+
authoring.tasks = carryForward('tasks');
|
|
1299
|
+
} else {
|
|
1300
|
+
authoring.tasks = 'absent';
|
|
1301
|
+
}
|
|
1302
|
+
const projectedRegistry = {};
|
|
1303
|
+
for (const taskPath of evidence.taskFiles) {
|
|
1304
|
+
const entry = candidate.task_registry?.[taskPath];
|
|
1305
|
+
if (!isPlainObject(entry)) failPrecondition(`task_registry.${taskPath} must be an object`);
|
|
1306
|
+
projectedRegistry[taskPath] = {};
|
|
1307
|
+
for (const key of canonicalRegistryFields(entry)) {
|
|
1308
|
+
if (hasOwn(entry, key)) projectedRegistry[taskPath][key] = cloneJson(entry[key]);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
// semantic_review_history (C13) and authoring_validation (C16) are durable
|
|
1312
|
+
// records this migration step never wipes on its own: history is
|
|
1313
|
+
// append-only for the life of the spec record (I6 — lineage_id never
|
|
1314
|
+
// resets on an ordinary content edit) and the C16 receipt may only ever be
|
|
1315
|
+
// written by spec-authoring-validation.cjs (I21) — scaffold is not a
|
|
1316
|
+
// writer of either and only seeds a fresh skeleton when a pre-cutover
|
|
1317
|
+
// legacy spec never carried them at all.
|
|
1318
|
+
const historyValid = hasOwn(candidate.validation || {}, 'semantic_review_history')
|
|
1319
|
+
&& semanticReviewHistoryShapeValid(candidate.validation.semantic_review_history);
|
|
1320
|
+
const authoringValidationValid = hasOwn(candidate.validation || {}, 'authoring_validation')
|
|
1321
|
+
&& authoringValidationShapeValid(candidate.validation.authoring_validation);
|
|
1322
|
+
const validationValid = isPlainObject(candidate.validation)
|
|
1323
|
+
&& VALIDATION_STATUS_VALUES.has(candidate.validation.status)
|
|
1324
|
+
&& semanticReviewShapeValid(candidate.validation.semantic_review)
|
|
1325
|
+
&& historyValid
|
|
1326
|
+
&& authoringValidationValid;
|
|
1327
|
+
const readinessValid = typeof candidate.ready_for_implementation === 'boolean';
|
|
1328
|
+
const preservedHistory = historyValid
|
|
1329
|
+
? cloneJson(candidate.validation.semantic_review_history)
|
|
1330
|
+
: semanticReviewHistoryInitial(featureName, candidate.created_at);
|
|
1331
|
+
const preservedAuthoringValidation = authoringValidationValid
|
|
1332
|
+
? cloneJson(candidate.validation.authoring_validation)
|
|
1333
|
+
: null;
|
|
1334
|
+
const freshValidation = {
|
|
1335
|
+
status: 'not-run',
|
|
1336
|
+
semantic_review: semanticReviewNotRun(),
|
|
1337
|
+
semantic_review_history: preservedHistory,
|
|
1338
|
+
authoring_validation: preservedAuthoringValidation,
|
|
1339
|
+
};
|
|
1340
|
+
const next = {
|
|
1341
|
+
schema_version: '2.1',
|
|
1342
|
+
feature_name: featureName,
|
|
1343
|
+
created_at: candidate.created_at,
|
|
1344
|
+
updated_at: candidate.updated_at,
|
|
1345
|
+
language: candidate.language,
|
|
1346
|
+
status,
|
|
1347
|
+
scope_lock: scopeLock,
|
|
1348
|
+
authoring,
|
|
1349
|
+
coordination: cloneJson(evidence.coordination),
|
|
1350
|
+
validation: validationValid ? cloneJson(candidate.validation) : freshValidation,
|
|
1351
|
+
semantic_model: isLegacy ? null : cloneJson(candidate.semantic_model),
|
|
1352
|
+
ready_for_implementation: readinessValid ? candidate.ready_for_implementation : false,
|
|
1353
|
+
workflow_policy: cloneJson(evidence.workflowPolicy),
|
|
1354
|
+
...(evidence.researchExists ? { research: 'research.md' } : {}),
|
|
1355
|
+
...(evidence.taskFiles.length > 0 ? {
|
|
1356
|
+
task_files: [...evidence.taskFiles].sort(), task_registry: projectedRegistry,
|
|
1357
|
+
} : {}),
|
|
1358
|
+
};
|
|
1359
|
+
const semanticChanged = evidence.tasksChanged || evidence.researchChanged
|
|
1360
|
+
|| isLegacy || JSON.stringify(next) !== JSON.stringify(candidate);
|
|
1361
|
+
if (semanticChanged || !validationValid || !readinessValid) {
|
|
1362
|
+
next.ready_for_implementation = false;
|
|
1363
|
+
next.validation = freshValidation;
|
|
1364
|
+
next.semantic_model = null;
|
|
1365
|
+
}
|
|
1366
|
+
const changed = JSON.stringify(next) !== JSON.stringify(candidate);
|
|
1367
|
+
if (changed) next.updated_at = evidence.timestamp;
|
|
1368
|
+
preflightSpec21Candidate(next, evidence);
|
|
1369
|
+
return { ok: true, spec: next, changed };
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1372
|
+
}
|
|
353
1373
|
}
|
|
354
1374
|
|
|
355
1375
|
function validateDependencyCycles(taskFiles, registry) {
|
|
@@ -388,7 +1408,12 @@ function listExistingTaskFiles(workRoot, specDir) {
|
|
|
388
1408
|
|
|
389
1409
|
function readPersistedPolicy(spec) {
|
|
390
1410
|
if (hasOwn(spec, 'override_receipt')) {
|
|
391
|
-
|
|
1411
|
+
if (spec.override_receipt !== null) {
|
|
1412
|
+
failPrecondition('existing top-level override_receipt is unsupported; omit the field');
|
|
1413
|
+
}
|
|
1414
|
+
if (spec.schema_version !== '2.0') {
|
|
1415
|
+
failPrecondition('top-level override_receipt is only readable as inert null during schema 2.0 migration; omit the field');
|
|
1416
|
+
}
|
|
392
1417
|
}
|
|
393
1418
|
if (!hasOwn(spec, 'workflow_policy')) {
|
|
394
1419
|
const legacy = spec.design_context?.execution_tier;
|
|
@@ -398,6 +1423,11 @@ function readPersistedPolicy(spec) {
|
|
|
398
1423
|
: 'existing spec must contain a persisted workflow_policy snapshot',
|
|
399
1424
|
);
|
|
400
1425
|
}
|
|
1426
|
+
if (isPlainObject(spec.workflow_policy)
|
|
1427
|
+
&& hasOwn(spec.workflow_policy, 'override_receipt')
|
|
1428
|
+
&& spec.workflow_policy.override_receipt !== null) {
|
|
1429
|
+
failPrecondition('existing workflow_policy.override_receipt is unsupported; omit the field');
|
|
1430
|
+
}
|
|
401
1431
|
try {
|
|
402
1432
|
return POLICY.readWorkflowPolicySnapshot(spec);
|
|
403
1433
|
} catch (error) {
|
|
@@ -415,7 +1445,7 @@ function validateResearchTopology(workRoot, specDir, spec, needsResearch) {
|
|
|
415
1445
|
assertNoSymlinkPath(workRoot, pointerPath, 'spec.research path');
|
|
416
1446
|
if (!isWithin(specDir, pointerPath)) failPrecondition('spec.research path must stay inside the spec directory');
|
|
417
1447
|
if (needsResearch && safePointer !== canonical) {
|
|
418
|
-
failPrecondition('spec.research path must be research.md
|
|
1448
|
+
failPrecondition('spec.research path must be research.md for an explicit --research request');
|
|
419
1449
|
}
|
|
420
1450
|
assertRegularFileOrMissing(workRoot, pointerPath, 'spec.research path');
|
|
421
1451
|
}
|
|
@@ -462,7 +1492,7 @@ function inspectTaskTopology(workRoot, specDir, spec, parsed, artifactDeclaratio
|
|
|
462
1492
|
for (const task of parsed) {
|
|
463
1493
|
if (!existingSet.has(task.file)) continue;
|
|
464
1494
|
const entry = registry[task.file];
|
|
465
|
-
const expected = registryEntry(task,
|
|
1495
|
+
const expected = registryEntry(task, artifactDeclarations);
|
|
466
1496
|
if (entry.id !== expected.id || entry.title !== expected.title) {
|
|
467
1497
|
failPrecondition(`conflicting duplicate task ${task.raw}: existing registry entry differs`);
|
|
468
1498
|
}
|
|
@@ -504,20 +1534,191 @@ function parseFailureInjection() {
|
|
|
504
1534
|
return Number(raw);
|
|
505
1535
|
}
|
|
506
1536
|
|
|
507
|
-
function
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
1537
|
+
function parseCrashInjection() {
|
|
1538
|
+
const raw = process.env.CAFEKIT_SCAFFOLD_KILL_AFTER_RENAMES;
|
|
1539
|
+
if (raw === undefined) return null;
|
|
1540
|
+
if (!/^[1-9]\d*$/.test(raw)) {
|
|
1541
|
+
failPrecondition('CAFEKIT_SCAFFOLD_KILL_AFTER_RENAMES must be a positive integer');
|
|
1542
|
+
}
|
|
1543
|
+
return Number(raw);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function digest(bytes) {
|
|
1547
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function fsyncPath(filePath) {
|
|
1551
|
+
const fd = fs.openSync(filePath, 'r');
|
|
1552
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
function fsyncDirectory(directory) {
|
|
1556
|
+
try { fsyncPath(directory); } catch (error) {
|
|
1557
|
+
if (!['EINVAL', 'ENOTSUP', 'EISDIR'].includes(error.code)) throw error;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function fileDigestOrNull(filePath) {
|
|
1562
|
+
try {
|
|
1563
|
+
const stat = fs.lstatSync(filePath);
|
|
1564
|
+
if (!stat.isFile()) throw new Error(`transaction path is not a regular file: ${filePath}`);
|
|
1565
|
+
return digest(fs.readFileSync(filePath));
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
if (error.code === 'ENOENT') return null;
|
|
1568
|
+
throw error;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function processIsAlive(pid) {
|
|
1573
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) return pid === process.pid;
|
|
1574
|
+
try { process.kill(pid, 0); return true; } catch (error) {
|
|
1575
|
+
return error.code === 'EPERM';
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function validateTransactionManifest(workRoot, transactionDir, manifest) {
|
|
1580
|
+
if (!isPlainObject(manifest) || manifest.format !== TRANSACTION_FORMAT) {
|
|
1581
|
+
throw new Error(`unrecognized scaffold transaction manifest: ${transactionDir}`);
|
|
1582
|
+
}
|
|
1583
|
+
const transactionId = path.basename(transactionDir).slice(TRANSACTION_PREFIX.length);
|
|
1584
|
+
if (manifest.transaction_id !== transactionId || !/^[a-f0-9-]{20,}$/i.test(transactionId)) {
|
|
1585
|
+
throw new Error(`scaffold transaction identity mismatch: ${transactionDir}`);
|
|
1586
|
+
}
|
|
1587
|
+
if (path.resolve(manifest.work_root || '') !== workRoot) {
|
|
1588
|
+
throw new Error(`scaffold transaction work root mismatch: ${transactionDir}`);
|
|
1589
|
+
}
|
|
1590
|
+
const specRelative = safeRelativePath(manifest.spec_dir, 'transaction spec_dir');
|
|
1591
|
+
const specDir = path.resolve(workRoot, specRelative);
|
|
1592
|
+
if (!isWithin(workRoot, specDir) || !Array.isArray(manifest.entries) || manifest.entries.length === 0) {
|
|
1593
|
+
throw new Error(`invalid scaffold transaction topology: ${transactionDir}`);
|
|
1594
|
+
}
|
|
1595
|
+
const seen = new Set();
|
|
1596
|
+
for (const entry of manifest.entries) {
|
|
1597
|
+
if (!isPlainObject(entry)) throw new Error(`invalid scaffold transaction entry: ${transactionDir}`);
|
|
1598
|
+
const relativePath = safeRelativePath(entry.path, 'transaction entry path');
|
|
1599
|
+
if (seen.has(relativePath)) throw new Error(`duplicate scaffold transaction path: ${relativePath}`);
|
|
1600
|
+
seen.add(relativePath);
|
|
1601
|
+
if (!isPlainObject(entry.before) || typeof entry.before.exists !== 'boolean'
|
|
1602
|
+
|| (entry.before.exists && !/^[a-f0-9]{64}$/.test(entry.before.digest || ''))
|
|
1603
|
+
|| !/^[a-f0-9]{64}$/.test(entry.after_digest || '')) {
|
|
1604
|
+
throw new Error(`invalid scaffold transaction digest evidence: ${relativePath}`);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return { ...manifest, specDir };
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function removeEmptyTransactionParents(specDir, target) {
|
|
1611
|
+
let current = path.dirname(target);
|
|
1612
|
+
while (isWithin(specDir, current) && current !== path.dirname(specDir)) {
|
|
1613
|
+
try { fs.rmdirSync(current); } catch (error) {
|
|
1614
|
+
if (error.code === 'ENOENT') break;
|
|
1615
|
+
if (['ENOTEMPTY', 'EEXIST'].includes(error.code)) break;
|
|
1616
|
+
throw error;
|
|
1617
|
+
}
|
|
1618
|
+
if (current === specDir) break;
|
|
1619
|
+
current = path.dirname(current);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function recoverTransaction(workRoot, transactionDir, rawManifest) {
|
|
1624
|
+
const manifest = validateTransactionManifest(workRoot, transactionDir, rawManifest);
|
|
1625
|
+
if (processIsAlive(manifest.pid)) return false;
|
|
1626
|
+
for (let index = manifest.entries.length - 1; index >= 0; index -= 1) {
|
|
1627
|
+
const entry = manifest.entries[index];
|
|
1628
|
+
const target = path.join(manifest.specDir, entry.path);
|
|
1629
|
+
const backup = path.join(transactionDir, 'backup', entry.path);
|
|
1630
|
+
const targetDigest = fileDigestOrNull(target);
|
|
1631
|
+
const backupDigest = fileDigestOrNull(backup);
|
|
1632
|
+
if (entry.before.exists) {
|
|
1633
|
+
if (backupDigest !== null && backupDigest !== entry.before.digest) {
|
|
1634
|
+
throw new Error(`recovery cannot prove backup ownership for ${entry.path}`);
|
|
1635
|
+
}
|
|
1636
|
+
if (targetDigest === entry.after_digest) {
|
|
1637
|
+
removeTarget(target);
|
|
1638
|
+
} else if (targetDigest !== null && targetDigest !== entry.before.digest) {
|
|
1639
|
+
throw new Error(`recovery refuses to overwrite unowned target ${entry.path}`);
|
|
1640
|
+
}
|
|
1641
|
+
if (fileDigestOrNull(target) === null) {
|
|
1642
|
+
if (backupDigest !== entry.before.digest) {
|
|
1643
|
+
throw new Error(`recovery is missing the owned backup for ${entry.path}`);
|
|
1644
|
+
}
|
|
1645
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1646
|
+
fs.renameSync(backup, target);
|
|
1647
|
+
}
|
|
1648
|
+
} else if (targetDigest === entry.after_digest) {
|
|
1649
|
+
removeTarget(target);
|
|
1650
|
+
} else if (targetDigest !== null) {
|
|
1651
|
+
throw new Error(`recovery refuses to delete unowned target ${entry.path}`);
|
|
1652
|
+
}
|
|
1653
|
+
removeEmptyTransactionParents(manifest.specDir, target);
|
|
1654
|
+
}
|
|
1655
|
+
fs.rmSync(transactionDir, { recursive: true, force: true });
|
|
1656
|
+
fsyncDirectory(workRoot);
|
|
1657
|
+
return true;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
function recoverTransactions(workRoot) {
|
|
1661
|
+
let recovered = 0;
|
|
1662
|
+
for (const entry of fs.readdirSync(workRoot, { withFileTypes: true })) {
|
|
1663
|
+
if (!entry.name.startsWith(TRANSACTION_PREFIX)) continue;
|
|
1664
|
+
const transactionDir = path.join(workRoot, entry.name);
|
|
1665
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
1666
|
+
failPrecondition(`scaffold transaction identity is not a directory: ${transactionDir}`);
|
|
1667
|
+
}
|
|
1668
|
+
const manifestPath = path.join(transactionDir, 'manifest.json');
|
|
1669
|
+
let manifest;
|
|
1670
|
+
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (error) {
|
|
1671
|
+
failPrecondition(`scaffold transaction cannot be recovered (${error.message}): ${transactionDir}`);
|
|
1672
|
+
}
|
|
1673
|
+
try {
|
|
1674
|
+
if (recoverTransaction(workRoot, transactionDir, manifest)) recovered += 1;
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
failPrecondition(`scaffold transaction recovery stopped safely (${error.message})`);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
return recovered;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
function prepareTransaction(workRoot, specDir, changes, snapshots, transactionRoot, createSpecDir) {
|
|
1683
|
+
assertExistingDirectory(workRoot, transactionRoot, 'scaffold transaction directory');
|
|
1684
|
+
const transactionId = crypto.randomUUID();
|
|
1685
|
+
const transactionDir = path.join(transactionRoot, `${TRANSACTION_PREFIX}${transactionId}`);
|
|
1686
|
+
const stageDir = path.join(transactionDir, 'stage');
|
|
1687
|
+
const backupDir = path.join(transactionDir, 'backup');
|
|
1688
|
+
fs.mkdirSync(stageDir, { recursive: true });
|
|
1689
|
+
fs.mkdirSync(backupDir);
|
|
1690
|
+
const entries = [];
|
|
511
1691
|
try {
|
|
512
1692
|
for (const change of changes) {
|
|
513
1693
|
safeRelativePath(change.path, `staged path ${change.path}`);
|
|
514
1694
|
const staged = path.join(stageDir, change.path);
|
|
515
1695
|
fs.mkdirSync(path.dirname(staged), { recursive: true });
|
|
516
1696
|
fs.writeFileSync(staged, change.body, { encoding: 'utf8', flag: 'wx' });
|
|
1697
|
+
fsyncPath(staged);
|
|
1698
|
+
const before = snapshots.get(change.path);
|
|
1699
|
+
entries.push({
|
|
1700
|
+
path: change.path,
|
|
1701
|
+
before: { exists: before.exists, ...(before.exists ? { digest: digest(before.bytes) } : {}) },
|
|
1702
|
+
after_digest: digest(Buffer.from(change.body, 'utf8')),
|
|
1703
|
+
});
|
|
517
1704
|
}
|
|
518
|
-
|
|
1705
|
+
const manifest = {
|
|
1706
|
+
format: TRANSACTION_FORMAT,
|
|
1707
|
+
transaction_id: transactionId,
|
|
1708
|
+
pid: process.pid,
|
|
1709
|
+
work_root: workRoot,
|
|
1710
|
+
spec_dir: path.relative(workRoot, specDir),
|
|
1711
|
+
create_spec_dir: createSpecDir,
|
|
1712
|
+
entries,
|
|
1713
|
+
};
|
|
1714
|
+
const manifestPath = path.join(transactionDir, 'manifest.json');
|
|
1715
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
|
|
1716
|
+
fsyncPath(manifestPath);
|
|
1717
|
+
fsyncDirectory(transactionDir);
|
|
1718
|
+
fsyncDirectory(transactionRoot);
|
|
1719
|
+
return { transactionDir, stageDir, backupDir, manifest };
|
|
519
1720
|
} catch (error) {
|
|
520
|
-
fs.rmSync(
|
|
1721
|
+
fs.rmSync(transactionDir, { recursive: true, force: true });
|
|
521
1722
|
throw error;
|
|
522
1723
|
}
|
|
523
1724
|
}
|
|
@@ -538,6 +1739,21 @@ function ensureTargetParent(workRoot, specDir, relativePath, createdDirs) {
|
|
|
538
1739
|
}
|
|
539
1740
|
}
|
|
540
1741
|
|
|
1742
|
+
function ensureDirectoryTree(workRoot, directory, createdDirs, label) {
|
|
1743
|
+
assertNoSymlinkPath(workRoot, directory, label);
|
|
1744
|
+
const relative = path.relative(path.resolve(workRoot), path.resolve(directory));
|
|
1745
|
+
let current = path.resolve(workRoot);
|
|
1746
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
1747
|
+
current = path.join(current, segment);
|
|
1748
|
+
if (fs.existsSync(current)) {
|
|
1749
|
+
assertExistingDirectory(workRoot, current, label);
|
|
1750
|
+
} else {
|
|
1751
|
+
fs.mkdirSync(current);
|
|
1752
|
+
createdDirs.push(current);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
541
1757
|
function removeTarget(target) {
|
|
542
1758
|
try {
|
|
543
1759
|
const stat = fs.lstatSync(target);
|
|
@@ -548,40 +1764,32 @@ function removeTarget(target) {
|
|
|
548
1764
|
}
|
|
549
1765
|
}
|
|
550
1766
|
|
|
551
|
-
function
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
try {
|
|
563
|
-
fs.rmdirSync(createdDirs[index]);
|
|
564
|
-
} catch (error) {
|
|
565
|
-
if (error.code !== 'ENOENT') throw error;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
function commitChanges(workRoot, specDir, changes, snapshots, failureAfter) {
|
|
572
|
-
const stageDir = stageChanges(workRoot, specDir, changes);
|
|
573
|
-
const parent = path.dirname(specDir);
|
|
574
|
-
let backupDir;
|
|
575
|
-
try {
|
|
576
|
-
backupDir = fs.mkdtempSync(path.join(parent, '.cafekit-scaffold-backup-'));
|
|
577
|
-
} catch (error) {
|
|
578
|
-
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
579
|
-
throw error;
|
|
580
|
-
}
|
|
581
|
-
const applied = [];
|
|
1767
|
+
function commitChanges(
|
|
1768
|
+
workRoot,
|
|
1769
|
+
specDir,
|
|
1770
|
+
changes,
|
|
1771
|
+
snapshots,
|
|
1772
|
+
failureAfter,
|
|
1773
|
+
{ createSpecDir = false, afterApply = null, transactionRoot = path.dirname(specDir) } = {},
|
|
1774
|
+
) {
|
|
1775
|
+
const transaction = prepareTransaction(
|
|
1776
|
+
workRoot, specDir, changes, snapshots, transactionRoot, createSpecDir,
|
|
1777
|
+
);
|
|
582
1778
|
const createdDirs = [];
|
|
583
1779
|
let writes = 0;
|
|
1780
|
+
let renames = 0;
|
|
1781
|
+
const crashAfter = parseCrashInjection();
|
|
1782
|
+
const renameBoundary = (from, to) => {
|
|
1783
|
+
fs.renameSync(from, to);
|
|
1784
|
+
renames += 1;
|
|
1785
|
+
fsyncDirectory(path.dirname(to));
|
|
1786
|
+
if (crashAfter !== null && renames >= crashAfter) process.kill(process.pid, 'SIGKILL');
|
|
1787
|
+
};
|
|
584
1788
|
try {
|
|
1789
|
+
if (createSpecDir) {
|
|
1790
|
+
if (fs.existsSync(specDir)) failPrecondition(`spec dir already exists: ${specDir}`);
|
|
1791
|
+
ensureDirectoryTree(workRoot, specDir, createdDirs, 'spec directory');
|
|
1792
|
+
}
|
|
585
1793
|
for (const change of changes) {
|
|
586
1794
|
const target = path.join(specDir, change.path);
|
|
587
1795
|
const before = captureTarget(workRoot, specDir, change.path);
|
|
@@ -590,50 +1798,68 @@ function commitChanges(workRoot, specDir, changes, snapshots, failureAfter) {
|
|
|
590
1798
|
failPrecondition(`target changed during preflight: ${change.path}`);
|
|
591
1799
|
}
|
|
592
1800
|
ensureTargetParent(workRoot, specDir, change.path, createdDirs);
|
|
593
|
-
const
|
|
594
|
-
target,
|
|
595
|
-
backup: path.join(backupDir, change.path),
|
|
596
|
-
targetInstalled: false,
|
|
597
|
-
backupMoved: false,
|
|
598
|
-
};
|
|
599
|
-
applied.push(record);
|
|
1801
|
+
const backup = path.join(transaction.backupDir, change.path);
|
|
600
1802
|
if (before.exists) {
|
|
601
|
-
fs.mkdirSync(path.dirname(
|
|
602
|
-
|
|
603
|
-
record.backupMoved = true;
|
|
1803
|
+
fs.mkdirSync(path.dirname(backup), { recursive: true });
|
|
1804
|
+
renameBoundary(target, backup);
|
|
604
1805
|
}
|
|
605
|
-
|
|
606
|
-
record.targetInstalled = true;
|
|
1806
|
+
renameBoundary(path.join(transaction.stageDir, change.path), target);
|
|
607
1807
|
writes += 1;
|
|
608
1808
|
if (failureAfter !== null && writes >= failureAfter) {
|
|
609
1809
|
throw new Error(`injected failure after ${writes} staged write(s)`);
|
|
610
1810
|
}
|
|
611
1811
|
}
|
|
1812
|
+
if (typeof afterApply === 'function') afterApply();
|
|
612
1813
|
} catch (error) {
|
|
613
1814
|
let rollbackError = null;
|
|
614
1815
|
try {
|
|
615
|
-
|
|
1816
|
+
const manifest = { ...transaction.manifest, pid: -1 };
|
|
1817
|
+
recoverTransaction(workRoot, transaction.transactionDir, manifest);
|
|
616
1818
|
} catch (rollbackFailure) {
|
|
617
1819
|
rollbackError = rollbackFailure;
|
|
618
1820
|
}
|
|
619
|
-
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
620
1821
|
if (rollbackError) throw new Error(`${error.message}; rollback failed: ${rollbackError.message}`);
|
|
621
1822
|
throw new Error(`${error.message}; transaction rolled back`);
|
|
622
1823
|
}
|
|
623
|
-
fs.rmSync(
|
|
624
|
-
|
|
1824
|
+
fs.rmSync(transaction.transactionDir, { recursive: true, force: true });
|
|
1825
|
+
fsyncDirectory(transactionRoot);
|
|
625
1826
|
}
|
|
626
1827
|
|
|
627
1828
|
function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, parsed, artifactDeclarations, taskTpl, ts }) {
|
|
628
1829
|
const workRoot = path.resolve(process.cwd());
|
|
629
1830
|
assertExistingDirectory(workRoot, specDir, 'spec directory');
|
|
630
1831
|
assertRegularFileOrMissing(workRoot, specPath, 'spec.json');
|
|
1832
|
+
const requirementsPath = path.join(specDir, 'requirements.md');
|
|
1833
|
+
const designPath = path.join(specDir, 'design.md');
|
|
1834
|
+
assertRegularFileOrMissing(workRoot, requirementsPath, 'requirements.md');
|
|
1835
|
+
assertRegularFileOrMissing(workRoot, designPath, 'design.md');
|
|
631
1836
|
const topology = inspectTaskTopology(workRoot, specDir, spec, parsed, artifactDeclarations);
|
|
1837
|
+
const allTaskFiles = [...new Set([...topology.declared, ...parsed.map((task) => task.file)])].sort();
|
|
1838
|
+
if (args.taskTriggers !== null || args.coordination || args.dependencies !== null || args.artifacts !== null) {
|
|
1839
|
+
failPrecondition('schema 2.1 mutation uses --boundaries as the only topology authority');
|
|
1840
|
+
}
|
|
1841
|
+
const boundaries = parseBoundaryDeclarations(args.boundaries, allTaskFiles);
|
|
1842
|
+
if (boundaries.length === 0) {
|
|
1843
|
+
failPrecondition('--tasks-only requires a complete non-empty --boundaries array for the resulting task bundle');
|
|
1844
|
+
}
|
|
1845
|
+
const dependencyDeclarations = dependenciesFromBoundaries(boundaries, allTaskFiles);
|
|
1846
|
+
const phases = args.phases === null && Array.isArray(spec.coordination?.phases)
|
|
1847
|
+
? cloneJson(spec.coordination.phases)
|
|
1848
|
+
: parsePhaseDeclarations(args.phases, allTaskFiles);
|
|
1849
|
+
validatePhaseBoundaryRefs(phases, boundaries);
|
|
1850
|
+
for (const task of parsed) {
|
|
1851
|
+
if (!topology.existingSet.has(task.file) || !dependencyDeclarations.has(task.file)) continue;
|
|
1852
|
+
const existing = topology.registry[task.file].dependencies;
|
|
1853
|
+
const requested = dependencyDeclarations.get(task.file);
|
|
1854
|
+
if (JSON.stringify(existing) !== JSON.stringify(requested)) {
|
|
1855
|
+
failPrecondition(`conflicting duplicate task ${task.raw}: existing registry dependencies differ`);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
632
1858
|
|
|
633
|
-
let nextPolicy =
|
|
1859
|
+
let nextPolicy = POLICY.canonicalWorkflowPolicySnapshot(spec);
|
|
634
1860
|
if (args.risks && args.risks.length > 0) {
|
|
635
1861
|
try {
|
|
636
|
-
nextPolicy = POLICY.escalateWorkflowPolicy(
|
|
1862
|
+
nextPolicy = POLICY.escalateWorkflowPolicy(nextPolicy, { risks: args.risks });
|
|
637
1863
|
} catch (error) {
|
|
638
1864
|
failPrecondition(`--tasks-only cannot escalate workflow policy (${error.message})`);
|
|
639
1865
|
}
|
|
@@ -641,40 +1867,62 @@ function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, par
|
|
|
641
1867
|
if (!validation.valid) failPrecondition(`--tasks-only escalation produced an invalid workflow_policy (${validation.errors.join('; ')})`);
|
|
642
1868
|
}
|
|
643
1869
|
|
|
644
|
-
const needsResearch =
|
|
1870
|
+
const needsResearch = args.research === true;
|
|
645
1871
|
const researchPath = validateResearchTopology(workRoot, specDir, spec, needsResearch);
|
|
646
1872
|
const researchExists = fs.existsSync(researchPath);
|
|
647
1873
|
const researchTemplate = needsResearch && !researchExists
|
|
648
|
-
?
|
|
1874
|
+
? renderResearchTemplate(args.feature, args.uncertainty)
|
|
649
1875
|
: null;
|
|
650
1876
|
const newTasks = parsed.filter((task) => !topology.existingSet.has(task.file));
|
|
651
1877
|
const nextSpec = cloneJson(spec);
|
|
652
|
-
let specChanged = false;
|
|
653
1878
|
if (!topology.hasTaskTopology && newTasks.length > 0) {
|
|
654
1879
|
nextSpec.task_files = [];
|
|
655
1880
|
nextSpec.task_registry = {};
|
|
656
|
-
specChanged = true;
|
|
657
1881
|
}
|
|
658
1882
|
for (const task of newTasks) {
|
|
659
1883
|
nextSpec.task_files.push(task.file);
|
|
660
|
-
nextSpec.task_registry[task.file] = registryEntry(
|
|
661
|
-
|
|
1884
|
+
nextSpec.task_registry[task.file] = registryEntry(
|
|
1885
|
+
task,
|
|
1886
|
+
artifactDeclarations,
|
|
1887
|
+
dependencyDeclarations.get(task.file) || [],
|
|
1888
|
+
);
|
|
662
1889
|
}
|
|
663
|
-
if (
|
|
664
|
-
nextSpec.
|
|
665
|
-
specChanged = true;
|
|
1890
|
+
if (needsResearch && nextSpec.research !== 'research.md') {
|
|
1891
|
+
nextSpec.research = 'research.md';
|
|
666
1892
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
1893
|
+
nextSpec.task_files.sort();
|
|
1894
|
+
const normalized = normalizeSpec21Candidate(nextSpec, {
|
|
1895
|
+
featureName: args.feature,
|
|
1896
|
+
timestamp: ts,
|
|
1897
|
+
specDir,
|
|
1898
|
+
requirementsExists: fs.existsSync(requirementsPath),
|
|
1899
|
+
designExists: fs.existsSync(designPath),
|
|
1900
|
+
researchExists: researchExists || researchTemplate !== null,
|
|
1901
|
+
researchChanged: researchTemplate !== null,
|
|
1902
|
+
taskFiles: allTaskFiles,
|
|
1903
|
+
tasksChanged: newTasks.length > 0,
|
|
1904
|
+
coordination: { boundaries, ...(phases.length > 0 ? { phases } : {}) },
|
|
1905
|
+
workflowPolicy: nextPolicy,
|
|
1906
|
+
});
|
|
1907
|
+
if (!normalized.ok) failPrecondition(`schema 2.1 migration rejected candidate (${normalized.error})`);
|
|
1908
|
+
const normalizedSpec = normalized.spec;
|
|
1909
|
+
const specChanged = JSON.stringify(normalizedSpec) !== JSON.stringify(spec);
|
|
1910
|
+
if (specChanged && normalizedSpec.updated_at === spec.created_at && ts !== spec.created_at) {
|
|
1911
|
+
failPrecondition('schema 2.1 migration produced a stale updated_at timestamp');
|
|
670
1912
|
}
|
|
671
1913
|
|
|
672
1914
|
const changes = newTasks.map((task) => ({
|
|
673
1915
|
path: task.file,
|
|
674
|
-
body: fillTask(
|
|
1916
|
+
body: fillTask(
|
|
1917
|
+
taskTpl,
|
|
1918
|
+
task,
|
|
1919
|
+
args.feature,
|
|
1920
|
+
dependencyDeclarations.get(task.file) || [],
|
|
1921
|
+
taskHasDeclaredImplementationObligation(normalizedSpec, task.file),
|
|
1922
|
+
),
|
|
675
1923
|
}));
|
|
676
1924
|
if (researchTemplate !== null) changes.push({ path: 'research.md', body: researchTemplate });
|
|
677
|
-
if (specChanged) changes.push({ path: 'spec.json', body: `${JSON.stringify(
|
|
1925
|
+
if (specChanged) changes.push({ path: 'spec.json', body: `${JSON.stringify(normalizedSpec, null, 2)}\n` });
|
|
678
1926
|
if (changes.length === 0) return { createdTasks: [], researchCreated: false };
|
|
679
1927
|
|
|
680
1928
|
const expectedAbsent = new Set(newTasks.map((task) => task.file));
|
|
@@ -687,22 +1935,163 @@ function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, par
|
|
|
687
1935
|
}
|
|
688
1936
|
snapshots.set(change.path, snapshot);
|
|
689
1937
|
}
|
|
690
|
-
commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection()
|
|
1938
|
+
commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection(), {
|
|
1939
|
+
transactionRoot: workRoot,
|
|
1940
|
+
afterApply: () => observeInstalledPolicyBaseline({ args, specDir, spec: normalizedSpec }),
|
|
1941
|
+
});
|
|
691
1942
|
return { createdTasks: newTasks.map((task) => task.file), researchCreated: researchTemplate !== null };
|
|
692
1943
|
}
|
|
693
1944
|
|
|
1945
|
+
function syncSemanticModel({ workRoot, args, specDir, ts }) {
|
|
1946
|
+
assertExistingDirectory(workRoot, specDir, 'spec directory');
|
|
1947
|
+
const specPath = path.join(specDir, 'spec.json');
|
|
1948
|
+
assertRegularFileOrMissing(workRoot, specPath, 'spec.json');
|
|
1949
|
+
if (!fs.existsSync(specPath)) failPrecondition('--sync-semantic-model requires an existing spec.json');
|
|
1950
|
+
let spec;
|
|
1951
|
+
try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); }
|
|
1952
|
+
catch (error) { failPrecondition(`--sync-semantic-model spec.json is invalid JSON (${error.message})`); }
|
|
1953
|
+
if (!isPlainObject(spec) || spec.schema_version !== '2.1') failPrecondition('--sync-semantic-model accepts canonical schema 2.1 only');
|
|
1954
|
+
assertCanonicalSpecInputClosedWorld(spec, { allowLegacyReview: true });
|
|
1955
|
+
const projection = SEMANTIC.modelFromMarkdown(specDir, spec);
|
|
1956
|
+
if (projection.errors.length) failPrecondition(`semantic projection is invalid (${projection.errors.join('; ')})`);
|
|
1957
|
+
const modelUnchanged = SEMANTIC.stableJson(spec.semantic_model) === SEMANTIC.stableJson(projection.model);
|
|
1958
|
+
const lifecycleCanonical = isPlainObject(spec.validation)
|
|
1959
|
+
&& Object.keys(spec.validation).sort().join(',') === [...VALIDATION_FIELDS].sort().join(',')
|
|
1960
|
+
&& semanticReviewShapeValid(spec.validation.semantic_review)
|
|
1961
|
+
&& semanticReviewHistoryShapeValid(spec.validation.semantic_review_history)
|
|
1962
|
+
&& authoringValidationShapeValid(spec.validation.authoring_validation);
|
|
1963
|
+
if (modelUnchanged && lifecycleCanonical) {
|
|
1964
|
+
console.log(`SEMANTIC_MODEL_UNCHANGED ${path.relative(process.cwd(), specDir) || specDir}`);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
const next = cloneJson(spec);
|
|
1968
|
+
next.semantic_model = projection.model;
|
|
1969
|
+
next.updated_at = ts;
|
|
1970
|
+
next.ready_for_implementation = false;
|
|
1971
|
+
if (!lifecycleCanonical) {
|
|
1972
|
+
for (const stage of ['requirements', 'design', 'research', 'tasks']) {
|
|
1973
|
+
if (next.authoring?.[stage] === 'validated') next.authoring[stage] = 'draft';
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
// Only the ephemeral semantic_review pointer is invalidated by a projection
|
|
1977
|
+
// resync; semantic_review_history (C13) and authoring_validation (C16) are
|
|
1978
|
+
// durable records this command never writes and must carry forward as-is.
|
|
1979
|
+
next.validation = {
|
|
1980
|
+
status: 'not-run',
|
|
1981
|
+
semantic_review: semanticReviewNotRun(),
|
|
1982
|
+
semantic_review_history: hasOwn(spec.validation, 'semantic_review_history')
|
|
1983
|
+
&& semanticReviewHistoryShapeValid(spec.validation.semantic_review_history)
|
|
1984
|
+
? cloneJson(spec.validation.semantic_review_history)
|
|
1985
|
+
: semanticReviewHistoryInitial(args.feature, spec.created_at),
|
|
1986
|
+
authoring_validation: hasOwn(spec.validation, 'authoring_validation')
|
|
1987
|
+
&& authoringValidationShapeValid(spec.validation.authoring_validation)
|
|
1988
|
+
? cloneJson(spec.validation.authoring_validation)
|
|
1989
|
+
: null,
|
|
1990
|
+
};
|
|
1991
|
+
preflightSpec21Candidate(next, {
|
|
1992
|
+
featureName: args.feature,
|
|
1993
|
+
requirementsExists: fs.existsSync(path.join(specDir, 'requirements.md')),
|
|
1994
|
+
designExists: fs.existsSync(path.join(specDir, 'design.md')),
|
|
1995
|
+
researchExists: fs.existsSync(path.join(specDir, 'research.md')),
|
|
1996
|
+
taskFiles: listExistingTaskFiles(workRoot, specDir),
|
|
1997
|
+
});
|
|
1998
|
+
const snapshot = captureTarget(workRoot, specDir, 'spec.json');
|
|
1999
|
+
commitChanges(
|
|
2000
|
+
workRoot,
|
|
2001
|
+
specDir,
|
|
2002
|
+
[{ path: 'spec.json', body: `${JSON.stringify(next, null, 2)}\n` }],
|
|
2003
|
+
new Map([['spec.json', snapshot]]),
|
|
2004
|
+
parseFailureInjection(),
|
|
2005
|
+
{ transactionRoot: workRoot },
|
|
2006
|
+
);
|
|
2007
|
+
console.log(`SEMANTIC_MODEL_SYNCED ${path.relative(process.cwd(), specDir) || specDir}`);
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// Version lives in a different file depending on where this script runs: an
|
|
2011
|
+
// installed adapter carries `<adapter>/cafekit.json`, the source tree carries
|
|
2012
|
+
// `packages/spec/package.json`. Read them with fs rather than require so the
|
|
2013
|
+
// runtime dependency closure — which may only copy files under src/claude —
|
|
2014
|
+
// stays satisfiable in an installed runtime.
|
|
2015
|
+
function readVersion() {
|
|
2016
|
+
const candidates = [
|
|
2017
|
+
path.resolve(__dirname, '..', 'cafekit.json'),
|
|
2018
|
+
path.resolve(__dirname, '..', '..', '..', 'package.json'),
|
|
2019
|
+
];
|
|
2020
|
+
for (const candidate of candidates) {
|
|
2021
|
+
try {
|
|
2022
|
+
const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
2023
|
+
if (typeof parsed.version === 'string' && parsed.version) return parsed.version;
|
|
2024
|
+
} catch {
|
|
2025
|
+
// try the next location
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
return 'unknown';
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// --help/--version answer before any precondition runs: a caller asking what
|
|
2032
|
+
// this command is must not be told their help flag is an invalid feature name,
|
|
2033
|
+
// and must not trigger transaction recovery as a side effect.
|
|
2034
|
+
function respondToInfoFlags(argv) {
|
|
2035
|
+
const flags = argv.slice(2);
|
|
2036
|
+
if (flags.includes('--help') || flags.includes('-h')) {
|
|
2037
|
+
console.log(USAGE_TEXT);
|
|
2038
|
+
return true;
|
|
2039
|
+
}
|
|
2040
|
+
if (flags.includes('--version') || flags.includes('-v')) {
|
|
2041
|
+
console.log(readVersion());
|
|
2042
|
+
return true;
|
|
2043
|
+
}
|
|
2044
|
+
return false;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
694
2047
|
function main() {
|
|
2048
|
+
if (respondToInfoFlags(process.argv)) return;
|
|
2049
|
+
const workRoot = path.resolve(process.cwd());
|
|
2050
|
+
const recoveredTransactions = recoverTransactions(workRoot);
|
|
2051
|
+
if (recoveredTransactions > 0) {
|
|
2052
|
+
console.error(`recovered ${recoveredTransactions} interrupted scaffold transaction(s)`);
|
|
2053
|
+
}
|
|
695
2054
|
const args = parseArgs(process.argv);
|
|
696
|
-
if (
|
|
2055
|
+
if (args.syncSemanticModel) {
|
|
2056
|
+
if (!args.feature || args.tasks !== null || args.tasksOnly || args.boundaries !== null || args.research
|
|
2057
|
+
|| args.planningDepth || args.assuranceLevel || args.lane) {
|
|
2058
|
+
failPrecondition('--sync-semantic-model is an isolated explicit promotion mode and cannot be combined with authoring mutations');
|
|
2059
|
+
}
|
|
2060
|
+
safeRelativePath(args.specsRoot, '--specs-root');
|
|
2061
|
+
safeRelativePath(args.feature, 'feature');
|
|
2062
|
+
const specDir = path.resolve(workRoot, args.specsRoot, args.feature);
|
|
2063
|
+
if (!isWithin(workRoot, specDir)) failPrecondition(`spec directory escapes the work root: ${specDir}`);
|
|
2064
|
+
syncSemanticModel({ workRoot, args, specDir, ts: nowIso() });
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
if (!args.feature || (args.tasks === null && args.tasksOnly) || (args.coordination && !args.tasks)) {
|
|
697
2068
|
usage();
|
|
698
2069
|
failPrecondition('missing feature or required task arguments');
|
|
699
2070
|
}
|
|
2071
|
+
if (args.research && !concreteUncertainty(args.uncertainty)) {
|
|
2072
|
+
failPrecondition('--research requires --uncertainty with one concrete unresolved question (at least 12 trimmed characters)');
|
|
2073
|
+
}
|
|
2074
|
+
if (!args.research && args.uncertainty !== null) {
|
|
2075
|
+
failPrecondition('--uncertainty is only valid with --research');
|
|
2076
|
+
}
|
|
700
2077
|
if (typeof args.lane === 'string' && args.lane.toLowerCase() === 'direct') {
|
|
701
2078
|
failPrecondition('Direct lane does not create a spec; run targeted verification from the Direct workflow instead');
|
|
702
2079
|
}
|
|
703
2080
|
if (args.lane && !POLICY.LANES.includes(args.lane)) {
|
|
704
2081
|
failPrecondition(`--lane must be one of ${POLICY.LANES.join(', ')}`);
|
|
705
2082
|
}
|
|
2083
|
+
if (args.planningDepth && !POLICY.PLANNING_DEPTHS.includes(args.planningDepth)) {
|
|
2084
|
+
failPrecondition(`--planning-depth must be one of ${POLICY.PLANNING_DEPTHS.join(', ')}`);
|
|
2085
|
+
}
|
|
2086
|
+
if (args.planningDepth === 'None') {
|
|
2087
|
+
failPrecondition('None planning depth does not create a durable spec; use the Direct workflow instead');
|
|
2088
|
+
}
|
|
2089
|
+
if (args.assuranceLevel && !POLICY.ASSURANCE_LEVELS.includes(args.assuranceLevel)) {
|
|
2090
|
+
failPrecondition(`--assurance-level must be one of ${POLICY.ASSURANCE_LEVELS.join(', ')}`);
|
|
2091
|
+
}
|
|
2092
|
+
if (args.lane && (args.planningDepth || args.assuranceLevel)) {
|
|
2093
|
+
failPrecondition('--lane is a compatibility adapter and cannot be combined with Specs v2 axis flags');
|
|
2094
|
+
}
|
|
706
2095
|
|
|
707
2096
|
const ids = (args.tasks || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
708
2097
|
if (args.tasksOnly && ids.length === 0) {
|
|
@@ -720,10 +2109,29 @@ function main() {
|
|
|
720
2109
|
seenIds.add(logicalId);
|
|
721
2110
|
parsed.push({ raw: id, req: m[1], seq: m[2], slug: m[3], file: `tasks/task-${id}.md` });
|
|
722
2111
|
}
|
|
2112
|
+
if (args.phases !== null && parsed.length === 0) {
|
|
2113
|
+
failPrecondition('--phases requires --tasks because phase grouping needs a task graph');
|
|
2114
|
+
}
|
|
2115
|
+
if (args.phases !== null && args.planningDepth !== 'Full') {
|
|
2116
|
+
failPrecondition('--phases requires explicit --planning-depth Full before any scaffold writes');
|
|
2117
|
+
}
|
|
723
2118
|
const artifactDeclarations = parseArtifactDeclarations(args.artifacts, parsed);
|
|
2119
|
+
if (args.taskTriggers !== null || args.coordination || args.dependencies !== null || args.artifacts !== null) {
|
|
2120
|
+
failPrecondition('schema 2.1 authoring uses --boundaries as the only topology authority; task triggers, coordination markers, dependencies, and artifact maps are read compatibility only');
|
|
2121
|
+
}
|
|
2122
|
+
const boundaries = args.tasksOnly ? null : parseBoundaryDeclarations(
|
|
2123
|
+
args.boundaries,
|
|
2124
|
+
parsed.map((task) => task.file).sort(),
|
|
2125
|
+
);
|
|
2126
|
+
const dependencyDeclarations = args.tasksOnly
|
|
2127
|
+
? null
|
|
2128
|
+
: dependenciesFromBoundaries(boundaries, parsed.map((task) => task.file).sort());
|
|
724
2129
|
const riskNames = parseRiskNames(args.risks);
|
|
2130
|
+
const phases = args.tasksOnly
|
|
2131
|
+
? []
|
|
2132
|
+
: parsePhaseDeclarations(args.phases, parsed.map((task) => task.file));
|
|
2133
|
+
if (!args.tasksOnly) validatePhaseBoundaryRefs(phases, boundaries);
|
|
725
2134
|
|
|
726
|
-
const workRoot = path.resolve(process.cwd());
|
|
727
2135
|
safeRelativePath(args.specsRoot, '--specs-root');
|
|
728
2136
|
safeRelativePath(args.feature, 'feature');
|
|
729
2137
|
if (!FEATURE_NAME_RE.test(args.feature)) {
|
|
@@ -750,7 +2158,12 @@ function main() {
|
|
|
750
2158
|
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
751
2159
|
failPrecondition('--tasks-only existing spec.json must contain a JSON object');
|
|
752
2160
|
}
|
|
2161
|
+
assertCanonicalSpecInputClosedWorld(spec);
|
|
2162
|
+
if (spec.schema_version === '2.0') assertLegacyMigrationClosedWorld(spec);
|
|
753
2163
|
const persistedPolicy = readPersistedPolicy(spec);
|
|
2164
|
+
if (args.planningDepth && args.planningDepth !== persistedPolicy.planning_depth) {
|
|
2165
|
+
failPrecondition(`--tasks-only --planning-depth ${args.planningDepth} does not match persisted workflow_policy.planning_depth ${persistedPolicy.planning_depth}`);
|
|
2166
|
+
}
|
|
754
2167
|
if (args.lane && args.lane !== persistedPolicy.lane) {
|
|
755
2168
|
failPrecondition(`--tasks-only --lane ${args.lane} does not match persisted workflow_policy.lane ${persistedPolicy.lane}; omit --lane or use the persisted lane (downgrade is not allowed)`);
|
|
756
2169
|
}
|
|
@@ -769,89 +2182,131 @@ function main() {
|
|
|
769
2182
|
const relTo = path.relative(process.cwd(), specDir) || specDir;
|
|
770
2183
|
console.log(`SCAFFOLDED (tasks-only) ${relTo}`);
|
|
771
2184
|
console.log(`- ${createdTasks.length} new task stub(s); task_files + task_registry merged.`);
|
|
772
|
-
if (result.researchCreated) console.log('- research.md created
|
|
2185
|
+
if (result.researchCreated) console.log('- research.md created by explicit --research request.');
|
|
773
2186
|
console.log(`- NEXT: Edit-fill {{...}} in each stub; set dependencies; run validator + spec-ground.`);
|
|
774
2187
|
return;
|
|
775
2188
|
}
|
|
776
2189
|
|
|
777
|
-
const policyInput = args.lane
|
|
2190
|
+
const policyInput = args.lane
|
|
2191
|
+
? { risks: riskNames, override: args.lane }
|
|
2192
|
+
: {
|
|
2193
|
+
risks: riskNames,
|
|
2194
|
+
...(args.planningDepth ? { planning_depth: args.planningDepth } : {}),
|
|
2195
|
+
...(args.assuranceLevel ? { assurance_level: args.assuranceLevel } : {}),
|
|
2196
|
+
};
|
|
778
2197
|
let selectedPolicy;
|
|
779
2198
|
try {
|
|
780
2199
|
selectedPolicy = POLICY.workflowPolicySnapshot(policyInput);
|
|
781
2200
|
} catch (error) {
|
|
782
2201
|
failPrecondition(error.message);
|
|
783
2202
|
}
|
|
784
|
-
if (
|
|
785
|
-
|
|
786
|
-
|
|
2203
|
+
if (parsed.length > 0 && boundaries.length === 0) {
|
|
2204
|
+
failPrecondition('physical task bundle requires at least one typed coordination.boundaries entry with semantic evidence');
|
|
2205
|
+
}
|
|
2206
|
+
if (parsed.length === 0 && args.boundaries !== null) {
|
|
2207
|
+
failPrecondition('--boundaries requires --tasks');
|
|
787
2208
|
}
|
|
788
|
-
|
|
789
2209
|
if (fs.existsSync(specDir)) {
|
|
790
2210
|
console.error(`precondition: spec dir already exists, refusing to overwrite: ${specDir}`);
|
|
791
2211
|
process.exit(2);
|
|
792
2212
|
}
|
|
793
2213
|
|
|
794
|
-
fs.mkdirSync(specDir, { recursive: true });
|
|
795
|
-
if (parsed.length > 0) {
|
|
796
|
-
fs.mkdirSync(path.join(specDir, 'tasks'), { recursive: true });
|
|
797
|
-
}
|
|
798
|
-
|
|
799
2214
|
// --- spec.json (from spec-state.json template) ---
|
|
800
2215
|
const spec = JSON.parse(readTemplate('spec-state.json'));
|
|
801
2216
|
spec.feature_name = args.feature;
|
|
802
2217
|
spec.created_at = ts;
|
|
803
2218
|
spec.updated_at = ts;
|
|
804
2219
|
spec.language = args.lang;
|
|
805
|
-
spec.timestamps = spec.timestamps || {};
|
|
806
|
-
spec.timestamps.init = ts;
|
|
807
2220
|
spec.scope_lock = spec.scope_lock || {};
|
|
808
2221
|
spec.scope_lock.source = args.title || `{{PROJECT_DESCRIPTION}}`;
|
|
809
2222
|
delete spec.workflow_policy;
|
|
810
2223
|
delete spec.override_receipt;
|
|
811
2224
|
Object.assign(spec, POLICY.persistWorkflowPolicySnapshot(spec, policyInput));
|
|
2225
|
+
spec.validation = isPlainObject(spec.validation) ? spec.validation : {};
|
|
2226
|
+
spec.validation.semantic_review = semanticReviewNotRun();
|
|
2227
|
+
spec.validation.semantic_review_history = semanticReviewHistoryInitial(args.feature, ts);
|
|
2228
|
+
spec.validation.authoring_validation = null;
|
|
2229
|
+
if (args.research) {
|
|
2230
|
+
spec.research = 'research.md';
|
|
2231
|
+
spec.authoring.research = 'draft';
|
|
2232
|
+
}
|
|
2233
|
+
spec.coordination = {
|
|
2234
|
+
boundaries,
|
|
2235
|
+
...(phases.length > 0 ? { phases } : {}),
|
|
2236
|
+
};
|
|
812
2237
|
if (parsed.length > 0) {
|
|
2238
|
+
spec.authoring.tasks = 'draft';
|
|
813
2239
|
spec.task_files = parsed.map((t) => t.file);
|
|
814
2240
|
spec.task_registry = {};
|
|
815
|
-
for (const t of parsed)
|
|
2241
|
+
for (const t of parsed) {
|
|
2242
|
+
spec.task_registry[t.file] = registryEntry(
|
|
2243
|
+
t,
|
|
2244
|
+
artifactDeclarations,
|
|
2245
|
+
dependencyDeclarations.get(t.file) || [],
|
|
2246
|
+
);
|
|
2247
|
+
}
|
|
816
2248
|
} else {
|
|
817
2249
|
delete spec.task_files;
|
|
818
2250
|
delete spec.task_registry;
|
|
819
2251
|
}
|
|
820
|
-
|
|
821
|
-
|
|
2252
|
+
if (args.research) spec.authoring.research = 'draft';
|
|
2253
|
+
preflightSpec21Candidate(spec, {
|
|
2254
|
+
featureName: args.feature,
|
|
2255
|
+
requirementsExists: true,
|
|
2256
|
+
designExists: true,
|
|
2257
|
+
researchExists: args.research,
|
|
2258
|
+
taskFiles: parsed.map((task) => task.file),
|
|
2259
|
+
});
|
|
2260
|
+
const changes = [{ path: 'spec.json', body: `${JSON.stringify(spec, null, 2)}\n` }];
|
|
822
2261
|
created.push('spec.json');
|
|
823
2262
|
|
|
824
2263
|
// --- doc templates (placeholders left for the model to fill) ---
|
|
825
2264
|
const docs = [
|
|
826
2265
|
['requirements.md', 'requirements.md'],
|
|
827
|
-
...(
|
|
828
|
-
? [['research.md', 'research.md']]
|
|
829
|
-
: []),
|
|
2266
|
+
...(args.research ? [['research.md', 'research.md']] : []),
|
|
830
2267
|
['design.md', 'design.md'],
|
|
831
2268
|
];
|
|
832
2269
|
for (const [tpl, out] of docs) {
|
|
833
|
-
const
|
|
834
|
-
|
|
2270
|
+
const source = tpl === 'research.md'
|
|
2271
|
+
? renderResearchTemplate(args.feature, args.uncertainty)
|
|
2272
|
+
: readTemplate(tpl);
|
|
2273
|
+
const body = applyExecutionClosureProfile(
|
|
2274
|
+
source,
|
|
2275
|
+
tpl === 'design.md'
|
|
2276
|
+
&& (selectedPolicy.lane === 'Critical' || declaredImplementationObligations(spec).length > 0),
|
|
2277
|
+
).replace(/\{\{FEATURE_NAME\}\}/g, args.feature);
|
|
2278
|
+
changes.push({ path: out, body });
|
|
835
2279
|
created.push(out);
|
|
836
2280
|
}
|
|
837
2281
|
|
|
838
|
-
fs.writeFileSync(
|
|
839
|
-
path.join(specDir, 'feature-receipt.md'),
|
|
840
|
-
'# Feature Verification Receipt\n\nVerification: PENDING\n\nStatus: in_progress\n\nBlocker: awaiting execution proof\n',
|
|
841
|
-
);
|
|
842
|
-
created.push('feature-receipt.md');
|
|
843
|
-
|
|
844
2282
|
// --- task stubs (fill the cheap placeholders; leave the rest) ---
|
|
845
2283
|
for (const t of parsed) {
|
|
846
|
-
|
|
2284
|
+
changes.push({
|
|
2285
|
+
path: t.file,
|
|
2286
|
+
body: fillTask(
|
|
2287
|
+
taskTpl,
|
|
2288
|
+
t,
|
|
2289
|
+
args.feature,
|
|
2290
|
+
dependencyDeclarations.get(t.file) || [],
|
|
2291
|
+
taskHasDeclaredImplementationObligation(spec, t.file),
|
|
2292
|
+
),
|
|
2293
|
+
});
|
|
847
2294
|
created.push(t.file);
|
|
848
2295
|
}
|
|
849
2296
|
|
|
2297
|
+
const snapshots = new Map();
|
|
2298
|
+
for (const change of changes) snapshots.set(change.path, captureTarget(workRoot, specDir, change.path));
|
|
2299
|
+
commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection(), {
|
|
2300
|
+
createSpecDir: true,
|
|
2301
|
+
transactionRoot: workRoot,
|
|
2302
|
+
afterApply: () => observeInstalledPolicyBaseline({ args, specDir, spec }),
|
|
2303
|
+
});
|
|
2304
|
+
|
|
850
2305
|
const rel = path.relative(process.cwd(), specDir) || specDir;
|
|
851
2306
|
console.log(`SCAFFOLDED ${rel}`);
|
|
852
|
-
console.log(`- ${created.length} files created: spec.json + ${docs.length} docs +
|
|
2307
|
+
console.log(`- ${created.length} files created: spec.json + ${docs.length} docs + ${parsed.length} task stub(s)`);
|
|
853
2308
|
if (parsed.length > 0) console.log(`- task_files + task_registry pre-populated (${parsed.length} entries, all pending)`);
|
|
854
|
-
console.log('- NEXT: Fill all spec placeholders (do NOT leave any); run validator + spec-ground before ready
|
|
2309
|
+
console.log('- NEXT: Fill all spec placeholders (do NOT leave any); run validator + spec-ground before ready.');
|
|
855
2310
|
}
|
|
856
2311
|
|
|
857
2312
|
try {
|