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