@haposoft/cafekit 0.16.0-rc.3 → 0.16.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/__tests__/codex-hooks.test.js +444 -14
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +615 -185
- 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 +687 -13
- package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +1914 -221
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +1 -1
- 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 +291 -58
- package/src/claude/hooks/__tests__/privacy-block.test.js +172 -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 +77 -30
- 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/spec-final-state.cjs +244 -0
- package/src/claude/scripts/spec-ground.cjs +535 -173
- package/src/claude/scripts/spec-readiness.cjs +118 -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 +1354 -139
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +2782 -142
- 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 +200 -260
- 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 +23 -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 +38 -16
- 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 +1 -1
- package/src/codex/hooks/privacy-block.cjs +67 -11
- 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,52 @@
|
|
|
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 PATH_SAFETY = require('../hooks/lib/runtime-path-safety.cjs');
|
|
26
32
|
|
|
27
33
|
const TEMPLATES = path.join(__dirname, '..', 'skills', 'specs', 'templates');
|
|
28
|
-
|
|
34
|
+
|
|
35
|
+
function resolveTemplatePath(name) {
|
|
36
|
+
const adapterRoot = path.resolve(__dirname, '..');
|
|
37
|
+
const projectRoot = path.resolve(adapterRoot, '..');
|
|
38
|
+
const candidates = [
|
|
39
|
+
{ file: path.join(TEMPLATES, name), root: adapterRoot },
|
|
40
|
+
// Packed Codex: <project>/.codex/scripts/spec-scaffold.cjs -> <project>/.agents/skills/specs/templates
|
|
41
|
+
{
|
|
42
|
+
file: path.resolve(__dirname, '..', '..', '.agents', 'skills', 'specs', 'templates', name),
|
|
43
|
+
root: projectRoot,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
for (const candidate of candidates) {
|
|
47
|
+
try {
|
|
48
|
+
return PATH_SAFETY.canonicalRegularFile(candidate.root, candidate.file, 'spec template');
|
|
49
|
+
} catch (e) {
|
|
50
|
+
if (e.code !== 'ENOENT' && !/cannot be inspected \(ENOENT\)/.test(e.message)) {
|
|
51
|
+
throw new Error(`template check failed: ${e.message} (${candidate.file})`);
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`template not found: ${name} (checked ${candidates.map((item) => item.file).join(', ')})`);
|
|
57
|
+
}
|
|
58
|
+
const TASK_ID_RE = /^R(\d+)-(\d{2})-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
29
59
|
const TASK_PATH_RE = /^tasks\/task-(R\d+-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
|
|
30
60
|
const FEATURE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
61
|
+
const EXECUTION_CLOSURE_BLOCK_RE = /<!-- EXECUTION_CLOSURE_START -->[\s\S]*?<!-- EXECUTION_CLOSURE_END -->\n?/g;
|
|
31
62
|
const REGISTRY_KEYS = [
|
|
32
63
|
'id',
|
|
33
64
|
'title',
|
|
@@ -38,11 +69,47 @@ const REGISTRY_KEYS = [
|
|
|
38
69
|
'completed_at',
|
|
39
70
|
'last_updated_at',
|
|
40
71
|
];
|
|
72
|
+
const AUTHORING_FIELDS = ['requirements', 'design', 'research', 'tasks'];
|
|
73
|
+
const AUTHORING_STATES = new Set(['draft', 'validated', 'absent']);
|
|
74
|
+
const SPEC_STATUS_VALUES = new Set(['in_progress', 'paused', 'blocked', 'done']);
|
|
75
|
+
const TASK_STATUS_VALUES = new Set(['pending', 'in_progress', 'blocked', 'done']);
|
|
76
|
+
const VALIDATION_STATUS_VALUES = new Set(['not-run', 'in_progress', 'completed']);
|
|
77
|
+
const CANONICAL_SPEC_FIELDS = new Set([
|
|
78
|
+
'schema_version', 'feature_name', 'created_at', 'updated_at', 'language', 'status',
|
|
79
|
+
'scope_lock', 'authoring', 'coordination', 'validation', 'semantic_model', 'ready_for_implementation',
|
|
80
|
+
'workflow_policy', 'research', 'task_files', 'task_registry', 'decisions',
|
|
81
|
+
]);
|
|
82
|
+
const SCOPE_LOCK_FIELDS = ['source', 'in_scope', 'out_of_scope', 'expansion_policy'];
|
|
83
|
+
const COORDINATION_FIELDS = new Set(['boundaries', 'phases']);
|
|
84
|
+
const VALIDATION_FIELDS = ['status', 'semantic_review'];
|
|
85
|
+
const SEMANTIC_REVIEW_FIELDS = ['status', 'semantic_digest', 'reviewed_criteria', 'counterexamples'];
|
|
86
|
+
const LEGACY_SEMANTIC_REVIEW_FIELDS = [...SEMANTIC_REVIEW_FIELDS, 'reviewed_artifact_digest'];
|
|
87
|
+
const LEGACY_SPEC_FIELDS = [
|
|
88
|
+
...CANONICAL_SPEC_FIELDS,
|
|
89
|
+
'approvals', 'current_phase', 'override_receipt', 'timestamps',
|
|
90
|
+
];
|
|
91
|
+
const LEGACY_COORDINATION_FIELDS = [
|
|
92
|
+
...COORDINATION_FIELDS,
|
|
93
|
+
'tasks_required', 'phases_required', 'reason', 'task_triggers',
|
|
94
|
+
];
|
|
95
|
+
const LEGACY_TIMESTAMP_FIELDS = [
|
|
96
|
+
'init', 'requirements_done', 'research_done', 'design_done', 'tasks_done',
|
|
97
|
+
'code_done', 'test_done', 'review_done', 'validation_done',
|
|
98
|
+
];
|
|
99
|
+
const LEGACY_APPROVAL_FIELDS = ['requirements', 'design', 'research', 'tasks'];
|
|
100
|
+
const LEGACY_APPROVAL_STATE_FIELDS = ['generated', 'agent_validated', 'user_approved'];
|
|
101
|
+
const LEGACY_POLICY_FIELDS = [
|
|
102
|
+
'version', 'planning_depth', 'automatic_planning_depth', 'assurance_level',
|
|
103
|
+
'automatic_assurance_level', 'lane', 'automatic_lane', 'risks', 'artifact_profile',
|
|
104
|
+
'planning_obligations', 'proof_obligations', 'actor_needs', 'classified_minimum', 'override_receipt',
|
|
105
|
+
];
|
|
106
|
+
const TRANSACTION_PREFIX = '.cafekit-scaffold-tx-';
|
|
107
|
+
const TRANSACTION_FORMAT = 'cafekit-spec-scaffold-transaction/1';
|
|
41
108
|
|
|
42
109
|
class ScaffoldPreconditionError extends Error {}
|
|
43
110
|
|
|
44
111
|
function usage() {
|
|
45
|
-
console.error('Usage: node spec-scaffold.cjs <feature> [--tasks "R0-01-slug,R1-01-slug"] [--lane Standard|Critical] [--
|
|
112
|
+
console.error('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]');
|
|
46
113
|
}
|
|
47
114
|
|
|
48
115
|
function failPrecondition(message) {
|
|
@@ -53,17 +120,19 @@ function observeInstalledPolicyBaseline({ args, specDir, spec }) {
|
|
|
53
120
|
const adapterDir = path.dirname(__dirname);
|
|
54
121
|
const adapterName = path.basename(adapterDir);
|
|
55
122
|
if (path.basename(__dirname) !== 'scripts' || (adapterName !== '.claude' && adapterName !== '.codex')) return;
|
|
56
|
-
|
|
57
123
|
const statePath = path.join(adapterDir, 'hooks', 'completion-authority-state.cjs');
|
|
58
124
|
const checkPath = path.join(adapterDir, 'hooks', 'completion-authority-check.cjs');
|
|
59
125
|
if (![statePath, checkPath].every((filePath) => {
|
|
60
126
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
127
|
+
const stat = fs.lstatSync(filePath);
|
|
128
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return false;
|
|
129
|
+
const real = fs.realpathSync(filePath);
|
|
130
|
+
const realDir = fs.realpathSync(path.dirname(filePath));
|
|
131
|
+
const rel = path.relative(realDir, real);
|
|
132
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
|
133
|
+
return true;
|
|
134
|
+
} catch { return false; }
|
|
65
135
|
})) return;
|
|
66
|
-
|
|
67
136
|
let installedRoot;
|
|
68
137
|
let scaffoldCwd;
|
|
69
138
|
let specFile;
|
|
@@ -78,54 +147,93 @@ function observeInstalledPolicyBaseline({ args, specDir, spec }) {
|
|
|
78
147
|
} catch (error) {
|
|
79
148
|
throw new Error(`installed ${adapterName} scaffold authority baseline could not be verified: ${error.message}`);
|
|
80
149
|
}
|
|
81
|
-
|
|
82
150
|
let STATE;
|
|
83
151
|
let check;
|
|
152
|
+
let runtime = {};
|
|
84
153
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
154
|
+
const realStatePath = fs.realpathSync(statePath);
|
|
155
|
+
const realCheckPath = fs.realpathSync(checkPath);
|
|
156
|
+
// Ensure real paths are still inside adapterDir's realpath (no symlink escape)
|
|
157
|
+
const realAdapterDir = fs.realpathSync(adapterDir);
|
|
158
|
+
const relState = path.relative(realAdapterDir, realStatePath);
|
|
159
|
+
const relCheck = path.relative(realAdapterDir, realCheckPath);
|
|
160
|
+
if (relState.startsWith('..') || path.isAbsolute(relState) || relCheck.startsWith('..') || path.isAbsolute(relCheck)) {
|
|
161
|
+
throw new Error('scaffold authority path escapes adapter');
|
|
162
|
+
}
|
|
163
|
+
STATE = require(realStatePath);
|
|
164
|
+
check = require(realCheckPath);
|
|
165
|
+
const runtimePath = path.join(installedRoot, adapterName, 'runtime.json');
|
|
166
|
+
if (fs.existsSync(runtimePath)) runtime = JSON.parse(fs.readFileSync(runtimePath, 'utf8'));
|
|
87
167
|
} catch (error) {
|
|
88
168
|
throw new Error(`installed ${adapterName} scaffold authority hooks are unavailable: ${error.message}`);
|
|
89
169
|
}
|
|
90
|
-
if (typeof check.observePolicyBaseline !== 'function') {
|
|
91
|
-
throw new Error(`installed ${adapterName} scaffold authority check does not expose observePolicyBaseline`);
|
|
92
|
-
}
|
|
93
|
-
|
|
170
|
+
if (typeof check.observePolicyBaseline !== 'function') throw new Error(`installed ${adapterName} scaffold authority check does not expose observePolicyBaseline`);
|
|
94
171
|
let result;
|
|
95
172
|
try {
|
|
96
|
-
result = check.observePolicyBaseline({
|
|
97
|
-
STATE,
|
|
98
|
-
policy: POLICY,
|
|
99
|
-
projectRoot: installedRoot,
|
|
100
|
-
candidate: { spec, specFile, featureName: args.feature },
|
|
101
|
-
});
|
|
173
|
+
result = check.observePolicyBaseline({ STATE, policy: POLICY, projectRoot: installedRoot, candidate: { spec, specFile, featureName: args.feature }, runtime });
|
|
102
174
|
} catch (error) {
|
|
103
175
|
throw new Error(`installed ${adapterName} scaffold authority baseline failed: ${error.message}`);
|
|
104
176
|
}
|
|
105
|
-
if (!result || result.ok !== true) {
|
|
106
|
-
throw new Error(`installed ${adapterName} scaffold authority baseline rejected the spec: ${result?.reason || 'unknown reason'}`);
|
|
107
|
-
}
|
|
177
|
+
if (!result || result.ok !== true) throw new Error(`installed ${adapterName} scaffold authority baseline rejected the spec: ${result?.reason || 'unknown reason'}`);
|
|
108
178
|
}
|
|
109
179
|
|
|
110
180
|
function parseArgs(argv) {
|
|
111
|
-
const a = {
|
|
181
|
+
const a = {
|
|
182
|
+
feature: null,
|
|
183
|
+
tasks: null,
|
|
184
|
+
lane: null,
|
|
185
|
+
risks: null,
|
|
186
|
+
lang: 'en',
|
|
187
|
+
title: null,
|
|
188
|
+
specsRoot: 'specs',
|
|
189
|
+
tasksOnly: false,
|
|
190
|
+
artifacts: null,
|
|
191
|
+
dependencies: null,
|
|
192
|
+
planningDepth: null,
|
|
193
|
+
assuranceLevel: null,
|
|
194
|
+
coordination: false,
|
|
195
|
+
taskTriggers: null,
|
|
196
|
+
boundaries: null,
|
|
197
|
+
phases: null,
|
|
198
|
+
research: false,
|
|
199
|
+
uncertainty: null,
|
|
200
|
+
syncSemanticModel: false,
|
|
201
|
+
};
|
|
202
|
+
let dependenciesSeen = false;
|
|
112
203
|
for (let i = 2; i < argv.length; i++) {
|
|
113
204
|
const v = argv[i];
|
|
114
205
|
if (v === '--tasks') a.tasks = argv[++i];
|
|
115
206
|
else if (v === '--lane') a.lane = argv[++i];
|
|
207
|
+
else if (v === '--planning-depth') a.planningDepth = argv[++i];
|
|
208
|
+
else if (v === '--assurance-level') a.assuranceLevel = argv[++i];
|
|
209
|
+
else if (v === '--coordination') a.coordination = true;
|
|
210
|
+
else if (v === '--task-triggers') a.taskTriggers = argv[++i];
|
|
211
|
+
else if (v === '--boundaries') a.boundaries = argv[++i];
|
|
212
|
+
else if (v === '--phases') a.phases = argv[++i];
|
|
213
|
+
else if (v === '--research') a.research = true;
|
|
214
|
+
else if (v === '--uncertainty') a.uncertainty = argv[++i];
|
|
116
215
|
else if (v === '--risks') a.risks = argv[++i];
|
|
117
216
|
else if (v === '--lang') a.lang = argv[++i];
|
|
118
217
|
else if (v === '--title') a.title = argv[++i];
|
|
119
218
|
else if (v === '--specs-root') a.specsRoot = argv[++i];
|
|
120
219
|
else if (v === '--tasks-only') a.tasksOnly = true;
|
|
220
|
+
else if (v === '--sync-semantic-model') a.syncSemanticModel = true;
|
|
121
221
|
else if (v === '--artifacts') a.artifacts = argv[++i];
|
|
222
|
+
else if (v === '--dependencies') {
|
|
223
|
+
if (dependenciesSeen) failPrecondition('--dependencies may be provided only once');
|
|
224
|
+
dependenciesSeen = true;
|
|
225
|
+
if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) {
|
|
226
|
+
failPrecondition('--dependencies requires one JSON object value');
|
|
227
|
+
}
|
|
228
|
+
a.dependencies = argv[++i];
|
|
229
|
+
}
|
|
122
230
|
else if (!a.feature) a.feature = v;
|
|
123
231
|
}
|
|
124
232
|
return a;
|
|
125
233
|
}
|
|
126
234
|
|
|
127
235
|
function readTemplate(name) {
|
|
128
|
-
const p =
|
|
236
|
+
const p = resolveTemplatePath(name);
|
|
129
237
|
if (!fs.existsSync(p)) {
|
|
130
238
|
failPrecondition(`template not found: ${p}`);
|
|
131
239
|
}
|
|
@@ -137,12 +245,25 @@ function titleFromSlug(slug) {
|
|
|
137
245
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
138
246
|
}
|
|
139
247
|
|
|
140
|
-
function
|
|
248
|
+
function concreteUncertainty(value) {
|
|
249
|
+
return typeof value === 'string'
|
|
250
|
+
&& value === value.trim()
|
|
251
|
+
&& value.length >= 12
|
|
252
|
+
&& !/^(?:tbd|todo|unknown|n\/a|none|placeholder)$/i.test(value);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function renderResearchTemplate(feature, uncertainty) {
|
|
256
|
+
return readTemplate('research.md')
|
|
257
|
+
.replace(/\{\{FEATURE_NAME\}\}/g, feature)
|
|
258
|
+
.replace(/\{\{UNCERTAINTY\}\}/g, uncertainty);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function registryEntry(t, artifactDeclarations, dependencies = []) {
|
|
141
262
|
const entry = {
|
|
142
263
|
id: `R${t.req}-${t.seq}`,
|
|
143
264
|
title: titleFromSlug(t.slug),
|
|
144
265
|
status: 'pending',
|
|
145
|
-
dependencies: [],
|
|
266
|
+
dependencies: [...dependencies],
|
|
146
267
|
blocker: null,
|
|
147
268
|
started_at: null,
|
|
148
269
|
completed_at: null,
|
|
@@ -153,6 +274,19 @@ function registryEntry(t, artifactDeclarations) {
|
|
|
153
274
|
return entry;
|
|
154
275
|
}
|
|
155
276
|
|
|
277
|
+
function pendingApproval() {
|
|
278
|
+
return 'draft';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function semanticReviewNotRun() {
|
|
282
|
+
return {
|
|
283
|
+
status: 'not-run',
|
|
284
|
+
semantic_digest: null,
|
|
285
|
+
reviewed_criteria: [],
|
|
286
|
+
counterexamples: [],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
156
290
|
function parseArtifactDeclarations(raw, parsedTasks) {
|
|
157
291
|
if (raw === null || raw === undefined) return new Map();
|
|
158
292
|
let declarations;
|
|
@@ -188,15 +322,344 @@ function parseArtifactDeclarations(raw, parsedTasks) {
|
|
|
188
322
|
return result;
|
|
189
323
|
}
|
|
190
324
|
|
|
191
|
-
function
|
|
192
|
-
return
|
|
325
|
+
function parseDependencyDeclarations(raw, parsedTasks, knownTaskFiles, existingRegistry = {}) {
|
|
326
|
+
if (raw === null || raw === undefined) return new Map();
|
|
327
|
+
let declarations;
|
|
328
|
+
try {
|
|
329
|
+
declarations = JSON.parse(raw);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
failPrecondition(`--dependencies must be valid JSON (${error.message})`);
|
|
332
|
+
}
|
|
333
|
+
if (!isPlainObject(declarations)) {
|
|
334
|
+
failPrecondition('--dependencies must be an object keyed by requested task id or task path');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const requested = new Map();
|
|
338
|
+
for (const task of parsedTasks) {
|
|
339
|
+
requested.set(task.raw, task);
|
|
340
|
+
requested.set(task.file, task);
|
|
341
|
+
}
|
|
342
|
+
const known = new Map();
|
|
343
|
+
for (const taskPath of knownTaskFiles) {
|
|
344
|
+
const identity = parseTaskPath(taskPath, `known task ${taskPath}`);
|
|
345
|
+
known.set(taskPath, taskPath);
|
|
346
|
+
known.set(identity.rawId, taskPath);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const result = new Map();
|
|
350
|
+
const declaredTargets = new Map();
|
|
351
|
+
for (const [key, dependencyRefs] of Object.entries(declarations)) {
|
|
352
|
+
const task = requested.get(key);
|
|
353
|
+
if (!task) failPrecondition(`--dependencies references unknown requested task "${key}"`);
|
|
354
|
+
const previousKey = declaredTargets.get(task.file);
|
|
355
|
+
if (previousKey) {
|
|
356
|
+
failPrecondition(
|
|
357
|
+
`--dependencies declares task "${task.file}" more than once after canonical resolution ` +
|
|
358
|
+
`("${previousKey}" and "${key}")`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
declaredTargets.set(task.file, key);
|
|
362
|
+
if (!Array.isArray(dependencyRefs)) {
|
|
363
|
+
failPrecondition(`--dependencies for "${key}" must be an array`);
|
|
364
|
+
}
|
|
365
|
+
const dependencies = [];
|
|
366
|
+
const seen = new Set();
|
|
367
|
+
for (const dependencyRef of dependencyRefs) {
|
|
368
|
+
if (typeof dependencyRef !== 'string') {
|
|
369
|
+
failPrecondition(`--dependencies for "${key}" must contain only task ids or task paths`);
|
|
370
|
+
}
|
|
371
|
+
const dependency = known.get(dependencyRef);
|
|
372
|
+
if (!dependency) {
|
|
373
|
+
failPrecondition(`--dependencies for "${key}" references unknown task "${dependencyRef}"`);
|
|
374
|
+
}
|
|
375
|
+
if (dependency === task.file) {
|
|
376
|
+
failPrecondition(`--dependencies for "${key}" cannot contain itself`);
|
|
377
|
+
}
|
|
378
|
+
if (seen.has(dependency)) {
|
|
379
|
+
failPrecondition(`--dependencies for "${key}" must not contain duplicate edges`);
|
|
380
|
+
}
|
|
381
|
+
seen.add(dependency);
|
|
382
|
+
dependencies.push(dependency);
|
|
383
|
+
}
|
|
384
|
+
result.set(task.file, dependencies);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const candidateRegistry = {};
|
|
388
|
+
for (const taskPath of knownTaskFiles) {
|
|
389
|
+
candidateRegistry[taskPath] = {
|
|
390
|
+
dependencies: [...(existingRegistry[taskPath]?.dependencies || [])],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
for (const task of parsedTasks) {
|
|
394
|
+
if (!candidateRegistry[task.file]) candidateRegistry[task.file] = { dependencies: [] };
|
|
395
|
+
}
|
|
396
|
+
for (const [taskPath, dependencies] of result) {
|
|
397
|
+
candidateRegistry[taskPath].dependencies = dependencies;
|
|
398
|
+
}
|
|
399
|
+
validateDependencyCycles(knownTaskFiles, candidateRegistry);
|
|
400
|
+
return result;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function parsePhaseDeclarations(raw, knownTaskFiles) {
|
|
404
|
+
if (raw === null) return [];
|
|
405
|
+
let phases;
|
|
406
|
+
try {
|
|
407
|
+
phases = JSON.parse(raw);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
failPrecondition(`--phases must be valid JSON (${error.message})`);
|
|
410
|
+
}
|
|
411
|
+
if (!Array.isArray(phases) || phases.length === 0) {
|
|
412
|
+
failPrecondition('--phases must be a non-empty JSON array');
|
|
413
|
+
}
|
|
414
|
+
const requiredKeys = ['entry_condition', 'exit_condition', 'id', 'owner_boundary', 'task_ids'];
|
|
415
|
+
const knownTaskIds = new Set(knownTaskFiles.map((taskPath) => parseTaskPath(taskPath, `known task ${taskPath}`).id));
|
|
416
|
+
const phaseIds = new Set();
|
|
417
|
+
return phases.map((phase, index) => {
|
|
418
|
+
const label = `--phases[${index}]`;
|
|
419
|
+
if (!isPlainObject(phase) || JSON.stringify(Object.keys(phase).sort()) !== JSON.stringify(requiredKeys)) {
|
|
420
|
+
failPrecondition(`${label} must contain exactly ${requiredKeys.join(', ')}`);
|
|
421
|
+
}
|
|
422
|
+
for (const key of ['id', 'entry_condition', 'exit_condition', 'owner_boundary']) {
|
|
423
|
+
if (typeof phase[key] !== 'string' || phase[key].trim() === '' || phase[key] !== phase[key].trim()) {
|
|
424
|
+
failPrecondition(`${label}.${key} must be a non-empty trimmed string`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(phase.id)) {
|
|
428
|
+
failPrecondition(`${label}.id must be one safe identifier`);
|
|
429
|
+
}
|
|
430
|
+
if (phaseIds.has(phase.id)) failPrecondition(`--phases contains duplicate id ${phase.id}`);
|
|
431
|
+
phaseIds.add(phase.id);
|
|
432
|
+
if (!Array.isArray(phase.task_ids) || phase.task_ids.length === 0) {
|
|
433
|
+
failPrecondition(`${label}.task_ids must be a non-empty array`);
|
|
434
|
+
}
|
|
435
|
+
const taskIds = new Set();
|
|
436
|
+
for (const taskId of phase.task_ids) {
|
|
437
|
+
if (typeof taskId !== 'string' || !knownTaskIds.has(taskId)) {
|
|
438
|
+
failPrecondition(`${label}.task_ids references unknown task ${String(taskId)}`);
|
|
439
|
+
}
|
|
440
|
+
if (taskIds.has(taskId)) failPrecondition(`${label}.task_ids must not contain duplicate task ids`);
|
|
441
|
+
taskIds.add(taskId);
|
|
442
|
+
}
|
|
443
|
+
return cloneJson(phase);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const BOUNDARY_FIELDS = Object.freeze({
|
|
448
|
+
ownership: ['id', 'type', 'tasks', 'write_sets'],
|
|
449
|
+
dependency: ['id', 'type', 'producer', 'consumer', 'deliverable'],
|
|
450
|
+
transition: ['id', 'type', 'design_ref', 'owner', 'consumers', 'precondition', 'postcondition', 'failure', 'recovery'],
|
|
451
|
+
proof: ['id', 'type', 'subject', 'verifier', 'verification_ref', 'artifact_anchor'],
|
|
452
|
+
parallel: ['id', 'type', 'tasks', 'resources'],
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
function exactTarget(value, label) {
|
|
456
|
+
safeRelativePath(value, label);
|
|
457
|
+
if (/[*?{}[\]]/.test(value) || /[\\/]$/.test(value)) {
|
|
458
|
+
failPrecondition(`${label} must be one exact grounded target, not a glob or parent directory`);
|
|
459
|
+
}
|
|
460
|
+
return value;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function targetsContend(left, right) {
|
|
464
|
+
const a = left.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
465
|
+
const b = right.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
466
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function boundaryTaskList(value, label, knownTaskIds, minimum = 1) {
|
|
470
|
+
if (!Array.isArray(value) || value.length < minimum) {
|
|
471
|
+
failPrecondition(`${label} must contain at least ${minimum} task id(s)`);
|
|
472
|
+
}
|
|
473
|
+
const seen = new Set();
|
|
474
|
+
for (const taskId of value) {
|
|
475
|
+
if (!knownTaskIds.has(taskId)) failPrecondition(`${label} references unknown task ${String(taskId)}`);
|
|
476
|
+
if (seen.has(taskId)) failPrecondition(`${label} must not contain duplicate task ids`);
|
|
477
|
+
seen.add(taskId);
|
|
478
|
+
}
|
|
479
|
+
return [...value];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function boundaryTargetMap(value, tasks, label) {
|
|
483
|
+
if (!isPlainObject(value) || JSON.stringify(Object.keys(value).sort()) !== JSON.stringify([...tasks].sort())) {
|
|
484
|
+
failPrecondition(`${label} must be an object keyed exactly by its task ids`);
|
|
485
|
+
}
|
|
486
|
+
const result = {};
|
|
487
|
+
for (const taskId of tasks) {
|
|
488
|
+
if (!Array.isArray(value[taskId]) || value[taskId].length === 0) {
|
|
489
|
+
failPrecondition(`${label}.${taskId} must be a non-empty exact target array`);
|
|
490
|
+
}
|
|
491
|
+
result[taskId] = value[taskId].map((target, index) => exactTarget(target, `${label}.${taskId}[${index}]`));
|
|
492
|
+
if (new Set(result[taskId]).size !== result[taskId].length) {
|
|
493
|
+
failPrecondition(`${label}.${taskId} must not contain duplicate targets`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
for (let left = 0; left < tasks.length; left += 1) {
|
|
497
|
+
for (let right = left + 1; right < tasks.length; right += 1) {
|
|
498
|
+
for (const a of result[tasks[left]]) {
|
|
499
|
+
for (const b of result[tasks[right]]) {
|
|
500
|
+
if (targetsContend(a, b)) {
|
|
501
|
+
failPrecondition(`${label} has resource contention between ${tasks[left]}:${a} and ${tasks[right]}:${b}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return result;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function parseBoundaryDeclarations(raw, knownTaskFiles) {
|
|
511
|
+
if (raw === null || raw === undefined) return [];
|
|
512
|
+
let boundaries;
|
|
513
|
+
try { boundaries = JSON.parse(raw); } catch (error) {
|
|
514
|
+
failPrecondition(`--boundaries must be valid JSON (${error.message})`);
|
|
515
|
+
}
|
|
516
|
+
if (!Array.isArray(boundaries) || boundaries.length === 0) {
|
|
517
|
+
failPrecondition('--boundaries must be a non-empty JSON array');
|
|
518
|
+
}
|
|
519
|
+
const taskPathById = new Map(knownTaskFiles.map((taskPath) => {
|
|
520
|
+
const identity = parseTaskPath(taskPath, `known task ${taskPath}`);
|
|
521
|
+
return [identity.id, taskPath];
|
|
522
|
+
}));
|
|
523
|
+
const knownTaskIds = new Set(taskPathById.keys());
|
|
524
|
+
const ids = new Set();
|
|
525
|
+
const result = boundaries.map((boundary, index) => {
|
|
526
|
+
const label = `--boundaries[${index}]`;
|
|
527
|
+
if (!isPlainObject(boundary) || !Object.prototype.hasOwnProperty.call(BOUNDARY_FIELDS, boundary.type)) {
|
|
528
|
+
failPrecondition(`${label}.type must be ownership, dependency, transition, proof, or parallel`);
|
|
529
|
+
}
|
|
530
|
+
if (JSON.stringify(Object.keys(boundary).sort()) !== JSON.stringify([...BOUNDARY_FIELDS[boundary.type]].sort())) {
|
|
531
|
+
failPrecondition(`${label} ${boundary.type} fields must be exactly ${BOUNDARY_FIELDS[boundary.type].join(', ')}`);
|
|
532
|
+
}
|
|
533
|
+
if (typeof boundary.id !== 'string' || !/^B-[A-Z0-9][A-Z0-9._-]*$/i.test(boundary.id)) {
|
|
534
|
+
failPrecondition(`${label}.id must be a safe B-* identifier`);
|
|
535
|
+
}
|
|
536
|
+
if (ids.has(boundary.id)) failPrecondition(`--boundaries contains duplicate id ${boundary.id}`);
|
|
537
|
+
ids.add(boundary.id);
|
|
538
|
+
if (boundary.type === 'ownership') {
|
|
539
|
+
const tasks = boundaryTaskList(boundary.tasks, `${label}.tasks`, knownTaskIds, 2);
|
|
540
|
+
boundaryTargetMap(boundary.write_sets, tasks, `${label}.write_sets`);
|
|
541
|
+
} else if (boundary.type === 'dependency') {
|
|
542
|
+
boundaryTaskList([boundary.producer, boundary.consumer], label, knownTaskIds, 2);
|
|
543
|
+
if (boundary.producer === boundary.consumer) failPrecondition(`${label} producer and consumer must differ`);
|
|
544
|
+
exactTarget(boundary.deliverable, `${label}.deliverable`);
|
|
545
|
+
} else if (boundary.type === 'transition') {
|
|
546
|
+
boundaryTaskList([boundary.owner], `${label}.owner`, knownTaskIds);
|
|
547
|
+
boundaryTaskList(boundary.consumers, `${label}.consumers`, knownTaskIds);
|
|
548
|
+
if (boundary.consumers.includes(boundary.owner)) failPrecondition(`${label}.consumers cannot include transition owner`);
|
|
549
|
+
if (!/^T\d+(?:\.\d+)?$/i.test(boundary.design_ref)) failPrecondition(`${label}.design_ref must be a T id`);
|
|
550
|
+
for (const field of ['precondition', 'postcondition', 'failure', 'recovery']) {
|
|
551
|
+
if (typeof boundary[field] !== 'string' || boundary[field].trim().length < 8) {
|
|
552
|
+
failPrecondition(`${label}.${field} must be a concrete definition`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
} else if (boundary.type === 'proof') {
|
|
556
|
+
boundaryTaskList([boundary.subject, boundary.verifier], label, knownTaskIds, 2);
|
|
557
|
+
if (boundary.subject === boundary.verifier) failPrecondition(`${label} subject and verifier must differ`);
|
|
558
|
+
if (!/^V\d+(?:\.\d+)?$/i.test(boundary.verification_ref)) failPrecondition(`${label}.verification_ref must be a V id`);
|
|
559
|
+
if (!new RegExp(`^A-${boundary.verifier}-\\d{2}$`).test(boundary.artifact_anchor)) {
|
|
560
|
+
failPrecondition(`${label}.artifact_anchor must be owned by verifier ${boundary.verifier}`);
|
|
561
|
+
}
|
|
562
|
+
} else if (boundary.type === 'parallel') {
|
|
563
|
+
const tasks = boundaryTaskList(boundary.tasks, `${label}.tasks`, knownTaskIds, 2);
|
|
564
|
+
boundaryTargetMap(boundary.resources, tasks, `${label}.resources`);
|
|
565
|
+
}
|
|
566
|
+
return cloneJson(boundary);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const dependencies = result.filter((boundary) => boundary.type === 'dependency');
|
|
570
|
+
const graph = new Map([...knownTaskIds].map((taskId) => [taskId, []]));
|
|
571
|
+
for (const boundary of dependencies) graph.get(boundary.producer).push(boundary.consumer);
|
|
572
|
+
const visiting = new Set();
|
|
573
|
+
const visited = new Set();
|
|
574
|
+
const visit = (taskId, stack = []) => {
|
|
575
|
+
if (visiting.has(taskId)) {
|
|
576
|
+
const start = stack.indexOf(taskId);
|
|
577
|
+
failPrecondition(`coordination.boundaries dependency cycle: ${stack.slice(start).concat(taskId).join(' -> ')}`);
|
|
578
|
+
}
|
|
579
|
+
if (visited.has(taskId)) return;
|
|
580
|
+
visiting.add(taskId);
|
|
581
|
+
for (const consumer of graph.get(taskId)) visit(consumer, [...stack, taskId]);
|
|
582
|
+
visiting.delete(taskId);
|
|
583
|
+
visited.add(taskId);
|
|
584
|
+
};
|
|
585
|
+
for (const taskId of knownTaskIds) visit(taskId);
|
|
586
|
+
const pathExists = (from, to, seen = new Set()) => {
|
|
587
|
+
if (from === to) return true;
|
|
588
|
+
if (seen.has(from)) return false;
|
|
589
|
+
seen.add(from);
|
|
590
|
+
return graph.get(from).some((next) => pathExists(next, to, seen));
|
|
591
|
+
};
|
|
592
|
+
for (const boundary of result.filter((candidate) => candidate.type === 'parallel')) {
|
|
593
|
+
for (let left = 0; left < boundary.tasks.length; left += 1) {
|
|
594
|
+
for (let right = left + 1; right < boundary.tasks.length; right += 1) {
|
|
595
|
+
if (pathExists(boundary.tasks[left], boundary.tasks[right])
|
|
596
|
+
|| pathExists(boundary.tasks[right], boundary.tasks[left])) {
|
|
597
|
+
failPrecondition(`${boundary.id}: parallel tasks must have no dependency path`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const authorities = result.filter((candidate) => ['transition', 'proof'].includes(candidate.type));
|
|
602
|
+
for (const authority of authorities) {
|
|
603
|
+
const owners = authority.type === 'transition'
|
|
604
|
+
? [authority.owner, ...authority.consumers]
|
|
605
|
+
: [authority.subject, authority.verifier];
|
|
606
|
+
if (boundary.tasks.filter((taskId) => owners.includes(taskId)).length > 1) {
|
|
607
|
+
failPrecondition(`${boundary.id}: parallel tasks cannot share ${authority.type} authority ${authority.id}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return result;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function dependenciesFromBoundaries(boundaries, knownTaskFiles) {
|
|
615
|
+
const pathById = new Map(knownTaskFiles.map((taskPath) => [parseTaskPath(taskPath, taskPath).id, taskPath]));
|
|
616
|
+
const result = new Map(knownTaskFiles.map((taskPath) => [taskPath, []]));
|
|
617
|
+
for (const boundary of boundaries) {
|
|
618
|
+
if (boundary.type !== 'dependency') continue;
|
|
619
|
+
result.get(pathById.get(boundary.consumer)).push(pathById.get(boundary.producer));
|
|
620
|
+
}
|
|
621
|
+
for (const [taskPath, dependencies] of result) result.set(taskPath, [...new Set(dependencies)].sort());
|
|
622
|
+
return result;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function validatePhaseBoundaryRefs(phases, boundaries) {
|
|
626
|
+
const boundaryIds = new Set(boundaries.map((boundary) => boundary.id));
|
|
627
|
+
for (const phase of phases) {
|
|
628
|
+
if (!boundaryIds.has(phase.owner_boundary)) {
|
|
629
|
+
failPrecondition(`phase ${phase.id}.owner_boundary references unknown boundary ${phase.owner_boundary}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function applyExecutionClosureProfile(content, includeExecutionClosure) {
|
|
635
|
+
if (!includeExecutionClosure) return content.replace(EXECUTION_CLOSURE_BLOCK_RE, '');
|
|
636
|
+
return content
|
|
637
|
+
.replace('<!-- EXECUTION_CLOSURE_START -->\n', '')
|
|
638
|
+
.replace('<!-- EXECUTION_CLOSURE_END -->\n', '');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function declaredImplementationObligations(spec) {
|
|
642
|
+
return Array.isArray(spec?.implementation_obligations)
|
|
643
|
+
? spec.implementation_obligations.filter(isPlainObject)
|
|
644
|
+
: [];
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function taskHasDeclaredImplementationObligation(spec, taskPath) {
|
|
648
|
+
return declaredImplementationObligations(spec).some((obligation) => (
|
|
649
|
+
obligation.owner_task === taskPath
|
|
650
|
+
|| (Array.isArray(obligation.consumer_tasks) && obligation.consumer_tasks.includes(taskPath))
|
|
651
|
+
));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function fillTask(taskTpl, t, feature, dependencies = [], includeExecutionClosure = false) {
|
|
655
|
+
return applyExecutionClosureProfile(taskTpl, includeExecutionClosure)
|
|
193
656
|
.replace(/\{\{REQ_NUMBER\}\}/g, t.req)
|
|
194
657
|
.replace(/\{\{SEQ\}\}/g, t.seq)
|
|
195
658
|
.replace(/\{\{TITLE\}\}/g, titleFromSlug(t.slug))
|
|
196
659
|
.replace(/\{\{FEATURE_NAME\}\}/g, feature)
|
|
197
660
|
.replace(/\{\{PRIORITY\}\}/g, 'P2')
|
|
198
661
|
.replace(/\{\{EFFORT\}\}/g, 'TBD')
|
|
199
|
-
.replace(/\{\{DEPENDENCIES\}\}/g, 'none');
|
|
662
|
+
.replace(/\{\{DEPENDENCIES\}\}/g, dependencies.length > 0 ? dependencies.join(', ') : 'none');
|
|
200
663
|
}
|
|
201
664
|
|
|
202
665
|
function parseRiskNames(raw) {
|
|
@@ -236,6 +699,148 @@ function cloneJson(value) {
|
|
|
236
699
|
return JSON.parse(JSON.stringify(value));
|
|
237
700
|
}
|
|
238
701
|
|
|
702
|
+
function assertExactKeys(value, expected, label) {
|
|
703
|
+
if (!isPlainObject(value)) failPrecondition(`${label} must be an object`);
|
|
704
|
+
const actual = Object.keys(value).sort();
|
|
705
|
+
const wanted = [...expected].sort();
|
|
706
|
+
if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
|
|
707
|
+
const unknown = actual.filter((key) => !wanted.includes(key));
|
|
708
|
+
const missing = wanted.filter((key) => !actual.includes(key));
|
|
709
|
+
failPrecondition(
|
|
710
|
+
`${label} must be closed-world; unknown=[${unknown.join(', ')}], missing=[${missing.join(', ')}]`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function canonicalRegistryFields(entry) {
|
|
716
|
+
return hasOwn(entry, 'artifacts') ? [...REGISTRY_KEYS, 'artifacts'] : REGISTRY_KEYS;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function assertCanonicalSpecInputClosedWorld(spec) {
|
|
720
|
+
if (!isPlainObject(spec) || spec.schema_version !== '2.1') return;
|
|
721
|
+
const topLevel = [...CANONICAL_SPEC_FIELDS].filter((field) => {
|
|
722
|
+
if (field === 'research') return hasOwn(spec, 'research');
|
|
723
|
+
if (field === 'task_files' || field === 'task_registry') return hasOwn(spec, field);
|
|
724
|
+
if (field === 'decisions') return hasOwn(spec, 'decisions');
|
|
725
|
+
return true;
|
|
726
|
+
});
|
|
727
|
+
assertExactKeys(spec, topLevel, 'canonical schema 2.1 spec.json');
|
|
728
|
+
assertExactKeys(spec.scope_lock, SCOPE_LOCK_FIELDS, 'canonical schema 2.1 scope_lock');
|
|
729
|
+
assertExactKeys(spec.authoring, AUTHORING_FIELDS, 'canonical schema 2.1 authoring');
|
|
730
|
+
const coordinationFields = hasOwn(spec.coordination, 'phases')
|
|
731
|
+
? [...COORDINATION_FIELDS] : ['boundaries'];
|
|
732
|
+
assertExactKeys(spec.coordination, coordinationFields, 'canonical schema 2.1 coordination');
|
|
733
|
+
assertExactKeys(spec.validation, VALIDATION_FIELDS, 'canonical schema 2.1 validation');
|
|
734
|
+
assertExactKeys(
|
|
735
|
+
spec.validation.semantic_review,
|
|
736
|
+
SEMANTIC_REVIEW_FIELDS,
|
|
737
|
+
'canonical schema 2.1 validation.semantic_review',
|
|
738
|
+
);
|
|
739
|
+
if (Array.isArray(spec.validation.semantic_review?.counterexamples)) {
|
|
740
|
+
const fields = ['criterion', 'case_kind', 'scenario', 'expected', 'decision_refs', 'verification_ref'];
|
|
741
|
+
for (const [index, counterexample] of spec.validation.semantic_review.counterexamples.entries()) {
|
|
742
|
+
assertExactKeys(
|
|
743
|
+
counterexample,
|
|
744
|
+
fields,
|
|
745
|
+
`canonical schema 2.1 validation.semantic_review.counterexamples[${index}]`,
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (hasOwn(spec, 'decisions')) {
|
|
750
|
+
if (!Array.isArray(spec.decisions) || spec.decisions.length === 0) failPrecondition('canonical schema 2.1 decisions must be omitted or a non-empty array');
|
|
751
|
+
for (const [index, decision] of spec.decisions.entries()) {
|
|
752
|
+
assertExactKeys(decision, ['id', 'classification', 'statement', 'status', 'evidence'], `canonical schema 2.1 decisions[${index}]`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (Array.isArray(spec.coordination?.boundaries)) {
|
|
756
|
+
for (const [index, boundary] of spec.coordination.boundaries.entries()) {
|
|
757
|
+
if (!isPlainObject(boundary) || !Object.prototype.hasOwnProperty.call(BOUNDARY_FIELDS, boundary.type)) {
|
|
758
|
+
failPrecondition(`canonical schema 2.1 coordination.boundaries[${index}] has an unknown type`);
|
|
759
|
+
}
|
|
760
|
+
assertExactKeys(
|
|
761
|
+
boundary,
|
|
762
|
+
BOUNDARY_FIELDS[boundary.type],
|
|
763
|
+
`canonical schema 2.1 coordination.boundaries[${index}]`,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (Array.isArray(spec.coordination?.phases)) {
|
|
768
|
+
const fields = ['entry_condition', 'exit_condition', 'id', 'owner_boundary', 'task_ids'];
|
|
769
|
+
for (const [index, phase] of spec.coordination.phases.entries()) {
|
|
770
|
+
assertExactKeys(phase, fields, `canonical schema 2.1 coordination.phases[${index}]`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (hasOwn(spec, 'task_registry') && isPlainObject(spec.task_registry)) {
|
|
774
|
+
for (const [taskPath, entry] of Object.entries(spec.task_registry)) {
|
|
775
|
+
assertExactKeys(entry, canonicalRegistryFields(entry), `canonical schema 2.1 task_registry.${taskPath}`);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function rejectUnknownKeys(value, allowed, label) {
|
|
781
|
+
if (!isPlainObject(value)) return;
|
|
782
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
783
|
+
if (unknown.length) {
|
|
784
|
+
failPrecondition(`${label} contains unsupported legacy authority field(s): ${unknown.join(', ')}; remove or explicitly migrate them before schema 2.1 promotion`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function assertLegacyMigrationClosedWorld(spec) {
|
|
789
|
+
rejectUnknownKeys(spec, LEGACY_SPEC_FIELDS, 'schema 2.0 spec.json');
|
|
790
|
+
rejectUnknownKeys(spec.scope_lock, SCOPE_LOCK_FIELDS, 'schema 2.0 scope_lock');
|
|
791
|
+
rejectUnknownKeys(spec.authoring, AUTHORING_FIELDS, 'schema 2.0 authoring');
|
|
792
|
+
rejectUnknownKeys(spec.coordination, LEGACY_COORDINATION_FIELDS, 'schema 2.0 coordination');
|
|
793
|
+
rejectUnknownKeys(spec.validation, VALIDATION_FIELDS, 'schema 2.0 validation');
|
|
794
|
+
rejectUnknownKeys(spec.validation?.semantic_review, LEGACY_SEMANTIC_REVIEW_FIELDS, 'schema 2.0 validation.semantic_review');
|
|
795
|
+
for (const [index, counterexample] of (spec.validation?.semantic_review?.counterexamples || []).entries()) {
|
|
796
|
+
rejectUnknownKeys(counterexample, ['criterion', 'case_kind', 'scenario', 'expected', 'decision_refs', 'verification_ref'], `schema 2.0 semantic_review.counterexamples[${index}]`);
|
|
797
|
+
}
|
|
798
|
+
for (const [index, boundary] of (spec.coordination?.boundaries || []).entries()) {
|
|
799
|
+
if (!isPlainObject(boundary) || !hasOwn(BOUNDARY_FIELDS, boundary.type)) {
|
|
800
|
+
failPrecondition(`schema 2.0 coordination.boundaries[${index}] has an unknown authority type`);
|
|
801
|
+
}
|
|
802
|
+
rejectUnknownKeys(boundary, BOUNDARY_FIELDS[boundary.type], `schema 2.0 coordination.boundaries[${index}]`);
|
|
803
|
+
}
|
|
804
|
+
if (isPlainObject(spec.workflow_policy)) {
|
|
805
|
+
rejectUnknownKeys(spec.workflow_policy, LEGACY_POLICY_FIELDS, 'schema 2.0 workflow_policy');
|
|
806
|
+
}
|
|
807
|
+
if (hasOwn(spec, 'current_phase')
|
|
808
|
+
&& (typeof spec.current_phase !== 'string' || spec.current_phase.trim() === '')) {
|
|
809
|
+
failPrecondition('schema 2.0 current_phase must be a non-empty string when present');
|
|
810
|
+
}
|
|
811
|
+
if (hasOwn(spec, 'timestamps')) {
|
|
812
|
+
if (!isPlainObject(spec.timestamps)) failPrecondition('schema 2.0 timestamps must be an object');
|
|
813
|
+
rejectUnknownKeys(spec.timestamps, LEGACY_TIMESTAMP_FIELDS, 'schema 2.0 timestamps');
|
|
814
|
+
for (const [field, value] of Object.entries(spec.timestamps)) {
|
|
815
|
+
if (value !== null && !isIsoTimestamp(value)) {
|
|
816
|
+
failPrecondition(`schema 2.0 timestamps.${field} must be null or a valid ISO 8601 timestamp`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if (hasOwn(spec, 'approvals')) {
|
|
821
|
+
if (!isPlainObject(spec.approvals)) failPrecondition('schema 2.0 approvals must be an object');
|
|
822
|
+
rejectUnknownKeys(spec.approvals, LEGACY_APPROVAL_FIELDS, 'schema 2.0 approvals');
|
|
823
|
+
for (const [section, state] of Object.entries(spec.approvals)) {
|
|
824
|
+
if (!isPlainObject(state)) failPrecondition(`schema 2.0 approvals.${section} must be an object`);
|
|
825
|
+
rejectUnknownKeys(state, LEGACY_APPROVAL_STATE_FIELDS, `schema 2.0 approvals.${section}`);
|
|
826
|
+
for (const [field, value] of Object.entries(state)) {
|
|
827
|
+
if (typeof value !== 'boolean') {
|
|
828
|
+
failPrecondition(`schema 2.0 approvals.${section}.${field} must be boolean`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (isPlainObject(spec.task_registry)) for (const [taskPath, entry] of Object.entries(spec.task_registry)) {
|
|
834
|
+
rejectUnknownKeys(entry, canonicalRegistryFields(entry), `schema 2.0 task_registry.${taskPath}`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function isIsoTimestamp(value) {
|
|
839
|
+
return typeof value === 'string'
|
|
840
|
+
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
|
|
841
|
+
&& Number.isFinite(Date.parse(value));
|
|
842
|
+
}
|
|
843
|
+
|
|
239
844
|
function isWithin(root, target) {
|
|
240
845
|
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
241
846
|
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
@@ -339,7 +944,9 @@ function validateRegistryEntry(taskPath, entry, declaredSet) {
|
|
|
339
944
|
const identity = parseTaskPath(taskPath, `task_registry.${taskPath}`);
|
|
340
945
|
if (entry.id !== identity.id) failPrecondition(`task_registry.${taskPath}.id conflicts with its task path`);
|
|
341
946
|
if (typeof entry.title !== 'string' || entry.title.trim() === '') failPrecondition(`task_registry.${taskPath}.title must be a non-empty string`);
|
|
342
|
-
if (
|
|
947
|
+
if (!TASK_STATUS_VALUES.has(entry.status)) {
|
|
948
|
+
failPrecondition(`task_registry.${taskPath}.status must be one of ${[...TASK_STATUS_VALUES].join(', ')}`);
|
|
949
|
+
}
|
|
343
950
|
if (!Array.isArray(entry.dependencies)) failPrecondition(`task_registry.${taskPath}.dependencies must be an array`);
|
|
344
951
|
const dependencies = new Set();
|
|
345
952
|
for (const dependency of entry.dependencies) {
|
|
@@ -350,6 +957,244 @@ function validateRegistryEntry(taskPath, entry, declaredSet) {
|
|
|
350
957
|
dependencies.add(dependency);
|
|
351
958
|
}
|
|
352
959
|
if (hasOwn(entry, 'artifacts')) validateArtifactList(entry.artifacts, `task_registry.${taskPath}.artifacts`);
|
|
960
|
+
const label = `task_registry.${taskPath}`;
|
|
961
|
+
if (entry.blocker !== null && (typeof entry.blocker !== 'string' || entry.blocker.trim() === '')) {
|
|
962
|
+
failPrecondition(`${label}.blocker must be null or a non-empty string`);
|
|
963
|
+
}
|
|
964
|
+
for (const field of ['started_at', 'completed_at', 'last_updated_at']) {
|
|
965
|
+
if (entry[field] !== null && !isIsoTimestamp(entry[field])) {
|
|
966
|
+
failPrecondition(`${label}.${field} must be null or an ISO 8601 timestamp`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (entry.status === 'pending') {
|
|
970
|
+
if (entry.blocker !== null || entry.started_at !== null || entry.completed_at !== null) {
|
|
971
|
+
failPrecondition(`${label} pending lifecycle is inconsistent`);
|
|
972
|
+
}
|
|
973
|
+
} else if (entry.status === 'in_progress') {
|
|
974
|
+
if (entry.blocker !== null || !isIsoTimestamp(entry.started_at)
|
|
975
|
+
|| entry.completed_at !== null || !isIsoTimestamp(entry.last_updated_at)
|
|
976
|
+
|| Date.parse(entry.last_updated_at) < Date.parse(entry.started_at)) {
|
|
977
|
+
failPrecondition(`${label} in_progress lifecycle is inconsistent`);
|
|
978
|
+
}
|
|
979
|
+
} else if (entry.status === 'blocked') {
|
|
980
|
+
if (typeof entry.blocker !== 'string' || entry.blocker.trim() === ''
|
|
981
|
+
|| entry.completed_at !== null || !isIsoTimestamp(entry.last_updated_at)
|
|
982
|
+
|| (isIsoTimestamp(entry.started_at) && Date.parse(entry.last_updated_at) < Date.parse(entry.started_at))) {
|
|
983
|
+
failPrecondition(`${label} blocked lifecycle is inconsistent`);
|
|
984
|
+
}
|
|
985
|
+
} else if (entry.status === 'done') {
|
|
986
|
+
if (entry.blocker !== null
|
|
987
|
+
|| ![entry.started_at, entry.completed_at, entry.last_updated_at].every(isIsoTimestamp)
|
|
988
|
+
|| Date.parse(entry.completed_at) < Date.parse(entry.started_at)
|
|
989
|
+
|| Date.parse(entry.last_updated_at) < Date.parse(entry.completed_at)) {
|
|
990
|
+
failPrecondition(`${label} done lifecycle is inconsistent`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function semanticReviewShapeValid(review) {
|
|
996
|
+
return isPlainObject(review)
|
|
997
|
+
&& ['not-run', 'completed'].includes(review.status)
|
|
998
|
+
&& (review.semantic_digest === null || typeof review.semantic_digest === 'string')
|
|
999
|
+
&& Array.isArray(review.reviewed_criteria)
|
|
1000
|
+
&& Array.isArray(review.counterexamples);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function preflightSpec21Candidate(spec, evidence) {
|
|
1004
|
+
if (!isPlainObject(spec)) failPrecondition('schema 2.1 candidate must be an object');
|
|
1005
|
+
assertCanonicalSpecInputClosedWorld(spec);
|
|
1006
|
+
if (hasOwn(spec, 'override_receipt')) {
|
|
1007
|
+
failPrecondition('schema 2.1 candidate override_receipt is unsupported; omit the field');
|
|
1008
|
+
}
|
|
1009
|
+
const required = [
|
|
1010
|
+
'schema_version', 'feature_name', 'created_at', 'updated_at', 'language', 'status',
|
|
1011
|
+
'scope_lock', 'authoring', 'coordination', 'validation', 'semantic_model', 'ready_for_implementation',
|
|
1012
|
+
'workflow_policy',
|
|
1013
|
+
];
|
|
1014
|
+
for (const field of required) if (!hasOwn(spec, field)) {
|
|
1015
|
+
failPrecondition(`schema 2.1 candidate is missing required machine field ${field}`);
|
|
1016
|
+
}
|
|
1017
|
+
if (spec.schema_version !== '2.1') failPrecondition('schema 2.1 candidate has an unsupported schema_version');
|
|
1018
|
+
if (spec.feature_name !== evidence.featureName) failPrecondition('feature_name conflicts with the scaffold feature identity');
|
|
1019
|
+
if (!isIsoTimestamp(spec.created_at) || !isIsoTimestamp(spec.updated_at)
|
|
1020
|
+
|| Date.parse(spec.updated_at) < Date.parse(spec.created_at)) {
|
|
1021
|
+
failPrecondition('schema 2.1 candidate lifecycle timestamps are invalid');
|
|
1022
|
+
}
|
|
1023
|
+
if (typeof spec.language !== 'string' || spec.language.trim() === '') failPrecondition('schema 2.1 candidate language must be non-empty');
|
|
1024
|
+
if (!SPEC_STATUS_VALUES.has(spec.status)) failPrecondition('schema 2.1 candidate status is invalid');
|
|
1025
|
+
if (!isPlainObject(spec.scope_lock)) failPrecondition('schema 2.1 candidate scope_lock must be an object');
|
|
1026
|
+
if (!isPlainObject(spec.authoring)
|
|
1027
|
+
|| Object.keys(spec.authoring).sort().join(',') !== [...AUTHORING_FIELDS].sort().join(',')) {
|
|
1028
|
+
failPrecondition('schema 2.1 candidate authoring must contain exactly requirements, design, research, and tasks');
|
|
1029
|
+
}
|
|
1030
|
+
for (const field of AUTHORING_FIELDS) if (!AUTHORING_STATES.has(spec.authoring[field])) {
|
|
1031
|
+
failPrecondition(`schema 2.1 candidate authoring.${field} is invalid`);
|
|
1032
|
+
}
|
|
1033
|
+
if (!evidence.requirementsExists || !evidence.designExists) {
|
|
1034
|
+
failPrecondition('schema 2.1 migration requires physical requirements.md and design.md files');
|
|
1035
|
+
}
|
|
1036
|
+
if (spec.authoring.requirements === 'absent' || spec.authoring.design === 'absent') {
|
|
1037
|
+
failPrecondition('physical requirements.md and design.md cannot be marked absent');
|
|
1038
|
+
}
|
|
1039
|
+
if (evidence.researchExists) {
|
|
1040
|
+
if (spec.research !== 'research.md' || spec.authoring.research === 'absent') {
|
|
1041
|
+
failPrecondition('research pointer, authoring state, and physical research.md must agree');
|
|
1042
|
+
}
|
|
1043
|
+
} else if (hasOwn(spec, 'research') || spec.authoring.research !== 'absent') {
|
|
1044
|
+
failPrecondition('absent physical research.md requires no pointer and authoring.research absent');
|
|
1045
|
+
}
|
|
1046
|
+
if (!isPlainObject(spec.coordination) || !Array.isArray(spec.coordination.boundaries)) {
|
|
1047
|
+
failPrecondition('schema 2.1 candidate coordination.boundaries must be an array');
|
|
1048
|
+
}
|
|
1049
|
+
if (!isPlainObject(spec.validation) || !VALIDATION_STATUS_VALUES.has(spec.validation.status)
|
|
1050
|
+
|| !semanticReviewShapeValid(spec.validation.semantic_review)) {
|
|
1051
|
+
failPrecondition('schema 2.1 candidate validation state is invalid');
|
|
1052
|
+
}
|
|
1053
|
+
if (typeof spec.ready_for_implementation !== 'boolean') failPrecondition('schema 2.1 candidate readiness must be boolean');
|
|
1054
|
+
if (spec.semantic_model !== null) {
|
|
1055
|
+
const semanticErrors = SEMANTIC.validateSemanticModel(spec.semantic_model, spec);
|
|
1056
|
+
if (semanticErrors.length) failPrecondition(`schema 2.1 candidate semantic_model is invalid (${semanticErrors.join('; ')})`);
|
|
1057
|
+
} else if (spec.ready_for_implementation === true) {
|
|
1058
|
+
failPrecondition('schema 2.1 readiness requires a promoted semantic_model');
|
|
1059
|
+
}
|
|
1060
|
+
if (isPlainObject(spec.workflow_policy) && hasOwn(spec.workflow_policy, 'override_receipt')) {
|
|
1061
|
+
failPrecondition('schema 2.1 candidate workflow_policy.override_receipt is unsupported; omit the field');
|
|
1062
|
+
}
|
|
1063
|
+
const policyValidation = POLICY.validateWorkflowPolicySnapshot(spec.workflow_policy);
|
|
1064
|
+
if (!policyValidation.valid || spec.workflow_policy.version !== '2.1') {
|
|
1065
|
+
failPrecondition(`schema 2.1 candidate workflow_policy is invalid (${policyValidation.errors.join('; ')})`);
|
|
1066
|
+
}
|
|
1067
|
+
const taskFiles = validateTaskPathList(spec.task_files || [], 'task_files');
|
|
1068
|
+
if (JSON.stringify(taskFiles) !== JSON.stringify([...evidence.taskFiles].sort())) {
|
|
1069
|
+
failPrecondition('schema 2.1 candidate task_files do not match the resulting physical topology');
|
|
1070
|
+
}
|
|
1071
|
+
if (taskFiles.length === 0) {
|
|
1072
|
+
if (hasOwn(spec, 'task_files') || hasOwn(spec, 'task_registry') || spec.authoring.tasks !== 'absent') {
|
|
1073
|
+
failPrecondition('taskless schema 2.1 candidate must omit task topology and mark tasks absent');
|
|
1074
|
+
}
|
|
1075
|
+
} else {
|
|
1076
|
+
if (!isPlainObject(spec.task_registry)
|
|
1077
|
+
|| Object.keys(spec.task_registry).sort().join(',') !== taskFiles.join(',')) {
|
|
1078
|
+
failPrecondition('schema 2.1 candidate task_registry must exactly match task_files');
|
|
1079
|
+
}
|
|
1080
|
+
if (!['draft', 'validated'].includes(spec.authoring.tasks)) {
|
|
1081
|
+
failPrecondition('physical tasks require draft or validated authoring state');
|
|
1082
|
+
}
|
|
1083
|
+
const declared = new Set(taskFiles);
|
|
1084
|
+
for (const taskPath of taskFiles) validateRegistryEntry(taskPath, spec.task_registry[taskPath], declared);
|
|
1085
|
+
validateDependencyCycles(taskFiles, spec.task_registry);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function normalizeSpec21Candidate(candidate, evidence) {
|
|
1090
|
+
try {
|
|
1091
|
+
if (!isPlainObject(candidate)) failPrecondition('migration candidate must be an object');
|
|
1092
|
+
if (!['2.0', '2.1'].includes(candidate.schema_version)) {
|
|
1093
|
+
failPrecondition('migration candidate schema_version must be exactly 2.0 or 2.1');
|
|
1094
|
+
}
|
|
1095
|
+
const isLegacy = candidate.schema_version === '2.0';
|
|
1096
|
+
if (isLegacy) assertLegacyMigrationClosedWorld(candidate);
|
|
1097
|
+
if (!isLegacy) assertCanonicalSpecInputClosedWorld(candidate);
|
|
1098
|
+
const featureName = !hasOwn(candidate, 'feature_name') && isLegacy
|
|
1099
|
+
? evidence.featureName : candidate.feature_name;
|
|
1100
|
+
if (typeof featureName !== 'string' || featureName.trim() === ''
|
|
1101
|
+
|| featureName !== evidence.featureName) {
|
|
1102
|
+
failPrecondition('existing feature_name conflicts with the scaffold feature identity');
|
|
1103
|
+
}
|
|
1104
|
+
if (!isIsoTimestamp(candidate.created_at) || !isIsoTimestamp(candidate.updated_at)) {
|
|
1105
|
+
failPrecondition('existing created_at and updated_at must be valid ISO 8601 timestamps');
|
|
1106
|
+
}
|
|
1107
|
+
if (typeof candidate.language !== 'string' || candidate.language.trim() === '') {
|
|
1108
|
+
failPrecondition('existing language must be a non-empty string');
|
|
1109
|
+
}
|
|
1110
|
+
const status = isLegacy && candidate.status === 'in-progress' ? 'in_progress' : candidate.status;
|
|
1111
|
+
if (!SPEC_STATUS_VALUES.has(status)) failPrecondition('existing spec status is invalid');
|
|
1112
|
+
if (!isPlainObject(candidate.scope_lock)) failPrecondition('existing scope_lock must be an object');
|
|
1113
|
+
const scopeLock = {
|
|
1114
|
+
source: candidate.scope_lock.source,
|
|
1115
|
+
in_scope: candidate.scope_lock.in_scope,
|
|
1116
|
+
out_of_scope: candidate.scope_lock.out_of_scope,
|
|
1117
|
+
expansion_policy: candidate.scope_lock.expansion_policy,
|
|
1118
|
+
};
|
|
1119
|
+
if (typeof scopeLock.source !== 'string' || scopeLock.source.trim() === ''
|
|
1120
|
+
|| !Array.isArray(scopeLock.in_scope) || !Array.isArray(scopeLock.out_of_scope)
|
|
1121
|
+
|| typeof scopeLock.expansion_policy !== 'string' || scopeLock.expansion_policy.trim() === '') {
|
|
1122
|
+
failPrecondition('existing scope_lock canonical fields are invalid');
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const previousAuthoring = isPlainObject(candidate.authoring) ? candidate.authoring : {};
|
|
1126
|
+
const authoring = {};
|
|
1127
|
+
for (const field of ['requirements', 'design']) {
|
|
1128
|
+
if (!evidence[`${field}Exists`]) failPrecondition(`physical ${field}.md is required for schema 2.1 migration`);
|
|
1129
|
+
authoring[field] = previousAuthoring[field] === 'validated' ? 'validated' : 'draft';
|
|
1130
|
+
}
|
|
1131
|
+
if (evidence.researchExists) {
|
|
1132
|
+
const pointer = isPlainObject(candidate.research) ? candidate.research.path : candidate.research;
|
|
1133
|
+
if (pointer !== undefined && pointer !== null && pointer !== 'research.md') {
|
|
1134
|
+
failPrecondition('legacy research pointer is ambiguous; expected canonical research.md');
|
|
1135
|
+
}
|
|
1136
|
+
authoring.research = previousAuthoring.research === 'validated' && !evidence.researchChanged
|
|
1137
|
+
? 'validated' : 'draft';
|
|
1138
|
+
} else {
|
|
1139
|
+
if (hasOwn(candidate, 'research') && candidate.research !== null) {
|
|
1140
|
+
failPrecondition('research pointer exists without a physical research.md artifact');
|
|
1141
|
+
}
|
|
1142
|
+
authoring.research = 'absent';
|
|
1143
|
+
}
|
|
1144
|
+
if (evidence.taskFiles.length > 0) {
|
|
1145
|
+
authoring.tasks = previousAuthoring.tasks === 'validated' && !evidence.tasksChanged
|
|
1146
|
+
? 'validated' : 'draft';
|
|
1147
|
+
} else {
|
|
1148
|
+
authoring.tasks = 'absent';
|
|
1149
|
+
}
|
|
1150
|
+
const projectedRegistry = {};
|
|
1151
|
+
for (const taskPath of evidence.taskFiles) {
|
|
1152
|
+
const entry = candidate.task_registry?.[taskPath];
|
|
1153
|
+
if (!isPlainObject(entry)) failPrecondition(`task_registry.${taskPath} must be an object`);
|
|
1154
|
+
projectedRegistry[taskPath] = {};
|
|
1155
|
+
for (const key of canonicalRegistryFields(entry)) {
|
|
1156
|
+
if (hasOwn(entry, key)) projectedRegistry[taskPath][key] = cloneJson(entry[key]);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const validationValid = isPlainObject(candidate.validation)
|
|
1160
|
+
&& VALIDATION_STATUS_VALUES.has(candidate.validation.status)
|
|
1161
|
+
&& semanticReviewShapeValid(candidate.validation.semantic_review);
|
|
1162
|
+
const readinessValid = typeof candidate.ready_for_implementation === 'boolean';
|
|
1163
|
+
const next = {
|
|
1164
|
+
schema_version: '2.1',
|
|
1165
|
+
feature_name: featureName,
|
|
1166
|
+
created_at: candidate.created_at,
|
|
1167
|
+
updated_at: candidate.updated_at,
|
|
1168
|
+
language: candidate.language,
|
|
1169
|
+
status,
|
|
1170
|
+
scope_lock: scopeLock,
|
|
1171
|
+
authoring,
|
|
1172
|
+
coordination: cloneJson(evidence.coordination),
|
|
1173
|
+
validation: validationValid ? cloneJson(candidate.validation) : {
|
|
1174
|
+
status: 'not-run', semantic_review: semanticReviewNotRun(),
|
|
1175
|
+
},
|
|
1176
|
+
semantic_model: isLegacy ? null : cloneJson(candidate.semantic_model),
|
|
1177
|
+
ready_for_implementation: readinessValid ? candidate.ready_for_implementation : false,
|
|
1178
|
+
workflow_policy: cloneJson(evidence.workflowPolicy),
|
|
1179
|
+
...(evidence.researchExists ? { research: 'research.md' } : {}),
|
|
1180
|
+
...(evidence.taskFiles.length > 0 ? {
|
|
1181
|
+
task_files: [...evidence.taskFiles].sort(), task_registry: projectedRegistry,
|
|
1182
|
+
} : {}),
|
|
1183
|
+
};
|
|
1184
|
+
const semanticChanged = evidence.tasksChanged || evidence.researchChanged
|
|
1185
|
+
|| isLegacy || JSON.stringify(next) !== JSON.stringify(candidate);
|
|
1186
|
+
if (semanticChanged || !validationValid || !readinessValid) {
|
|
1187
|
+
next.ready_for_implementation = false;
|
|
1188
|
+
next.validation = { status: 'not-run', semantic_review: semanticReviewNotRun() };
|
|
1189
|
+
next.semantic_model = null;
|
|
1190
|
+
}
|
|
1191
|
+
const changed = JSON.stringify(next) !== JSON.stringify(candidate);
|
|
1192
|
+
if (changed) next.updated_at = evidence.timestamp;
|
|
1193
|
+
preflightSpec21Candidate(next, evidence);
|
|
1194
|
+
return { ok: true, spec: next, changed };
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1197
|
+
}
|
|
353
1198
|
}
|
|
354
1199
|
|
|
355
1200
|
function validateDependencyCycles(taskFiles, registry) {
|
|
@@ -388,7 +1233,12 @@ function listExistingTaskFiles(workRoot, specDir) {
|
|
|
388
1233
|
|
|
389
1234
|
function readPersistedPolicy(spec) {
|
|
390
1235
|
if (hasOwn(spec, 'override_receipt')) {
|
|
391
|
-
|
|
1236
|
+
if (spec.override_receipt !== null) {
|
|
1237
|
+
failPrecondition('existing top-level override_receipt is unsupported; omit the field');
|
|
1238
|
+
}
|
|
1239
|
+
if (spec.schema_version !== '2.0') {
|
|
1240
|
+
failPrecondition('top-level override_receipt is only readable as inert null during schema 2.0 migration; omit the field');
|
|
1241
|
+
}
|
|
392
1242
|
}
|
|
393
1243
|
if (!hasOwn(spec, 'workflow_policy')) {
|
|
394
1244
|
const legacy = spec.design_context?.execution_tier;
|
|
@@ -398,6 +1248,11 @@ function readPersistedPolicy(spec) {
|
|
|
398
1248
|
: 'existing spec must contain a persisted workflow_policy snapshot',
|
|
399
1249
|
);
|
|
400
1250
|
}
|
|
1251
|
+
if (isPlainObject(spec.workflow_policy)
|
|
1252
|
+
&& hasOwn(spec.workflow_policy, 'override_receipt')
|
|
1253
|
+
&& spec.workflow_policy.override_receipt !== null) {
|
|
1254
|
+
failPrecondition('existing workflow_policy.override_receipt is unsupported; omit the field');
|
|
1255
|
+
}
|
|
401
1256
|
try {
|
|
402
1257
|
return POLICY.readWorkflowPolicySnapshot(spec);
|
|
403
1258
|
} catch (error) {
|
|
@@ -415,7 +1270,7 @@ function validateResearchTopology(workRoot, specDir, spec, needsResearch) {
|
|
|
415
1270
|
assertNoSymlinkPath(workRoot, pointerPath, 'spec.research path');
|
|
416
1271
|
if (!isWithin(specDir, pointerPath)) failPrecondition('spec.research path must stay inside the spec directory');
|
|
417
1272
|
if (needsResearch && safePointer !== canonical) {
|
|
418
|
-
failPrecondition('spec.research path must be research.md
|
|
1273
|
+
failPrecondition('spec.research path must be research.md for an explicit --research request');
|
|
419
1274
|
}
|
|
420
1275
|
assertRegularFileOrMissing(workRoot, pointerPath, 'spec.research path');
|
|
421
1276
|
}
|
|
@@ -462,7 +1317,7 @@ function inspectTaskTopology(workRoot, specDir, spec, parsed, artifactDeclaratio
|
|
|
462
1317
|
for (const task of parsed) {
|
|
463
1318
|
if (!existingSet.has(task.file)) continue;
|
|
464
1319
|
const entry = registry[task.file];
|
|
465
|
-
const expected = registryEntry(task,
|
|
1320
|
+
const expected = registryEntry(task, artifactDeclarations);
|
|
466
1321
|
if (entry.id !== expected.id || entry.title !== expected.title) {
|
|
467
1322
|
failPrecondition(`conflicting duplicate task ${task.raw}: existing registry entry differs`);
|
|
468
1323
|
}
|
|
@@ -504,20 +1359,191 @@ function parseFailureInjection() {
|
|
|
504
1359
|
return Number(raw);
|
|
505
1360
|
}
|
|
506
1361
|
|
|
507
|
-
function
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
1362
|
+
function parseCrashInjection() {
|
|
1363
|
+
const raw = process.env.CAFEKIT_SCAFFOLD_KILL_AFTER_RENAMES;
|
|
1364
|
+
if (raw === undefined) return null;
|
|
1365
|
+
if (!/^[1-9]\d*$/.test(raw)) {
|
|
1366
|
+
failPrecondition('CAFEKIT_SCAFFOLD_KILL_AFTER_RENAMES must be a positive integer');
|
|
1367
|
+
}
|
|
1368
|
+
return Number(raw);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function digest(bytes) {
|
|
1372
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function fsyncPath(filePath) {
|
|
1376
|
+
const fd = fs.openSync(filePath, 'r');
|
|
1377
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function fsyncDirectory(directory) {
|
|
1381
|
+
try { fsyncPath(directory); } catch (error) {
|
|
1382
|
+
if (!['EINVAL', 'ENOTSUP', 'EISDIR'].includes(error.code)) throw error;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function fileDigestOrNull(filePath) {
|
|
1387
|
+
try {
|
|
1388
|
+
const stat = fs.lstatSync(filePath);
|
|
1389
|
+
if (!stat.isFile()) throw new Error(`transaction path is not a regular file: ${filePath}`);
|
|
1390
|
+
return digest(fs.readFileSync(filePath));
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
if (error.code === 'ENOENT') return null;
|
|
1393
|
+
throw error;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function processIsAlive(pid) {
|
|
1398
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) return pid === process.pid;
|
|
1399
|
+
try { process.kill(pid, 0); return true; } catch (error) {
|
|
1400
|
+
return error.code === 'EPERM';
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function validateTransactionManifest(workRoot, transactionDir, manifest) {
|
|
1405
|
+
if (!isPlainObject(manifest) || manifest.format !== TRANSACTION_FORMAT) {
|
|
1406
|
+
throw new Error(`unrecognized scaffold transaction manifest: ${transactionDir}`);
|
|
1407
|
+
}
|
|
1408
|
+
const transactionId = path.basename(transactionDir).slice(TRANSACTION_PREFIX.length);
|
|
1409
|
+
if (manifest.transaction_id !== transactionId || !/^[a-f0-9-]{20,}$/i.test(transactionId)) {
|
|
1410
|
+
throw new Error(`scaffold transaction identity mismatch: ${transactionDir}`);
|
|
1411
|
+
}
|
|
1412
|
+
if (path.resolve(manifest.work_root || '') !== workRoot) {
|
|
1413
|
+
throw new Error(`scaffold transaction work root mismatch: ${transactionDir}`);
|
|
1414
|
+
}
|
|
1415
|
+
const specRelative = safeRelativePath(manifest.spec_dir, 'transaction spec_dir');
|
|
1416
|
+
const specDir = path.resolve(workRoot, specRelative);
|
|
1417
|
+
if (!isWithin(workRoot, specDir) || !Array.isArray(manifest.entries) || manifest.entries.length === 0) {
|
|
1418
|
+
throw new Error(`invalid scaffold transaction topology: ${transactionDir}`);
|
|
1419
|
+
}
|
|
1420
|
+
const seen = new Set();
|
|
1421
|
+
for (const entry of manifest.entries) {
|
|
1422
|
+
if (!isPlainObject(entry)) throw new Error(`invalid scaffold transaction entry: ${transactionDir}`);
|
|
1423
|
+
const relativePath = safeRelativePath(entry.path, 'transaction entry path');
|
|
1424
|
+
if (seen.has(relativePath)) throw new Error(`duplicate scaffold transaction path: ${relativePath}`);
|
|
1425
|
+
seen.add(relativePath);
|
|
1426
|
+
if (!isPlainObject(entry.before) || typeof entry.before.exists !== 'boolean'
|
|
1427
|
+
|| (entry.before.exists && !/^[a-f0-9]{64}$/.test(entry.before.digest || ''))
|
|
1428
|
+
|| !/^[a-f0-9]{64}$/.test(entry.after_digest || '')) {
|
|
1429
|
+
throw new Error(`invalid scaffold transaction digest evidence: ${relativePath}`);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
return { ...manifest, specDir };
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function removeEmptyTransactionParents(specDir, target) {
|
|
1436
|
+
let current = path.dirname(target);
|
|
1437
|
+
while (isWithin(specDir, current) && current !== path.dirname(specDir)) {
|
|
1438
|
+
try { fs.rmdirSync(current); } catch (error) {
|
|
1439
|
+
if (error.code === 'ENOENT') break;
|
|
1440
|
+
if (['ENOTEMPTY', 'EEXIST'].includes(error.code)) break;
|
|
1441
|
+
throw error;
|
|
1442
|
+
}
|
|
1443
|
+
if (current === specDir) break;
|
|
1444
|
+
current = path.dirname(current);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function recoverTransaction(workRoot, transactionDir, rawManifest) {
|
|
1449
|
+
const manifest = validateTransactionManifest(workRoot, transactionDir, rawManifest);
|
|
1450
|
+
if (processIsAlive(manifest.pid)) return false;
|
|
1451
|
+
for (let index = manifest.entries.length - 1; index >= 0; index -= 1) {
|
|
1452
|
+
const entry = manifest.entries[index];
|
|
1453
|
+
const target = path.join(manifest.specDir, entry.path);
|
|
1454
|
+
const backup = path.join(transactionDir, 'backup', entry.path);
|
|
1455
|
+
const targetDigest = fileDigestOrNull(target);
|
|
1456
|
+
const backupDigest = fileDigestOrNull(backup);
|
|
1457
|
+
if (entry.before.exists) {
|
|
1458
|
+
if (backupDigest !== null && backupDigest !== entry.before.digest) {
|
|
1459
|
+
throw new Error(`recovery cannot prove backup ownership for ${entry.path}`);
|
|
1460
|
+
}
|
|
1461
|
+
if (targetDigest === entry.after_digest) {
|
|
1462
|
+
removeTarget(target);
|
|
1463
|
+
} else if (targetDigest !== null && targetDigest !== entry.before.digest) {
|
|
1464
|
+
throw new Error(`recovery refuses to overwrite unowned target ${entry.path}`);
|
|
1465
|
+
}
|
|
1466
|
+
if (fileDigestOrNull(target) === null) {
|
|
1467
|
+
if (backupDigest !== entry.before.digest) {
|
|
1468
|
+
throw new Error(`recovery is missing the owned backup for ${entry.path}`);
|
|
1469
|
+
}
|
|
1470
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1471
|
+
fs.renameSync(backup, target);
|
|
1472
|
+
}
|
|
1473
|
+
} else if (targetDigest === entry.after_digest) {
|
|
1474
|
+
removeTarget(target);
|
|
1475
|
+
} else if (targetDigest !== null) {
|
|
1476
|
+
throw new Error(`recovery refuses to delete unowned target ${entry.path}`);
|
|
1477
|
+
}
|
|
1478
|
+
removeEmptyTransactionParents(manifest.specDir, target);
|
|
1479
|
+
}
|
|
1480
|
+
fs.rmSync(transactionDir, { recursive: true, force: true });
|
|
1481
|
+
fsyncDirectory(workRoot);
|
|
1482
|
+
return true;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function recoverTransactions(workRoot) {
|
|
1486
|
+
let recovered = 0;
|
|
1487
|
+
for (const entry of fs.readdirSync(workRoot, { withFileTypes: true })) {
|
|
1488
|
+
if (!entry.name.startsWith(TRANSACTION_PREFIX)) continue;
|
|
1489
|
+
const transactionDir = path.join(workRoot, entry.name);
|
|
1490
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
1491
|
+
failPrecondition(`scaffold transaction identity is not a directory: ${transactionDir}`);
|
|
1492
|
+
}
|
|
1493
|
+
const manifestPath = path.join(transactionDir, 'manifest.json');
|
|
1494
|
+
let manifest;
|
|
1495
|
+
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (error) {
|
|
1496
|
+
failPrecondition(`scaffold transaction cannot be recovered (${error.message}): ${transactionDir}`);
|
|
1497
|
+
}
|
|
1498
|
+
try {
|
|
1499
|
+
if (recoverTransaction(workRoot, transactionDir, manifest)) recovered += 1;
|
|
1500
|
+
} catch (error) {
|
|
1501
|
+
failPrecondition(`scaffold transaction recovery stopped safely (${error.message})`);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
return recovered;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function prepareTransaction(workRoot, specDir, changes, snapshots, transactionRoot, createSpecDir) {
|
|
1508
|
+
assertExistingDirectory(workRoot, transactionRoot, 'scaffold transaction directory');
|
|
1509
|
+
const transactionId = crypto.randomUUID();
|
|
1510
|
+
const transactionDir = path.join(transactionRoot, `${TRANSACTION_PREFIX}${transactionId}`);
|
|
1511
|
+
const stageDir = path.join(transactionDir, 'stage');
|
|
1512
|
+
const backupDir = path.join(transactionDir, 'backup');
|
|
1513
|
+
fs.mkdirSync(stageDir, { recursive: true });
|
|
1514
|
+
fs.mkdirSync(backupDir);
|
|
1515
|
+
const entries = [];
|
|
511
1516
|
try {
|
|
512
1517
|
for (const change of changes) {
|
|
513
1518
|
safeRelativePath(change.path, `staged path ${change.path}`);
|
|
514
1519
|
const staged = path.join(stageDir, change.path);
|
|
515
1520
|
fs.mkdirSync(path.dirname(staged), { recursive: true });
|
|
516
1521
|
fs.writeFileSync(staged, change.body, { encoding: 'utf8', flag: 'wx' });
|
|
1522
|
+
fsyncPath(staged);
|
|
1523
|
+
const before = snapshots.get(change.path);
|
|
1524
|
+
entries.push({
|
|
1525
|
+
path: change.path,
|
|
1526
|
+
before: { exists: before.exists, ...(before.exists ? { digest: digest(before.bytes) } : {}) },
|
|
1527
|
+
after_digest: digest(Buffer.from(change.body, 'utf8')),
|
|
1528
|
+
});
|
|
517
1529
|
}
|
|
518
|
-
|
|
1530
|
+
const manifest = {
|
|
1531
|
+
format: TRANSACTION_FORMAT,
|
|
1532
|
+
transaction_id: transactionId,
|
|
1533
|
+
pid: process.pid,
|
|
1534
|
+
work_root: workRoot,
|
|
1535
|
+
spec_dir: path.relative(workRoot, specDir),
|
|
1536
|
+
create_spec_dir: createSpecDir,
|
|
1537
|
+
entries,
|
|
1538
|
+
};
|
|
1539
|
+
const manifestPath = path.join(transactionDir, 'manifest.json');
|
|
1540
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
|
|
1541
|
+
fsyncPath(manifestPath);
|
|
1542
|
+
fsyncDirectory(transactionDir);
|
|
1543
|
+
fsyncDirectory(transactionRoot);
|
|
1544
|
+
return { transactionDir, stageDir, backupDir, manifest };
|
|
519
1545
|
} catch (error) {
|
|
520
|
-
fs.rmSync(
|
|
1546
|
+
fs.rmSync(transactionDir, { recursive: true, force: true });
|
|
521
1547
|
throw error;
|
|
522
1548
|
}
|
|
523
1549
|
}
|
|
@@ -538,6 +1564,21 @@ function ensureTargetParent(workRoot, specDir, relativePath, createdDirs) {
|
|
|
538
1564
|
}
|
|
539
1565
|
}
|
|
540
1566
|
|
|
1567
|
+
function ensureDirectoryTree(workRoot, directory, createdDirs, label) {
|
|
1568
|
+
assertNoSymlinkPath(workRoot, directory, label);
|
|
1569
|
+
const relative = path.relative(path.resolve(workRoot), path.resolve(directory));
|
|
1570
|
+
let current = path.resolve(workRoot);
|
|
1571
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
1572
|
+
current = path.join(current, segment);
|
|
1573
|
+
if (fs.existsSync(current)) {
|
|
1574
|
+
assertExistingDirectory(workRoot, current, label);
|
|
1575
|
+
} else {
|
|
1576
|
+
fs.mkdirSync(current);
|
|
1577
|
+
createdDirs.push(current);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
541
1582
|
function removeTarget(target) {
|
|
542
1583
|
try {
|
|
543
1584
|
const stat = fs.lstatSync(target);
|
|
@@ -548,40 +1589,32 @@ function removeTarget(target) {
|
|
|
548
1589
|
}
|
|
549
1590
|
}
|
|
550
1591
|
|
|
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 = [];
|
|
1592
|
+
function commitChanges(
|
|
1593
|
+
workRoot,
|
|
1594
|
+
specDir,
|
|
1595
|
+
changes,
|
|
1596
|
+
snapshots,
|
|
1597
|
+
failureAfter,
|
|
1598
|
+
{ createSpecDir = false, afterApply = null, transactionRoot = path.dirname(specDir) } = {},
|
|
1599
|
+
) {
|
|
1600
|
+
const transaction = prepareTransaction(
|
|
1601
|
+
workRoot, specDir, changes, snapshots, transactionRoot, createSpecDir,
|
|
1602
|
+
);
|
|
582
1603
|
const createdDirs = [];
|
|
583
1604
|
let writes = 0;
|
|
1605
|
+
let renames = 0;
|
|
1606
|
+
const crashAfter = parseCrashInjection();
|
|
1607
|
+
const renameBoundary = (from, to) => {
|
|
1608
|
+
fs.renameSync(from, to);
|
|
1609
|
+
renames += 1;
|
|
1610
|
+
fsyncDirectory(path.dirname(to));
|
|
1611
|
+
if (crashAfter !== null && renames >= crashAfter) process.kill(process.pid, 'SIGKILL');
|
|
1612
|
+
};
|
|
584
1613
|
try {
|
|
1614
|
+
if (createSpecDir) {
|
|
1615
|
+
if (fs.existsSync(specDir)) failPrecondition(`spec dir already exists: ${specDir}`);
|
|
1616
|
+
ensureDirectoryTree(workRoot, specDir, createdDirs, 'spec directory');
|
|
1617
|
+
}
|
|
585
1618
|
for (const change of changes) {
|
|
586
1619
|
const target = path.join(specDir, change.path);
|
|
587
1620
|
const before = captureTarget(workRoot, specDir, change.path);
|
|
@@ -590,50 +1623,68 @@ function commitChanges(workRoot, specDir, changes, snapshots, failureAfter) {
|
|
|
590
1623
|
failPrecondition(`target changed during preflight: ${change.path}`);
|
|
591
1624
|
}
|
|
592
1625
|
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);
|
|
1626
|
+
const backup = path.join(transaction.backupDir, change.path);
|
|
600
1627
|
if (before.exists) {
|
|
601
|
-
fs.mkdirSync(path.dirname(
|
|
602
|
-
|
|
603
|
-
record.backupMoved = true;
|
|
1628
|
+
fs.mkdirSync(path.dirname(backup), { recursive: true });
|
|
1629
|
+
renameBoundary(target, backup);
|
|
604
1630
|
}
|
|
605
|
-
|
|
606
|
-
record.targetInstalled = true;
|
|
1631
|
+
renameBoundary(path.join(transaction.stageDir, change.path), target);
|
|
607
1632
|
writes += 1;
|
|
608
1633
|
if (failureAfter !== null && writes >= failureAfter) {
|
|
609
1634
|
throw new Error(`injected failure after ${writes} staged write(s)`);
|
|
610
1635
|
}
|
|
611
1636
|
}
|
|
1637
|
+
if (typeof afterApply === 'function') afterApply();
|
|
612
1638
|
} catch (error) {
|
|
613
1639
|
let rollbackError = null;
|
|
614
1640
|
try {
|
|
615
|
-
|
|
1641
|
+
const manifest = { ...transaction.manifest, pid: -1 };
|
|
1642
|
+
recoverTransaction(workRoot, transaction.transactionDir, manifest);
|
|
616
1643
|
} catch (rollbackFailure) {
|
|
617
1644
|
rollbackError = rollbackFailure;
|
|
618
1645
|
}
|
|
619
|
-
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
620
1646
|
if (rollbackError) throw new Error(`${error.message}; rollback failed: ${rollbackError.message}`);
|
|
621
1647
|
throw new Error(`${error.message}; transaction rolled back`);
|
|
622
1648
|
}
|
|
623
|
-
fs.rmSync(
|
|
624
|
-
|
|
1649
|
+
fs.rmSync(transaction.transactionDir, { recursive: true, force: true });
|
|
1650
|
+
fsyncDirectory(transactionRoot);
|
|
625
1651
|
}
|
|
626
1652
|
|
|
627
1653
|
function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, parsed, artifactDeclarations, taskTpl, ts }) {
|
|
628
1654
|
const workRoot = path.resolve(process.cwd());
|
|
629
1655
|
assertExistingDirectory(workRoot, specDir, 'spec directory');
|
|
630
1656
|
assertRegularFileOrMissing(workRoot, specPath, 'spec.json');
|
|
1657
|
+
const requirementsPath = path.join(specDir, 'requirements.md');
|
|
1658
|
+
const designPath = path.join(specDir, 'design.md');
|
|
1659
|
+
assertRegularFileOrMissing(workRoot, requirementsPath, 'requirements.md');
|
|
1660
|
+
assertRegularFileOrMissing(workRoot, designPath, 'design.md');
|
|
631
1661
|
const topology = inspectTaskTopology(workRoot, specDir, spec, parsed, artifactDeclarations);
|
|
1662
|
+
const allTaskFiles = [...new Set([...topology.declared, ...parsed.map((task) => task.file)])].sort();
|
|
1663
|
+
if (args.taskTriggers !== null || args.coordination || args.dependencies !== null || args.artifacts !== null) {
|
|
1664
|
+
failPrecondition('schema 2.1 mutation uses --boundaries as the only topology authority');
|
|
1665
|
+
}
|
|
1666
|
+
const boundaries = parseBoundaryDeclarations(args.boundaries, allTaskFiles);
|
|
1667
|
+
if (boundaries.length === 0) {
|
|
1668
|
+
failPrecondition('--tasks-only requires a complete non-empty --boundaries array for the resulting task bundle');
|
|
1669
|
+
}
|
|
1670
|
+
const dependencyDeclarations = dependenciesFromBoundaries(boundaries, allTaskFiles);
|
|
1671
|
+
const phases = args.phases === null && Array.isArray(spec.coordination?.phases)
|
|
1672
|
+
? cloneJson(spec.coordination.phases)
|
|
1673
|
+
: parsePhaseDeclarations(args.phases, allTaskFiles);
|
|
1674
|
+
validatePhaseBoundaryRefs(phases, boundaries);
|
|
1675
|
+
for (const task of parsed) {
|
|
1676
|
+
if (!topology.existingSet.has(task.file) || !dependencyDeclarations.has(task.file)) continue;
|
|
1677
|
+
const existing = topology.registry[task.file].dependencies;
|
|
1678
|
+
const requested = dependencyDeclarations.get(task.file);
|
|
1679
|
+
if (JSON.stringify(existing) !== JSON.stringify(requested)) {
|
|
1680
|
+
failPrecondition(`conflicting duplicate task ${task.raw}: existing registry dependencies differ`);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
632
1683
|
|
|
633
|
-
let nextPolicy =
|
|
1684
|
+
let nextPolicy = POLICY.canonicalWorkflowPolicySnapshot(spec);
|
|
634
1685
|
if (args.risks && args.risks.length > 0) {
|
|
635
1686
|
try {
|
|
636
|
-
nextPolicy = POLICY.escalateWorkflowPolicy(
|
|
1687
|
+
nextPolicy = POLICY.escalateWorkflowPolicy(nextPolicy, { risks: args.risks });
|
|
637
1688
|
} catch (error) {
|
|
638
1689
|
failPrecondition(`--tasks-only cannot escalate workflow policy (${error.message})`);
|
|
639
1690
|
}
|
|
@@ -641,40 +1692,61 @@ function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, par
|
|
|
641
1692
|
if (!validation.valid) failPrecondition(`--tasks-only escalation produced an invalid workflow_policy (${validation.errors.join('; ')})`);
|
|
642
1693
|
}
|
|
643
1694
|
|
|
644
|
-
const needsResearch =
|
|
1695
|
+
const needsResearch = args.research === true;
|
|
645
1696
|
const researchPath = validateResearchTopology(workRoot, specDir, spec, needsResearch);
|
|
646
1697
|
const researchExists = fs.existsSync(researchPath);
|
|
647
1698
|
const researchTemplate = needsResearch && !researchExists
|
|
648
|
-
?
|
|
1699
|
+
? renderResearchTemplate(args.feature, args.uncertainty)
|
|
649
1700
|
: null;
|
|
650
1701
|
const newTasks = parsed.filter((task) => !topology.existingSet.has(task.file));
|
|
651
1702
|
const nextSpec = cloneJson(spec);
|
|
652
|
-
let specChanged = false;
|
|
653
1703
|
if (!topology.hasTaskTopology && newTasks.length > 0) {
|
|
654
1704
|
nextSpec.task_files = [];
|
|
655
1705
|
nextSpec.task_registry = {};
|
|
656
|
-
specChanged = true;
|
|
657
1706
|
}
|
|
658
1707
|
for (const task of newTasks) {
|
|
659
1708
|
nextSpec.task_files.push(task.file);
|
|
660
|
-
nextSpec.task_registry[task.file] = registryEntry(
|
|
661
|
-
|
|
1709
|
+
nextSpec.task_registry[task.file] = registryEntry(
|
|
1710
|
+
task,
|
|
1711
|
+
artifactDeclarations,
|
|
1712
|
+
dependencyDeclarations.get(task.file) || [],
|
|
1713
|
+
);
|
|
662
1714
|
}
|
|
663
|
-
if (
|
|
664
|
-
nextSpec.
|
|
665
|
-
specChanged = true;
|
|
1715
|
+
if (needsResearch && nextSpec.research !== 'research.md') {
|
|
1716
|
+
nextSpec.research = 'research.md';
|
|
666
1717
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
1718
|
+
nextSpec.task_files.sort();
|
|
1719
|
+
const normalized = normalizeSpec21Candidate(nextSpec, {
|
|
1720
|
+
featureName: args.feature,
|
|
1721
|
+
timestamp: ts,
|
|
1722
|
+
requirementsExists: fs.existsSync(requirementsPath),
|
|
1723
|
+
designExists: fs.existsSync(designPath),
|
|
1724
|
+
researchExists: researchExists || researchTemplate !== null,
|
|
1725
|
+
researchChanged: researchTemplate !== null,
|
|
1726
|
+
taskFiles: allTaskFiles,
|
|
1727
|
+
tasksChanged: newTasks.length > 0,
|
|
1728
|
+
coordination: { boundaries, ...(phases.length > 0 ? { phases } : {}) },
|
|
1729
|
+
workflowPolicy: nextPolicy,
|
|
1730
|
+
});
|
|
1731
|
+
if (!normalized.ok) failPrecondition(`schema 2.1 migration rejected candidate (${normalized.error})`);
|
|
1732
|
+
const normalizedSpec = normalized.spec;
|
|
1733
|
+
const specChanged = JSON.stringify(normalizedSpec) !== JSON.stringify(spec);
|
|
1734
|
+
if (specChanged && normalizedSpec.updated_at === spec.created_at && ts !== spec.created_at) {
|
|
1735
|
+
failPrecondition('schema 2.1 migration produced a stale updated_at timestamp');
|
|
670
1736
|
}
|
|
671
1737
|
|
|
672
1738
|
const changes = newTasks.map((task) => ({
|
|
673
1739
|
path: task.file,
|
|
674
|
-
body: fillTask(
|
|
1740
|
+
body: fillTask(
|
|
1741
|
+
taskTpl,
|
|
1742
|
+
task,
|
|
1743
|
+
args.feature,
|
|
1744
|
+
dependencyDeclarations.get(task.file) || [],
|
|
1745
|
+
taskHasDeclaredImplementationObligation(normalizedSpec, task.file),
|
|
1746
|
+
),
|
|
675
1747
|
}));
|
|
676
1748
|
if (researchTemplate !== null) changes.push({ path: 'research.md', body: researchTemplate });
|
|
677
|
-
if (specChanged) changes.push({ path: 'spec.json', body: `${JSON.stringify(
|
|
1749
|
+
if (specChanged) changes.push({ path: 'spec.json', body: `${JSON.stringify(normalizedSpec, null, 2)}\n` });
|
|
678
1750
|
if (changes.length === 0) return { createdTasks: [], researchCreated: false };
|
|
679
1751
|
|
|
680
1752
|
const expectedAbsent = new Set(newTasks.map((task) => task.file));
|
|
@@ -687,22 +1759,101 @@ function scaffoldTasksOnly({ args, specDir, specPath, spec, persistedPolicy, par
|
|
|
687
1759
|
}
|
|
688
1760
|
snapshots.set(change.path, snapshot);
|
|
689
1761
|
}
|
|
690
|
-
commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection()
|
|
1762
|
+
commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection(), {
|
|
1763
|
+
transactionRoot: workRoot,
|
|
1764
|
+
afterApply: () => observeInstalledPolicyBaseline({ args, specDir, spec: normalizedSpec }),
|
|
1765
|
+
});
|
|
691
1766
|
return { createdTasks: newTasks.map((task) => task.file), researchCreated: researchTemplate !== null };
|
|
692
1767
|
}
|
|
693
1768
|
|
|
1769
|
+
function syncSemanticModel({ workRoot, args, specDir, ts }) {
|
|
1770
|
+
assertExistingDirectory(workRoot, specDir, 'spec directory');
|
|
1771
|
+
const specPath = path.join(specDir, 'spec.json');
|
|
1772
|
+
assertRegularFileOrMissing(workRoot, specPath, 'spec.json');
|
|
1773
|
+
if (!fs.existsSync(specPath)) failPrecondition('--sync-semantic-model requires an existing spec.json');
|
|
1774
|
+
let spec;
|
|
1775
|
+
try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); }
|
|
1776
|
+
catch (error) { failPrecondition(`--sync-semantic-model spec.json is invalid JSON (${error.message})`); }
|
|
1777
|
+
if (!isPlainObject(spec) || spec.schema_version !== '2.1') failPrecondition('--sync-semantic-model accepts canonical schema 2.1 only');
|
|
1778
|
+
assertCanonicalSpecInputClosedWorld(spec);
|
|
1779
|
+
const projection = SEMANTIC.modelFromMarkdown(specDir, spec);
|
|
1780
|
+
if (projection.errors.length) failPrecondition(`semantic projection is invalid (${projection.errors.join('; ')})`);
|
|
1781
|
+
const modelUnchanged = SEMANTIC.stableJson(spec.semantic_model) === SEMANTIC.stableJson(projection.model);
|
|
1782
|
+
if (modelUnchanged) {
|
|
1783
|
+
console.log(`SEMANTIC_MODEL_UNCHANGED ${path.relative(process.cwd(), specDir) || specDir}`);
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
const next = cloneJson(spec);
|
|
1787
|
+
next.semantic_model = projection.model;
|
|
1788
|
+
next.updated_at = ts;
|
|
1789
|
+
next.ready_for_implementation = false;
|
|
1790
|
+
next.validation = { status: 'not-run', semantic_review: semanticReviewNotRun() };
|
|
1791
|
+
preflightSpec21Candidate(next, {
|
|
1792
|
+
featureName: args.feature,
|
|
1793
|
+
requirementsExists: fs.existsSync(path.join(specDir, 'requirements.md')),
|
|
1794
|
+
designExists: fs.existsSync(path.join(specDir, 'design.md')),
|
|
1795
|
+
researchExists: fs.existsSync(path.join(specDir, 'research.md')),
|
|
1796
|
+
taskFiles: listExistingTaskFiles(workRoot, specDir),
|
|
1797
|
+
});
|
|
1798
|
+
const snapshot = captureTarget(workRoot, specDir, 'spec.json');
|
|
1799
|
+
commitChanges(
|
|
1800
|
+
workRoot,
|
|
1801
|
+
specDir,
|
|
1802
|
+
[{ path: 'spec.json', body: `${JSON.stringify(next, null, 2)}\n` }],
|
|
1803
|
+
new Map([['spec.json', snapshot]]),
|
|
1804
|
+
parseFailureInjection(),
|
|
1805
|
+
{ transactionRoot: workRoot },
|
|
1806
|
+
);
|
|
1807
|
+
console.log(`SEMANTIC_MODEL_SYNCED ${path.relative(process.cwd(), specDir) || specDir}`);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
694
1810
|
function main() {
|
|
1811
|
+
const workRoot = path.resolve(process.cwd());
|
|
1812
|
+
const recoveredTransactions = recoverTransactions(workRoot);
|
|
1813
|
+
if (recoveredTransactions > 0) {
|
|
1814
|
+
console.error(`recovered ${recoveredTransactions} interrupted scaffold transaction(s)`);
|
|
1815
|
+
}
|
|
695
1816
|
const args = parseArgs(process.argv);
|
|
696
|
-
if (
|
|
1817
|
+
if (args.syncSemanticModel) {
|
|
1818
|
+
if (!args.feature || args.tasks !== null || args.tasksOnly || args.boundaries !== null || args.research
|
|
1819
|
+
|| args.planningDepth || args.assuranceLevel || args.lane) {
|
|
1820
|
+
failPrecondition('--sync-semantic-model is an isolated explicit promotion mode and cannot be combined with authoring mutations');
|
|
1821
|
+
}
|
|
1822
|
+
safeRelativePath(args.specsRoot, '--specs-root');
|
|
1823
|
+
safeRelativePath(args.feature, 'feature');
|
|
1824
|
+
const specDir = path.resolve(workRoot, args.specsRoot, args.feature);
|
|
1825
|
+
if (!isWithin(workRoot, specDir)) failPrecondition(`spec directory escapes the work root: ${specDir}`);
|
|
1826
|
+
syncSemanticModel({ workRoot, args, specDir, ts: nowIso() });
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
if (!args.feature || (args.tasks === null && args.tasksOnly) || (args.coordination && !args.tasks)) {
|
|
697
1830
|
usage();
|
|
698
1831
|
failPrecondition('missing feature or required task arguments');
|
|
699
1832
|
}
|
|
1833
|
+
if (args.research && !concreteUncertainty(args.uncertainty)) {
|
|
1834
|
+
failPrecondition('--research requires --uncertainty with one concrete unresolved question (at least 12 trimmed characters)');
|
|
1835
|
+
}
|
|
1836
|
+
if (!args.research && args.uncertainty !== null) {
|
|
1837
|
+
failPrecondition('--uncertainty is only valid with --research');
|
|
1838
|
+
}
|
|
700
1839
|
if (typeof args.lane === 'string' && args.lane.toLowerCase() === 'direct') {
|
|
701
1840
|
failPrecondition('Direct lane does not create a spec; run targeted verification from the Direct workflow instead');
|
|
702
1841
|
}
|
|
703
1842
|
if (args.lane && !POLICY.LANES.includes(args.lane)) {
|
|
704
1843
|
failPrecondition(`--lane must be one of ${POLICY.LANES.join(', ')}`);
|
|
705
1844
|
}
|
|
1845
|
+
if (args.planningDepth && !POLICY.PLANNING_DEPTHS.includes(args.planningDepth)) {
|
|
1846
|
+
failPrecondition(`--planning-depth must be one of ${POLICY.PLANNING_DEPTHS.join(', ')}`);
|
|
1847
|
+
}
|
|
1848
|
+
if (args.planningDepth === 'None') {
|
|
1849
|
+
failPrecondition('None planning depth does not create a durable spec; use the Direct workflow instead');
|
|
1850
|
+
}
|
|
1851
|
+
if (args.assuranceLevel && !POLICY.ASSURANCE_LEVELS.includes(args.assuranceLevel)) {
|
|
1852
|
+
failPrecondition(`--assurance-level must be one of ${POLICY.ASSURANCE_LEVELS.join(', ')}`);
|
|
1853
|
+
}
|
|
1854
|
+
if (args.lane && (args.planningDepth || args.assuranceLevel)) {
|
|
1855
|
+
failPrecondition('--lane is a compatibility adapter and cannot be combined with Specs v2 axis flags');
|
|
1856
|
+
}
|
|
706
1857
|
|
|
707
1858
|
const ids = (args.tasks || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
708
1859
|
if (args.tasksOnly && ids.length === 0) {
|
|
@@ -720,10 +1871,29 @@ function main() {
|
|
|
720
1871
|
seenIds.add(logicalId);
|
|
721
1872
|
parsed.push({ raw: id, req: m[1], seq: m[2], slug: m[3], file: `tasks/task-${id}.md` });
|
|
722
1873
|
}
|
|
1874
|
+
if (args.phases !== null && parsed.length === 0) {
|
|
1875
|
+
failPrecondition('--phases requires --tasks because phase grouping needs a task graph');
|
|
1876
|
+
}
|
|
1877
|
+
if (args.phases !== null && args.planningDepth !== 'Full') {
|
|
1878
|
+
failPrecondition('--phases requires explicit --planning-depth Full before any scaffold writes');
|
|
1879
|
+
}
|
|
723
1880
|
const artifactDeclarations = parseArtifactDeclarations(args.artifacts, parsed);
|
|
1881
|
+
if (args.taskTriggers !== null || args.coordination || args.dependencies !== null || args.artifacts !== null) {
|
|
1882
|
+
failPrecondition('schema 2.1 authoring uses --boundaries as the only topology authority; task triggers, coordination markers, dependencies, and artifact maps are read compatibility only');
|
|
1883
|
+
}
|
|
1884
|
+
const boundaries = args.tasksOnly ? null : parseBoundaryDeclarations(
|
|
1885
|
+
args.boundaries,
|
|
1886
|
+
parsed.map((task) => task.file).sort(),
|
|
1887
|
+
);
|
|
1888
|
+
const dependencyDeclarations = args.tasksOnly
|
|
1889
|
+
? null
|
|
1890
|
+
: dependenciesFromBoundaries(boundaries, parsed.map((task) => task.file).sort());
|
|
724
1891
|
const riskNames = parseRiskNames(args.risks);
|
|
1892
|
+
const phases = args.tasksOnly
|
|
1893
|
+
? []
|
|
1894
|
+
: parsePhaseDeclarations(args.phases, parsed.map((task) => task.file));
|
|
1895
|
+
if (!args.tasksOnly) validatePhaseBoundaryRefs(phases, boundaries);
|
|
725
1896
|
|
|
726
|
-
const workRoot = path.resolve(process.cwd());
|
|
727
1897
|
safeRelativePath(args.specsRoot, '--specs-root');
|
|
728
1898
|
safeRelativePath(args.feature, 'feature');
|
|
729
1899
|
if (!FEATURE_NAME_RE.test(args.feature)) {
|
|
@@ -750,7 +1920,12 @@ function main() {
|
|
|
750
1920
|
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
751
1921
|
failPrecondition('--tasks-only existing spec.json must contain a JSON object');
|
|
752
1922
|
}
|
|
1923
|
+
assertCanonicalSpecInputClosedWorld(spec);
|
|
1924
|
+
if (spec.schema_version === '2.0') assertLegacyMigrationClosedWorld(spec);
|
|
753
1925
|
const persistedPolicy = readPersistedPolicy(spec);
|
|
1926
|
+
if (args.planningDepth && args.planningDepth !== persistedPolicy.planning_depth) {
|
|
1927
|
+
failPrecondition(`--tasks-only --planning-depth ${args.planningDepth} does not match persisted workflow_policy.planning_depth ${persistedPolicy.planning_depth}`);
|
|
1928
|
+
}
|
|
754
1929
|
if (args.lane && args.lane !== persistedPolicy.lane) {
|
|
755
1930
|
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
1931
|
}
|
|
@@ -769,89 +1944,129 @@ function main() {
|
|
|
769
1944
|
const relTo = path.relative(process.cwd(), specDir) || specDir;
|
|
770
1945
|
console.log(`SCAFFOLDED (tasks-only) ${relTo}`);
|
|
771
1946
|
console.log(`- ${createdTasks.length} new task stub(s); task_files + task_registry merged.`);
|
|
772
|
-
if (result.researchCreated) console.log('- research.md created
|
|
1947
|
+
if (result.researchCreated) console.log('- research.md created by explicit --research request.');
|
|
773
1948
|
console.log(`- NEXT: Edit-fill {{...}} in each stub; set dependencies; run validator + spec-ground.`);
|
|
774
1949
|
return;
|
|
775
1950
|
}
|
|
776
1951
|
|
|
777
|
-
const policyInput = args.lane
|
|
1952
|
+
const policyInput = args.lane
|
|
1953
|
+
? { risks: riskNames, override: args.lane }
|
|
1954
|
+
: {
|
|
1955
|
+
risks: riskNames,
|
|
1956
|
+
...(args.planningDepth ? { planning_depth: args.planningDepth } : {}),
|
|
1957
|
+
...(args.assuranceLevel ? { assurance_level: args.assuranceLevel } : {}),
|
|
1958
|
+
};
|
|
778
1959
|
let selectedPolicy;
|
|
779
1960
|
try {
|
|
780
1961
|
selectedPolicy = POLICY.workflowPolicySnapshot(policyInput);
|
|
781
1962
|
} catch (error) {
|
|
782
1963
|
failPrecondition(error.message);
|
|
783
1964
|
}
|
|
784
|
-
if (
|
|
785
|
-
|
|
786
|
-
|
|
1965
|
+
if (parsed.length > 0 && boundaries.length === 0) {
|
|
1966
|
+
failPrecondition('physical task bundle requires at least one typed coordination.boundaries entry with semantic evidence');
|
|
1967
|
+
}
|
|
1968
|
+
if (parsed.length === 0 && args.boundaries !== null) {
|
|
1969
|
+
failPrecondition('--boundaries requires --tasks');
|
|
787
1970
|
}
|
|
788
|
-
|
|
789
1971
|
if (fs.existsSync(specDir)) {
|
|
790
1972
|
console.error(`precondition: spec dir already exists, refusing to overwrite: ${specDir}`);
|
|
791
1973
|
process.exit(2);
|
|
792
1974
|
}
|
|
793
1975
|
|
|
794
|
-
fs.mkdirSync(specDir, { recursive: true });
|
|
795
|
-
if (parsed.length > 0) {
|
|
796
|
-
fs.mkdirSync(path.join(specDir, 'tasks'), { recursive: true });
|
|
797
|
-
}
|
|
798
|
-
|
|
799
1976
|
// --- spec.json (from spec-state.json template) ---
|
|
800
1977
|
const spec = JSON.parse(readTemplate('spec-state.json'));
|
|
801
1978
|
spec.feature_name = args.feature;
|
|
802
1979
|
spec.created_at = ts;
|
|
803
1980
|
spec.updated_at = ts;
|
|
804
1981
|
spec.language = args.lang;
|
|
805
|
-
spec.timestamps = spec.timestamps || {};
|
|
806
|
-
spec.timestamps.init = ts;
|
|
807
1982
|
spec.scope_lock = spec.scope_lock || {};
|
|
808
1983
|
spec.scope_lock.source = args.title || `{{PROJECT_DESCRIPTION}}`;
|
|
809
1984
|
delete spec.workflow_policy;
|
|
810
1985
|
delete spec.override_receipt;
|
|
811
1986
|
Object.assign(spec, POLICY.persistWorkflowPolicySnapshot(spec, policyInput));
|
|
1987
|
+
spec.validation = isPlainObject(spec.validation) ? spec.validation : {};
|
|
1988
|
+
spec.validation.semantic_review = semanticReviewNotRun();
|
|
1989
|
+
if (args.research) {
|
|
1990
|
+
spec.research = 'research.md';
|
|
1991
|
+
spec.authoring.research = 'draft';
|
|
1992
|
+
}
|
|
1993
|
+
spec.coordination = {
|
|
1994
|
+
boundaries,
|
|
1995
|
+
...(phases.length > 0 ? { phases } : {}),
|
|
1996
|
+
};
|
|
812
1997
|
if (parsed.length > 0) {
|
|
1998
|
+
spec.authoring.tasks = 'draft';
|
|
813
1999
|
spec.task_files = parsed.map((t) => t.file);
|
|
814
2000
|
spec.task_registry = {};
|
|
815
|
-
for (const t of parsed)
|
|
2001
|
+
for (const t of parsed) {
|
|
2002
|
+
spec.task_registry[t.file] = registryEntry(
|
|
2003
|
+
t,
|
|
2004
|
+
artifactDeclarations,
|
|
2005
|
+
dependencyDeclarations.get(t.file) || [],
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
816
2008
|
} else {
|
|
817
2009
|
delete spec.task_files;
|
|
818
2010
|
delete spec.task_registry;
|
|
819
2011
|
}
|
|
820
|
-
|
|
821
|
-
|
|
2012
|
+
if (args.research) spec.authoring.research = 'draft';
|
|
2013
|
+
preflightSpec21Candidate(spec, {
|
|
2014
|
+
featureName: args.feature,
|
|
2015
|
+
requirementsExists: true,
|
|
2016
|
+
designExists: true,
|
|
2017
|
+
researchExists: args.research,
|
|
2018
|
+
taskFiles: parsed.map((task) => task.file),
|
|
2019
|
+
});
|
|
2020
|
+
const changes = [{ path: 'spec.json', body: `${JSON.stringify(spec, null, 2)}\n` }];
|
|
822
2021
|
created.push('spec.json');
|
|
823
2022
|
|
|
824
2023
|
// --- doc templates (placeholders left for the model to fill) ---
|
|
825
2024
|
const docs = [
|
|
826
2025
|
['requirements.md', 'requirements.md'],
|
|
827
|
-
...(
|
|
828
|
-
? [['research.md', 'research.md']]
|
|
829
|
-
: []),
|
|
2026
|
+
...(args.research ? [['research.md', 'research.md']] : []),
|
|
830
2027
|
['design.md', 'design.md'],
|
|
831
2028
|
];
|
|
832
2029
|
for (const [tpl, out] of docs) {
|
|
833
|
-
const
|
|
834
|
-
|
|
2030
|
+
const source = tpl === 'research.md'
|
|
2031
|
+
? renderResearchTemplate(args.feature, args.uncertainty)
|
|
2032
|
+
: readTemplate(tpl);
|
|
2033
|
+
const body = applyExecutionClosureProfile(
|
|
2034
|
+
source,
|
|
2035
|
+
tpl === 'design.md'
|
|
2036
|
+
&& (selectedPolicy.lane === 'Critical' || declaredImplementationObligations(spec).length > 0),
|
|
2037
|
+
).replace(/\{\{FEATURE_NAME\}\}/g, args.feature);
|
|
2038
|
+
changes.push({ path: out, body });
|
|
835
2039
|
created.push(out);
|
|
836
2040
|
}
|
|
837
2041
|
|
|
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
2042
|
// --- task stubs (fill the cheap placeholders; leave the rest) ---
|
|
845
2043
|
for (const t of parsed) {
|
|
846
|
-
|
|
2044
|
+
changes.push({
|
|
2045
|
+
path: t.file,
|
|
2046
|
+
body: fillTask(
|
|
2047
|
+
taskTpl,
|
|
2048
|
+
t,
|
|
2049
|
+
args.feature,
|
|
2050
|
+
dependencyDeclarations.get(t.file) || [],
|
|
2051
|
+
taskHasDeclaredImplementationObligation(spec, t.file),
|
|
2052
|
+
),
|
|
2053
|
+
});
|
|
847
2054
|
created.push(t.file);
|
|
848
2055
|
}
|
|
849
2056
|
|
|
2057
|
+
const snapshots = new Map();
|
|
2058
|
+
for (const change of changes) snapshots.set(change.path, captureTarget(workRoot, specDir, change.path));
|
|
2059
|
+
commitChanges(workRoot, specDir, changes, snapshots, parseFailureInjection(), {
|
|
2060
|
+
createSpecDir: true,
|
|
2061
|
+
transactionRoot: workRoot,
|
|
2062
|
+
afterApply: () => observeInstalledPolicyBaseline({ args, specDir, spec }),
|
|
2063
|
+
});
|
|
2064
|
+
|
|
850
2065
|
const rel = path.relative(process.cwd(), specDir) || specDir;
|
|
851
2066
|
console.log(`SCAFFOLDED ${rel}`);
|
|
852
|
-
console.log(`- ${created.length} files created: spec.json + ${docs.length} docs +
|
|
2067
|
+
console.log(`- ${created.length} files created: spec.json + ${docs.length} docs + ${parsed.length} task stub(s)`);
|
|
853
2068
|
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
|
|
2069
|
+
console.log('- NEXT: Fill all spec placeholders (do NOT leave any); run validator + spec-ground before ready.');
|
|
855
2070
|
}
|
|
856
2071
|
|
|
857
2072
|
try {
|