thoth-agents 0.4.2 → 0.5.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/LICENSE +30 -0
- package/README.md +86 -58
- package/dist/agents/prompt-sections.d.ts +5 -3
- package/dist/agents/prompt-utils.d.ts +1 -1
- package/dist/agents/worker.d.ts +2 -0
- package/dist/{chunk-STCURXAQ.js → chunk-MRFCA3U2.js} +33 -67
- package/dist/{chunk-EFGSGPYM.js → chunk-OW6I7ZUU.js} +391 -416
- package/dist/chunk-V7HSGRMB.js +1016 -0
- package/dist/{chunk-WCLUZZHI.js → chunk-VUNYH263.js} +1580 -351
- package/dist/cli/claude-code-paths.d.ts +2 -2
- package/dist/cli/codex-install.d.ts +5 -2
- package/dist/cli/codex-paths.d.ts +2 -2
- package/dist/cli/index.js +47 -11
- package/dist/cli/operations/pi.d.ts +1 -0
- package/dist/cli/pi-effort.d.ts +1 -1
- package/dist/cli/pi-external-package.d.ts +24 -0
- package/dist/cli/pi-git-source.d.ts +1 -0
- package/dist/cli/pi-install.d.ts +92 -15
- package/dist/cli/pi-managed-write.d.ts +3 -0
- package/dist/cli/pi-model-config.d.ts +17 -0
- package/dist/cli/pi-native-probe.d.ts +2 -0
- package/dist/cli/pi-paths.d.ts +2 -0
- package/dist/cli/pi-resources.d.ts +3 -1
- package/dist/cli/pi-tool-config.d.ts +32 -0
- package/dist/cli/tui/index.js +3 -3
- package/dist/cli/types.d.ts +2 -0
- package/dist/config/constants.d.ts +4 -8
- package/dist/config/schema.d.ts +7 -36
- package/dist/harness/adapters/claude-code.d.ts +2 -4
- package/dist/harness/core/agent-pack.d.ts +52 -42
- package/dist/harness/core/memory-governance.d.ts +2 -2
- package/dist/harness/core/sdd.d.ts +159 -319
- package/dist/harness/writers/pi-agent.d.ts +3 -1
- package/dist/index.js +12 -6
- package/dist/pi/tools-panel.d.ts +51 -0
- package/dist/pi.d.ts +44 -2
- package/dist/pi.js +471 -9
- package/dist/utils/subprocess.d.ts +1 -0
- package/package.json +23 -6
- package/pi/.thoth-agents-assets.json +5 -6
- package/pi/agents/thoth-designer.md +23 -8
- package/pi/agents/thoth-explorer.md +29 -9
- package/pi/agents/thoth-librarian.md +30 -8
- package/pi/agents/thoth-oracle.md +18 -10
- package/pi/agents/thoth-worker.md +96 -0
- package/plugin/.claude-plugin/.thoth-agents-plugin-assets.json +7 -12
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +2 -2
- package/plugin/agents/designer.md +13 -5
- package/plugin/agents/explorer.md +16 -5
- package/plugin/agents/librarian.md +15 -4
- package/plugin/agents/oracle.md +13 -7
- package/plugin/agents/orchestrator.md +82 -91
- package/plugin/agents/worker.md +76 -0
- package/plugin/skills/plan-reviewer/SKILL.md +39 -141
- package/plugin/skills/thoth-archive/SKILL.md +50 -47
- package/plugin/skills/thoth-archive/scripts/archive.mjs +287 -654
- package/plugin/skills/thoth-constitution/SKILL.md +23 -42
- package/plugin/skills/thoth-constitution/templates/constitution.md +54 -44
- package/plugin/skills/thoth-init/SKILL.md +19 -29
- package/plugin/skills/thoth-init/scripts/init.mjs +73 -66
- package/plugin/skills/thoth-sdd/SKILL.md +164 -117
- package/plugin/skills/thoth-sdd/references/phases/checklist.md +2 -22
- package/plugin/skills/thoth-sdd/references/phases/clarify.md +14 -12
- package/plugin/skills/thoth-sdd/references/phases/converge.md +2 -13
- package/plugin/skills/thoth-sdd/references/phases/explore.md +3 -8
- package/plugin/skills/thoth-sdd/references/phases/implement.md +58 -57
- package/plugin/skills/thoth-sdd/references/phases/plan.md +2 -18
- package/plugin/skills/thoth-sdd/references/phases/specify.md +2 -33
- package/plugin/skills/thoth-sdd/references/phases/tasks.md +4 -45
- package/plugin/skills/thoth-sdd/references/phases/verify.md +4 -22
- package/plugin/skills/thoth-sdd/scripts/validate.mjs +610 -1232
- package/plugin/skills/thoth-sdd/templates/change.md +94 -0
- package/skills/README.md +11 -13
- package/skills/plan-reviewer/SKILL.md +39 -141
- package/skills/thoth-archive/SKILL.md +50 -47
- package/skills/thoth-archive/scripts/archive.mjs +287 -654
- package/skills/thoth-constitution/SKILL.md +23 -42
- package/skills/thoth-constitution/templates/constitution.md +54 -44
- package/skills/thoth-init/SKILL.md +19 -29
- package/skills/thoth-init/scripts/init.mjs +73 -66
- package/skills/thoth-sdd/SKILL.md +164 -117
- package/skills/thoth-sdd/references/phases/checklist.md +2 -22
- package/skills/thoth-sdd/references/phases/clarify.md +14 -12
- package/skills/thoth-sdd/references/phases/converge.md +2 -13
- package/skills/thoth-sdd/references/phases/explore.md +3 -8
- package/skills/thoth-sdd/references/phases/implement.md +58 -57
- package/skills/thoth-sdd/references/phases/plan.md +2 -18
- package/skills/thoth-sdd/references/phases/specify.md +2 -33
- package/skills/thoth-sdd/references/phases/tasks.md +4 -45
- package/skills/thoth-sdd/references/phases/verify.md +4 -22
- package/skills/thoth-sdd/scripts/validate.mjs +610 -1232
- package/skills/thoth-sdd/templates/change.md +94 -0
- package/thoth-agents.schema.json +9 -77
- package/dist/agents/deep.d.ts +0 -2
- package/dist/agents/quick.d.ts +0 -2
- package/dist/chunk-2U5MBLLV.js +0 -312
- package/pi/agents/thoth-deep.md +0 -81
- package/pi/agents/thoth-quick.md +0 -81
- package/plugin/agents/deep.md +0 -68
- package/plugin/agents/quick.md +0 -68
- package/plugin/skills/plan-reviewer/templates/plan-review.md +0 -65
- package/plugin/skills/thoth-sdd/templates/archive-report.md +0 -25
- package/plugin/skills/thoth-sdd/templates/checklist.md +0 -27
- package/plugin/skills/thoth-sdd/templates/plan.md +0 -46
- package/plugin/skills/thoth-sdd/templates/spec.md +0 -62
- package/plugin/skills/thoth-sdd/templates/tasks.md +0 -58
- package/plugin/skills/thoth-sdd/templates/verify-report.md +0 -28
- package/skills/plan-reviewer/templates/plan-review.md +0 -65
- package/skills/thoth-sdd/templates/archive-report.md +0 -25
- package/skills/thoth-sdd/templates/checklist.md +0 -27
- package/skills/thoth-sdd/templates/plan.md +0 -46
- package/skills/thoth-sdd/templates/spec.md +0 -62
- package/skills/thoth-sdd/templates/tasks.md +0 -58
- package/skills/thoth-sdd/templates/verify-report.md +0 -28
|
@@ -1,722 +1,355 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
3
|
import {
|
|
4
4
|
existsSync,
|
|
5
|
+
linkSync,
|
|
6
|
+
lstatSync,
|
|
5
7
|
mkdirSync,
|
|
8
|
+
readdirSync,
|
|
6
9
|
readFileSync,
|
|
7
10
|
renameSync,
|
|
8
11
|
rmdirSync,
|
|
9
12
|
rmSync,
|
|
10
13
|
writeFileSync,
|
|
11
14
|
} from 'node:fs';
|
|
12
|
-
import {
|
|
15
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
16
|
+
import { pathToFileURL } from 'node:url';
|
|
13
17
|
import {
|
|
14
|
-
CAPABILITY_PATTERN,
|
|
15
18
|
parseCanonicalSpec,
|
|
16
|
-
parseRequirementDelta as parseSharedRequirementDelta,
|
|
17
19
|
preflightRequirementDeltas,
|
|
18
20
|
} from '../../thoth-sdd/scripts/durable-deltas.mjs';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
throw new Error(`
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
function errorMessage(error) {
|
|
34
|
-
return error instanceof Error ? error.message : String(error);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function attemptRecovery(errors, label, operation) {
|
|
38
|
-
try {
|
|
39
|
-
operation();
|
|
40
|
-
} catch (error) {
|
|
41
|
-
errors.push(`${label}: ${errorMessage(error)}`);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function parseArgs(argv) {
|
|
46
|
-
const options = { json: false };
|
|
47
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
48
|
-
const argument = argv[index];
|
|
49
|
-
if (argument === '--json') options.json = true;
|
|
50
|
-
else if (argument === '--change') options.change = argv[++index];
|
|
51
|
-
else if (argument === '--date') options.date = argv[++index];
|
|
52
|
-
else throw new Error(`Unknown argument: ${argument}`);
|
|
53
|
-
}
|
|
54
|
-
if (!options.change) throw new Error('--change is required');
|
|
55
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(options.date ?? '')) {
|
|
56
|
-
throw new Error('--date must use YYYY-MM-DD');
|
|
57
|
-
}
|
|
58
|
-
return options;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function sectionContent(content, heading) {
|
|
62
|
-
const expression = new RegExp(`^##\\s+${heading}\\s*$`, 'im');
|
|
63
|
-
const match = expression.exec(content);
|
|
64
|
-
if (!match || match.index === undefined) return undefined;
|
|
65
|
-
const start = match.index + match[0].length;
|
|
66
|
-
const remainder = content.slice(start);
|
|
67
|
-
const nextHeading = /^##\s+/m.exec(remainder);
|
|
68
|
-
return remainder.slice(0, nextHeading?.index ?? remainder.length).trim();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function contractCandidateLines(content, prefix) {
|
|
72
|
-
const pattern = new RegExp(`^\\s*-\\s+(?:\\*\\*)?${prefix}\\b`, 'i');
|
|
73
|
-
return content.split(/\r?\n/).filter((line) => pattern.test(line));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function assertSequentialContractIds(ids, prefix) {
|
|
77
|
-
const valid =
|
|
78
|
-
new Set(ids).size === ids.length &&
|
|
79
|
-
ids.every(
|
|
80
|
-
(id, index) => id === `${prefix}-${String(index + 1).padStart(3, '0')}`,
|
|
81
|
-
);
|
|
82
|
-
if (!valid) {
|
|
83
|
-
throw new Error(`${prefix}-### identifiers must be unique and sequential`);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function parseComplianceMatrix(content) {
|
|
88
|
-
const matrix = sectionContent(content, 'Compliance matrix') ?? '';
|
|
89
|
-
const candidates = matrix
|
|
90
|
-
.split(/\r?\n/)
|
|
91
|
-
.filter((line) => /^\s*\|\s*(?:FR|SC)-/i.test(line));
|
|
92
|
-
const entries = [
|
|
93
|
-
...matrix.matchAll(
|
|
94
|
-
/^\|\s*((?:FR|SC)-\d{3})(?:\s+`?\[(?:buildable|outcome)\]`?)?\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|\s*(PASS|RISK|FAIL)\s*\|\s*$/gim,
|
|
95
|
-
),
|
|
96
|
-
].map((match) => ({
|
|
97
|
-
id: match[1].toUpperCase(),
|
|
98
|
-
evidence: match[2],
|
|
99
|
-
check: match[3],
|
|
100
|
-
result: match[4].toUpperCase(),
|
|
101
|
-
}));
|
|
102
|
-
return { entries, malformed: candidates.length !== entries.length };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function isConcreteEvidence(value) {
|
|
106
|
-
const normalized = value.replaceAll('`', '').trim();
|
|
107
|
-
return (
|
|
108
|
-
normalized.length > 0 &&
|
|
109
|
-
!/^\[.*\]$/.test(normalized) &&
|
|
110
|
-
!/^(?:N\/?A|NONE|PENDING|TBD|NOT YET|-)[.!]?$/i.test(normalized)
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function hasExplicitResidualRisk(content, id) {
|
|
115
|
-
const residualRisks = sectionContent(content, 'Residual risks') ?? '';
|
|
116
|
-
const match = new RegExp(`^-\\s+${id}\\s*:\\s*(\\S.*)$`, 'im').exec(
|
|
117
|
-
residualRisks,
|
|
118
|
-
);
|
|
119
|
-
return match !== null && isConcreteEvidence(match[1]);
|
|
21
|
+
import {
|
|
22
|
+
assertNoSymlinkAncestors,
|
|
23
|
+
resolveSddChangeLocation,
|
|
24
|
+
validate,
|
|
25
|
+
} from '../../thoth-sdd/scripts/validate.mjs';
|
|
26
|
+
|
|
27
|
+
function localPath(root, path) {
|
|
28
|
+
const absolute = resolve(root, path);
|
|
29
|
+
const rel = relative(root, absolute);
|
|
30
|
+
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
31
|
+
throw new Error(`Path escapes project: ${path}`);
|
|
32
|
+
assertNoSymlinkAncestors(absolute);
|
|
33
|
+
return absolute;
|
|
120
34
|
}
|
|
121
35
|
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
});
|
|
36
|
+
function bytes(path) {
|
|
37
|
+
if (!existsSync(path)) return null;
|
|
38
|
+
const stat = lstatSync(path);
|
|
39
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
40
|
+
throw new Error(`Expected regular file: ${path}`);
|
|
41
|
+
return readFileSync(path);
|
|
129
42
|
}
|
|
130
43
|
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
return ['Completeness', 'Correctness', 'Coherence'].every((dimension) => {
|
|
134
|
-
const match = new RegExp(
|
|
135
|
-
`^-\\s*\\*\\*${dimension}\\*\\*:\\s*(\\S.*)$`,
|
|
136
|
-
'im',
|
|
137
|
-
).exec(dimensions);
|
|
138
|
-
return match !== null && isConcreteEvidence(match[1]);
|
|
139
|
-
});
|
|
44
|
+
function sha(bytes) {
|
|
45
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
140
46
|
}
|
|
141
47
|
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
!/^\*\*Independent from implementer\*\*:\s*Yes(?:<br>)?\s*$/im.test(verify)
|
|
146
|
-
) {
|
|
147
|
-
throw new Error(
|
|
148
|
-
'verify-report.md requires an independent oracle reviewer before archive',
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
if (!/^\*\*Verdict\*\*:\s*PASS\s*$/im.test(verify)) {
|
|
152
|
-
throw new Error('verify-report.md must record PASS before archive');
|
|
153
|
-
}
|
|
154
|
-
if (hasBlockingCriticalFinding(verify)) {
|
|
155
|
-
throw new Error('Unresolved CRITICAL verification findings block archive');
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (!hasConcreteReviewDimensions(verify)) {
|
|
159
|
-
throw new Error(
|
|
160
|
-
'verify-report.md must record completeness, correctness, and coherence review dimensions',
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const matrix = parseComplianceMatrix(verify);
|
|
165
|
-
const knownIds = new Set([
|
|
166
|
-
...contract.frIds,
|
|
167
|
-
...contract.buildableScIds,
|
|
168
|
-
...contract.outcomeScIds,
|
|
169
|
-
]);
|
|
170
|
-
const rowIds = matrix.entries.map(({ id }) => id);
|
|
171
|
-
const duplicateIds = rowIds.filter(
|
|
172
|
-
(id, index) => rowIds.indexOf(id) !== index,
|
|
48
|
+
function assertReviewedBaselines(root, updates, baselines) {
|
|
49
|
+
const reviewed = new Map(
|
|
50
|
+
baselines.map((baseline) => [baseline.path, baseline]),
|
|
173
51
|
);
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
) {
|
|
184
|
-
throw new Error(
|
|
185
|
-
'Compliance matrix must contain canonical rows with unique known requirement IDs and no FAIL results',
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
const rows = new Map(matrix.entries.map((entry) => [entry.id, entry]));
|
|
189
|
-
const missingEvidence = [
|
|
190
|
-
...contract.frIds,
|
|
191
|
-
...contract.buildableScIds,
|
|
192
|
-
].filter((id) => {
|
|
193
|
-
const row = rows.get(id);
|
|
194
|
-
return (
|
|
195
|
-
row?.result !== 'PASS' ||
|
|
196
|
-
!isConcreteEvidence(row.evidence) ||
|
|
197
|
-
!isConcreteEvidence(row.check)
|
|
198
|
-
);
|
|
199
|
-
});
|
|
200
|
-
if (missingEvidence.length > 0) {
|
|
201
|
-
throw new Error(
|
|
202
|
-
`Verification evidence is missing for: ${missingEvidence.join(', ')}`,
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const unresolvedOutcomes = contract.outcomeScIds.filter((id) => {
|
|
207
|
-
const row = rows.get(id);
|
|
208
|
-
if (!row) return true;
|
|
209
|
-
if (row.result === 'PASS') {
|
|
210
|
-
return (
|
|
211
|
-
!isConcreteEvidence(row.evidence) || !isConcreteEvidence(row.check)
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
return row.result !== 'RISK' || !hasExplicitResidualRisk(verify, id);
|
|
215
|
-
});
|
|
216
|
-
if (unresolvedOutcomes.length > 0) {
|
|
217
|
-
throw new Error(
|
|
218
|
-
`Outcome verification disposition is missing for: ${unresolvedOutcomes.join(', ')}`,
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function assertArchivable(change, contract) {
|
|
224
|
-
for (const file of [
|
|
225
|
-
'spec.md',
|
|
226
|
-
'plan.md',
|
|
227
|
-
'tasks.md',
|
|
228
|
-
'verify-report.md',
|
|
229
|
-
'archive-report.md',
|
|
230
|
-
]) {
|
|
231
|
-
if (!existsSync(join(change, file))) {
|
|
232
|
-
throw new Error(`${file} is required before archive`);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const tasks = readFileSync(join(change, 'tasks.md'), 'utf8');
|
|
237
|
-
if (/^- \[(?: |~)\] T\d{3}\b/m.test(tasks)) {
|
|
238
|
-
throw new Error('All tasks must be complete before archive');
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const verify = readFileSync(join(change, 'verify-report.md'), 'utf8');
|
|
242
|
-
assertVerifyCloseout(verify, contract);
|
|
243
|
-
|
|
244
|
-
const report = readFileSync(join(change, 'archive-report.md'), 'utf8');
|
|
245
|
-
if (!/^\*\*Status\*\*:\s*READY(?:<br>)?\s*$/im.test(report)) {
|
|
246
|
-
throw new Error('archive-report.md must record READY before archive');
|
|
247
|
-
}
|
|
248
|
-
if (!/^\*\*Oracle verdict\*\*:\s*PASS(?:<br>)?\s*$/im.test(report)) {
|
|
249
|
-
throw new Error('archive-report.md must record oracle PASS before archive');
|
|
250
|
-
}
|
|
251
|
-
if (
|
|
252
|
-
!/^\*\*Archive path\*\*:\s*`openspec\/changes\/archive\/YYYY-MM-DD-\[feature\]\/`(?:<br>)?\s*$/im.test(
|
|
253
|
-
report,
|
|
52
|
+
for (const update of updates) {
|
|
53
|
+
const path = relative(root, update.path).replaceAll('\\', '/');
|
|
54
|
+
const baseline = reviewed.get(path);
|
|
55
|
+
if (!baseline)
|
|
56
|
+
throw new Error(`Missing reviewed canonical baseline: ${path}`);
|
|
57
|
+
if (
|
|
58
|
+
(baseline.state === 'absent' && update.before !== null) ||
|
|
59
|
+
(baseline.state === 'present' &&
|
|
60
|
+
(update.before === null || sha(update.before) !== baseline.sha256))
|
|
254
61
|
)
|
|
255
|
-
|
|
256
|
-
throw new Error('archive-report.md must retain the dated archive target');
|
|
62
|
+
throw new Error(`Canonical plan differs from reviewed baseline: ${path}`);
|
|
257
63
|
}
|
|
258
|
-
if (
|
|
259
|
-
!/verify-report\.md/i.test(
|
|
260
|
-
sectionContent(report, 'Verification lineage') ?? '',
|
|
261
|
-
)
|
|
262
|
-
) {
|
|
263
|
-
throw new Error('archive-report.md must retain verification lineage');
|
|
264
|
-
}
|
|
265
|
-
if (
|
|
266
|
-
!/^- Pending: archive applies declared durable deltas transactionally\.\s*$/im.test(
|
|
267
|
-
sectionContent(report, 'Canonical specification sync') ?? '',
|
|
268
|
-
)
|
|
269
|
-
) {
|
|
64
|
+
if (reviewed.size !== updates.length)
|
|
270
65
|
throw new Error(
|
|
271
|
-
'
|
|
66
|
+
'Reviewed canonical baseline coverage does not match updates',
|
|
272
67
|
);
|
|
273
|
-
}
|
|
274
|
-
return report;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function parseDeltaMetadata(metadata) {
|
|
278
|
-
const delta = parseSharedRequirementDelta(metadata);
|
|
279
|
-
if (delta) return delta;
|
|
280
|
-
throw new Error(`Invalid requirement delta metadata: [${metadata}]`);
|
|
281
68
|
}
|
|
282
69
|
|
|
283
|
-
function
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
throw new Error('spec.md must contain a canonical User stories section');
|
|
287
|
-
}
|
|
288
|
-
const stories = [
|
|
289
|
-
...userStories.matchAll(
|
|
290
|
-
/^###\s+US(\d+)\s+-\s+(.+?)\s+\(Priority:\s*P\d+\)\s*$/gim,
|
|
291
|
-
),
|
|
292
|
-
];
|
|
293
|
-
const scenariosByRequirement = new Map();
|
|
294
|
-
|
|
295
|
-
for (const [index, storyMatch] of stories.entries()) {
|
|
296
|
-
const start = storyMatch.index ?? 0;
|
|
297
|
-
const nextStory = stories[index + 1]?.index;
|
|
298
|
-
const end = nextStory ?? userStories.length;
|
|
299
|
-
const story = userStories.slice(start, end);
|
|
300
|
-
const covers =
|
|
301
|
-
/^\*\*Covers\*\*:\s*(.+)$/im.exec(story)?.[1].match(/\bFR-\d{3}\b/g) ??
|
|
302
|
-
[];
|
|
303
|
-
const acceptanceMarker = /^\*\*Acceptance scenarios\*\*:\s*$/im.exec(story);
|
|
304
|
-
const acceptance = acceptanceMarker
|
|
305
|
-
? story.slice((acceptanceMarker.index ?? 0) + acceptanceMarker[0].length)
|
|
306
|
-
: '';
|
|
307
|
-
const scenarioStarts = [
|
|
308
|
-
...acceptance.matchAll(/^\d+\.\s+\*\*Given\*\*/gim),
|
|
309
|
-
];
|
|
310
|
-
const scenarios = scenarioStarts.map((scenarioStart, scenarioIndex) => {
|
|
311
|
-
const start = scenarioStart.index ?? 0;
|
|
312
|
-
const end = scenarioStarts[scenarioIndex + 1]?.index ?? acceptance.length;
|
|
313
|
-
const normalized = acceptance
|
|
314
|
-
.slice(start, end)
|
|
315
|
-
.replace(/\s+/g, ' ')
|
|
316
|
-
.trim();
|
|
317
|
-
const scenario =
|
|
318
|
-
/^\d+\.\s+\*\*Given\*\*\s+(.+?),\s+\*\*When\*\*\s+(.+?),\s+\*\*Then\*\*\s+(.+?)\.?\s*$/i.exec(
|
|
319
|
-
normalized,
|
|
320
|
-
);
|
|
321
|
-
if (!scenario) {
|
|
322
|
-
throw new Error(
|
|
323
|
-
`US${storyMatch[1]} acceptance scenario ${scenarioIndex + 1} must use Given, When, and Then`,
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
return {
|
|
327
|
-
title: `US${storyMatch[1]} - ${storyMatch[2].trim()} ${scenarioIndex + 1}`,
|
|
328
|
-
given: scenario[1].trim(),
|
|
329
|
-
when: scenario[2].trim(),
|
|
330
|
-
result: scenario[3].trim(),
|
|
331
|
-
};
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
for (const requirementId of covers) {
|
|
335
|
-
const current = scenariosByRequirement.get(requirementId) ?? [];
|
|
336
|
-
current.push(...scenarios);
|
|
337
|
-
scenariosByRequirement.set(requirementId, current);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return scenariosByRequirement;
|
|
70
|
+
function render(delta) {
|
|
71
|
+
const [given, when, then] = delta.scenario;
|
|
72
|
+
return `### Requirement: ${delta.title}\n\n${delta.statement}\n\n#### Scenario: ${delta.title}\n\n- **GIVEN** ${given}\n- **WHEN** ${when}\n- **THEN** ${then}`;
|
|
342
73
|
}
|
|
343
74
|
|
|
344
|
-
function
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const functionalRequirements = [
|
|
362
|
-
...functionalRequirementsSection.matchAll(
|
|
363
|
-
/^- \*\*(FR-\d{3})\s+—\s+(.+?)\*\*:\s*`\[(.+?)\]`\s+(\S.+)$/gim,
|
|
364
|
-
),
|
|
365
|
-
];
|
|
366
|
-
const rawFrCandidates = contractCandidateLines(spec, 'FR');
|
|
367
|
-
if (
|
|
368
|
-
functionalRequirements.length === 0 ||
|
|
369
|
-
rawFrCandidates.length !== functionalRequirements.length
|
|
370
|
-
) {
|
|
371
|
-
throw new Error(
|
|
372
|
-
'Every FR-### must use the canonical named requirement, delta metadata, and normative statement format',
|
|
373
|
-
);
|
|
374
|
-
}
|
|
375
|
-
assertSequentialContractIds(
|
|
376
|
-
functionalRequirements.map((match) => match[1]),
|
|
377
|
-
'FR',
|
|
378
|
-
);
|
|
379
|
-
|
|
380
|
-
const successCriteria = [
|
|
381
|
-
...successCriteriaSection.matchAll(
|
|
382
|
-
/^- \*\*(SC-\d{3})\*\*\s+`\[(buildable|outcome)\]`:\s*(\S.+)$/gim,
|
|
383
|
-
),
|
|
384
|
-
];
|
|
385
|
-
const rawScCandidates = contractCandidateLines(spec, 'SC');
|
|
386
|
-
if (
|
|
387
|
-
successCriteria.length === 0 ||
|
|
388
|
-
rawScCandidates.length !== successCriteria.length
|
|
389
|
-
) {
|
|
390
|
-
throw new Error(
|
|
391
|
-
'Every SC-### must be classified as buildable or outcome before archive',
|
|
75
|
+
function planUpdates(root, deltas) {
|
|
76
|
+
const groups = new Map();
|
|
77
|
+
for (const delta of deltas)
|
|
78
|
+
groups.set(delta.capability, [
|
|
79
|
+
...(groups.get(delta.capability) ?? []),
|
|
80
|
+
delta,
|
|
81
|
+
]);
|
|
82
|
+
return [...groups].map(([capability, changes]) => {
|
|
83
|
+
const path = localPath(root, `.thoth/specs/${capability}/spec.md`);
|
|
84
|
+
const before = bytes(path);
|
|
85
|
+
const title = capability
|
|
86
|
+
.split('-')
|
|
87
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
88
|
+
.join(' ');
|
|
89
|
+
const canonical = parseCanonicalSpec(
|
|
90
|
+
before?.toString('utf8') ??
|
|
91
|
+
`# ${title} Specification\n\n## Purpose\n\nDurable behavior for ${capability}.\n\n## Requirements\n`,
|
|
392
92
|
);
|
|
393
|
-
|
|
394
|
-
assertSequentialContractIds(
|
|
395
|
-
successCriteria.map((match) => match[1]),
|
|
396
|
-
'SC',
|
|
397
|
-
);
|
|
398
|
-
|
|
399
|
-
const deltas = [];
|
|
400
|
-
for (const match of functionalRequirements) {
|
|
401
|
-
const metadata = parseDeltaMetadata(match[3]);
|
|
402
|
-
if (metadata.operation === 'INTERNAL') continue;
|
|
403
|
-
const scenarios = scenariosByRequirement.get(match[1]) ?? [];
|
|
404
|
-
if (scenarios.length === 0) {
|
|
405
|
-
throw new Error(
|
|
406
|
-
`${match[1]} must map to at least one acceptance scenario before archive`,
|
|
407
|
-
);
|
|
408
|
-
}
|
|
409
|
-
deltas.push({
|
|
410
|
-
title: match[2].trim(),
|
|
411
|
-
statement: match[4].trim(),
|
|
412
|
-
scenarios,
|
|
413
|
-
...metadata,
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
return {
|
|
417
|
-
deltas,
|
|
418
|
-
frIds: functionalRequirements.map((match) => match[1]),
|
|
419
|
-
buildableScIds: successCriteria
|
|
420
|
-
.filter((match) => match[2].toLowerCase() === 'buildable')
|
|
421
|
-
.map((match) => match[1]),
|
|
422
|
-
outcomeScIds: successCriteria
|
|
423
|
-
.filter((match) => match[2].toLowerCase() === 'outcome')
|
|
424
|
-
.map((match) => match[1]),
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function newCanonicalSpec(capability) {
|
|
429
|
-
const title = capability
|
|
430
|
-
.split('-')
|
|
431
|
-
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
432
|
-
.join(' ');
|
|
433
|
-
return parseCanonicalSpec(
|
|
434
|
-
`# ${title} Specification\n\n## Purpose\n\nDurable behavioral contract for \`${capability}\`.\n\n## Requirements\n`,
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function renderRequirement(delta) {
|
|
439
|
-
const scenarios = delta.scenarios
|
|
440
|
-
.map(
|
|
441
|
-
(scenario) =>
|
|
442
|
-
`#### Scenario: ${scenario.title}\n\n- **GIVEN** ${scenario.given}\n- **WHEN** ${scenario.when}\n- **THEN** ${scenario.result}`,
|
|
443
|
-
)
|
|
444
|
-
.join('\n\n');
|
|
445
|
-
return `### Requirement: ${delta.title}\n\n${delta.statement}\n\n${scenarios}`;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function renderCanonicalSpec(canonical) {
|
|
449
|
-
const requirements = [...canonical.requirements.values()];
|
|
450
|
-
return `${canonical.prefix}${requirements.length > 0 ? `\n\n${requirements.join('\n\n')}` : ''}\n`;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function planCanonicalUpdates(specRoot, deltas) {
|
|
454
|
-
const byCapability = new Map();
|
|
455
|
-
for (const delta of deltas) {
|
|
456
|
-
const capability = delta.capability;
|
|
457
|
-
if (!CAPABILITY_PATTERN.test(capability)) {
|
|
458
|
-
throw new Error(`Invalid capability: ${capability}`);
|
|
459
|
-
}
|
|
460
|
-
const current = byCapability.get(capability) ?? [];
|
|
461
|
-
current.push(delta);
|
|
462
|
-
byCapability.set(capability, current);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
const updates = [];
|
|
466
|
-
for (const [capability, capabilityDeltas] of byCapability) {
|
|
467
|
-
const path = join(specRoot, capability, 'spec.md');
|
|
468
|
-
const present = existsSync(path);
|
|
469
|
-
let canonical;
|
|
470
|
-
try {
|
|
471
|
-
canonical = present
|
|
472
|
-
? parseCanonicalSpec(readFileSync(path, 'utf8'))
|
|
473
|
-
: newCanonicalSpec(capability);
|
|
474
|
-
} catch (error) {
|
|
475
|
-
throw new Error(
|
|
476
|
-
`SDD-SPEC-DELTA-BASELINE: ${capability} canonical baseline is invalid: ${errorMessage(error)}`,
|
|
477
|
-
);
|
|
478
|
-
}
|
|
479
|
-
const preflight = preflightRequirementDeltas({
|
|
93
|
+
const checked = preflightRequirementDeltas({
|
|
480
94
|
capability,
|
|
481
|
-
present,
|
|
95
|
+
present: before !== null,
|
|
482
96
|
requirements: canonical.requirements,
|
|
483
|
-
deltas:
|
|
97
|
+
deltas: changes,
|
|
484
98
|
});
|
|
485
|
-
if (
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
if (delta.operation === 'RENAMED') {
|
|
497
|
-
const entries = [...canonical.requirements.entries()];
|
|
99
|
+
if (checked.errors.length)
|
|
100
|
+
throw new Error(
|
|
101
|
+
`${checked.errors[0].code}: ${checked.errors[0].message}`,
|
|
102
|
+
);
|
|
103
|
+
for (const delta of changes) {
|
|
104
|
+
if (delta.operation === 'ADDED' || delta.operation === 'MODIFIED')
|
|
105
|
+
canonical.requirements.set(delta.title, render(delta));
|
|
106
|
+
else if (delta.operation === 'REMOVED')
|
|
107
|
+
canonical.requirements.delete(delta.title);
|
|
108
|
+
else if (delta.operation === 'RENAMED') {
|
|
498
109
|
canonical.requirements = new Map(
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
? [delta.title,
|
|
502
|
-
: [
|
|
110
|
+
[...canonical.requirements].map(([name, block]) =>
|
|
111
|
+
name === delta.previousTitle
|
|
112
|
+
? [delta.title, render(delta)]
|
|
113
|
+
: [name, block],
|
|
503
114
|
),
|
|
504
115
|
);
|
|
505
|
-
continue;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
if (delta.operation === 'MODIFIED') {
|
|
509
|
-
canonical.requirements.set(delta.title, renderRequirement(delta));
|
|
510
|
-
} else if (delta.operation === 'REMOVED') {
|
|
511
|
-
canonical.requirements.delete(delta.title);
|
|
512
116
|
}
|
|
513
117
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
118
|
+
const requirements = [...canonical.requirements.values()];
|
|
119
|
+
const after = Buffer.from(
|
|
120
|
+
`${canonical.prefix}${requirements.length ? `\n\n${requirements.join('\n\n')}` : ''}\n`,
|
|
121
|
+
);
|
|
122
|
+
return { path, before, after, capability };
|
|
123
|
+
});
|
|
518
124
|
}
|
|
519
125
|
|
|
520
|
-
function
|
|
521
|
-
const transactionId = `${process.pid}-${Date.now()}`;
|
|
522
|
-
const staged = [];
|
|
523
|
-
const createdDirectories = [];
|
|
126
|
+
function existsNoFollow(path) {
|
|
524
127
|
try {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
if (!existsSync(directory)) {
|
|
528
|
-
mkdirSync(directory, { recursive: true });
|
|
529
|
-
createdDirectories.push(directory);
|
|
530
|
-
}
|
|
531
|
-
const stagePath = join(
|
|
532
|
-
directory,
|
|
533
|
-
`.spec.md.thoth-stage-${transactionId}`,
|
|
534
|
-
);
|
|
535
|
-
writeFileSync(stagePath, update.content);
|
|
536
|
-
staged.push({ ...update, stagePath });
|
|
537
|
-
}
|
|
538
|
-
return { staged, createdDirectories, transactionId };
|
|
128
|
+
lstatSync(path);
|
|
129
|
+
return true;
|
|
539
130
|
} catch (error) {
|
|
540
|
-
|
|
541
|
-
rollbackCanonicalUpdates({ staged, createdDirectories, applied: [] });
|
|
542
|
-
} catch (recoveryError) {
|
|
543
|
-
throw new Error(
|
|
544
|
-
`${errorMessage(error)}; staging recovery failed: ${errorMessage(recoveryError)}`,
|
|
545
|
-
);
|
|
546
|
-
}
|
|
131
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
|
|
547
132
|
throw error;
|
|
548
133
|
}
|
|
549
134
|
}
|
|
550
135
|
|
|
551
|
-
function
|
|
136
|
+
export function archiveChange({ change, date, projectRoot }) {
|
|
137
|
+
const changeRoot =
|
|
138
|
+
projectRoot && !isAbsolute(change)
|
|
139
|
+
? resolve(projectRoot, change)
|
|
140
|
+
: resolve(change);
|
|
141
|
+
const location = resolveSddChangeLocation(changeRoot);
|
|
142
|
+
if (location.archived)
|
|
143
|
+
throw new Error('Only an active change can be archived');
|
|
144
|
+
const { id, recordPath, projectRoot: locatedRoot } = location;
|
|
145
|
+
if (
|
|
146
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
|
147
|
+
new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date
|
|
148
|
+
)
|
|
149
|
+
throw new Error('Date must be a real ISO date');
|
|
150
|
+
const root = locatedRoot;
|
|
151
|
+
if (projectRoot && resolve(projectRoot) !== root)
|
|
152
|
+
throw new Error('Project root does not match the change root');
|
|
153
|
+
const target = localPath(root, `.thoth/changes/archive/${date}-${id}`);
|
|
154
|
+
if (existsNoFollow(target))
|
|
155
|
+
throw new Error('Archive destination already exists');
|
|
156
|
+
if (
|
|
157
|
+
readdirSync(join(root, '.thoth')).some((name) =>
|
|
158
|
+
name.startsWith('.archive-'),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
throw new Error('An unfinished archive transaction requires inspection');
|
|
162
|
+
const initial = validate({ change: changeRoot, through: 'closeout' });
|
|
163
|
+
if (!initial.valid)
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Closeout rejected: ${initial.errors.map((error) => error.code).join(', ')}`,
|
|
166
|
+
);
|
|
167
|
+
const transaction = localPath(root, '.thoth/.archive-transaction');
|
|
168
|
+
mkdirSync(transaction); // Exclusive lock, followed by fresh validation under the lock.
|
|
552
169
|
const applied = [];
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if (hadOriginal) injectFault('after-original-backup');
|
|
562
|
-
renameSync(item.stagePath, item.path);
|
|
563
|
-
if (applied.length === 1) injectFault('after-first-canonical-write');
|
|
170
|
+
const created = [];
|
|
171
|
+
let updates = [];
|
|
172
|
+
const parents = (path) => {
|
|
173
|
+
if (existsNoFollow(path)) {
|
|
174
|
+
const stat = lstatSync(path);
|
|
175
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
176
|
+
throw new Error(`Expected directory: ${path}`);
|
|
177
|
+
return;
|
|
564
178
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
179
|
+
parents(dirname(path));
|
|
180
|
+
mkdirSync(path);
|
|
181
|
+
created.push(path);
|
|
182
|
+
};
|
|
183
|
+
try {
|
|
184
|
+
const result = validate({ change: changeRoot, through: 'closeout' });
|
|
185
|
+
if (!result.valid)
|
|
570
186
|
throw new Error(
|
|
571
|
-
|
|
187
|
+
`Closeout rejected: ${result.errors.map((error) => error.code).join(', ')}`,
|
|
572
188
|
);
|
|
189
|
+
updates = planUpdates(root, result.deltas);
|
|
190
|
+
assertReviewedBaselines(root, updates, result.specBaselines ?? []);
|
|
191
|
+
for (const path of [
|
|
192
|
+
dirname(target),
|
|
193
|
+
...updates.map((item) => dirname(item.path)),
|
|
194
|
+
]) {
|
|
195
|
+
let cursor = path;
|
|
196
|
+
while (!existsNoFollow(cursor)) cursor = dirname(cursor);
|
|
197
|
+
const stat = lstatSync(cursor);
|
|
198
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
199
|
+
throw new Error(`Expected directory: ${cursor}`);
|
|
573
200
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
201
|
+
// A filesystem recovery journal, not a workflow report or agent state mirror.
|
|
202
|
+
writeFileSync(
|
|
203
|
+
join(transaction, 'recovery.json'),
|
|
204
|
+
JSON.stringify({
|
|
205
|
+
changeId: id,
|
|
206
|
+
changeRoot,
|
|
207
|
+
recordPath,
|
|
208
|
+
target,
|
|
209
|
+
originals: updates.map(({ path, before, after }) => ({
|
|
210
|
+
path: relative(root, path),
|
|
211
|
+
before: before?.toString('base64') ?? null,
|
|
212
|
+
after: after.toString('base64'),
|
|
213
|
+
})),
|
|
214
|
+
}),
|
|
215
|
+
{ flag: 'wx' },
|
|
216
|
+
);
|
|
217
|
+
const latest = validate({ change: changeRoot, through: 'closeout' });
|
|
218
|
+
if (!latest.valid)
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Closeout rejected before mutation: ${latest.errors.map((error) => error.code).join(', ')}`,
|
|
221
|
+
);
|
|
222
|
+
assertReviewedBaselines(root, updates, latest.specBaselines ?? []);
|
|
223
|
+
const reviewed = new Map(
|
|
224
|
+
(latest.specBaselines ?? []).map((baseline) => [baseline.path, baseline]),
|
|
583
225
|
);
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
226
|
+
for (const [index, update] of updates.entries()) {
|
|
227
|
+
localPath(root, update.path);
|
|
228
|
+
parents(dirname(update.path));
|
|
229
|
+
const path = relative(root, update.path).replaceAll('\\', '/');
|
|
230
|
+
const baseline = reviewed.get(path);
|
|
231
|
+
const actual = bytes(update.path);
|
|
232
|
+
if (
|
|
233
|
+
!baseline ||
|
|
234
|
+
(baseline.state === 'absent' && actual !== null) ||
|
|
235
|
+
(baseline.state === 'present' &&
|
|
236
|
+
(actual === null || sha(actual) !== baseline.sha256))
|
|
237
|
+
)
|
|
238
|
+
throw new Error(`Reviewed canonical baseline changed: ${path}`);
|
|
239
|
+
const item = {
|
|
240
|
+
...update,
|
|
241
|
+
backup: join(transaction, `original-${index}`),
|
|
242
|
+
installed: false,
|
|
243
|
+
captured: false,
|
|
244
|
+
};
|
|
245
|
+
applied.push(item);
|
|
246
|
+
if (update.before !== null) {
|
|
247
|
+
renameSync(update.path, item.backup);
|
|
248
|
+
item.captured = true;
|
|
249
|
+
if (!readFileSync(item.backup).equals(update.before))
|
|
250
|
+
throw new Error(
|
|
251
|
+
'Durable baseline changed during archive; displaced original preserved',
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
const staged = join(transaction, `next-${index}`);
|
|
255
|
+
writeFileSync(staged, update.after, { flag: 'wx' });
|
|
256
|
+
linkSync(staged, localPath(root, update.path));
|
|
257
|
+
item.installed = true;
|
|
258
|
+
if (
|
|
259
|
+
process.env.THOTH_ARCHIVE_TEST_FAULT ===
|
|
260
|
+
'after-first-canonical-write' &&
|
|
261
|
+
index === 0
|
|
262
|
+
)
|
|
263
|
+
throw new Error('Injected archive fault');
|
|
264
|
+
}
|
|
265
|
+
for (const item of applied) {
|
|
266
|
+
if (
|
|
267
|
+
!bytes(item.path)?.equals(item.after) ||
|
|
268
|
+
(item.captured && !readFileSync(item.backup).equals(item.before))
|
|
269
|
+
)
|
|
270
|
+
throw new Error(
|
|
271
|
+
'Concurrent durable edit detected; transaction retained',
|
|
588
272
|
);
|
|
589
|
-
|
|
590
|
-
|
|
273
|
+
}
|
|
274
|
+
parents(dirname(target));
|
|
275
|
+
// Location is the durable archive status; move the verified record unchanged.
|
|
276
|
+
renameSync(changeRoot, target);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
const recoveryErrors = [];
|
|
279
|
+
for (const [index, item] of [...applied].reverse().entries()) {
|
|
280
|
+
try {
|
|
281
|
+
if (item.installed) {
|
|
282
|
+
const displaced = join(transaction, `rollback-${index}`);
|
|
283
|
+
renameSync(item.path, displaced);
|
|
284
|
+
if (!readFileSync(displaced).equals(item.after)) {
|
|
285
|
+
linkSync(displaced, item.path);
|
|
286
|
+
throw new Error(`Concurrent edit preserved: ${item.path}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (item.captured) linkSync(item.backup, item.path);
|
|
290
|
+
} catch (recovery) {
|
|
291
|
+
recoveryErrors.push(recovery.message);
|
|
591
292
|
}
|
|
592
293
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
294
|
+
for (const path of created.reverse())
|
|
295
|
+
try {
|
|
296
|
+
rmdirSync(path);
|
|
297
|
+
} catch (recovery) {
|
|
298
|
+
if (recovery.code !== 'ENOTEMPTY')
|
|
299
|
+
recoveryErrors.push(recovery.message);
|
|
300
|
+
}
|
|
301
|
+
if (!recoveryErrors.length) rmSync(transaction, { recursive: true });
|
|
302
|
+
throw new Error(
|
|
303
|
+
`${error.message}${recoveryErrors.length ? `; recovery retained at ${transaction}: ${recoveryErrors.join('; ')}` : ''}`,
|
|
597
304
|
);
|
|
598
305
|
}
|
|
599
|
-
for (const directory of [
|
|
600
|
-
...(transaction.createdDirectories ?? []),
|
|
601
|
-
].reverse()) {
|
|
602
|
-
if (existsSync(directory)) {
|
|
603
|
-
attemptRecovery(recoveryErrors, `remove ${directory}`, () =>
|
|
604
|
-
rmdirSync(directory),
|
|
605
|
-
);
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
if (recoveryErrors.length > 0) {
|
|
609
|
-
throw new Error(recoveryErrors.join('; '));
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function finalizeCanonicalUpdates(transaction) {
|
|
614
306
|
const warnings = [];
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
} catch (error) {
|
|
620
|
-
warnings.push(
|
|
621
|
-
`Archived successfully but could not remove backup ${item.backupPath}: ${errorMessage(error)}`,
|
|
622
|
-
);
|
|
623
|
-
}
|
|
307
|
+
try {
|
|
308
|
+
rmSync(transaction, { recursive: true });
|
|
309
|
+
} catch {
|
|
310
|
+
warnings.push(`Inspect retained transaction: ${transaction}`);
|
|
624
311
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
.replace(
|
|
640
|
-
/^- Pending: archive applies declared durable deltas transactionally\.\s*$/im,
|
|
641
|
-
sync,
|
|
642
|
-
);
|
|
312
|
+
const archivedRecordPath = join(target, `${id}.md`);
|
|
313
|
+
return {
|
|
314
|
+
status: 'archived',
|
|
315
|
+
changeId: id,
|
|
316
|
+
archivePath: target,
|
|
317
|
+
recordPath: archivedRecordPath,
|
|
318
|
+
archive: target,
|
|
319
|
+
originalChangeRoot: changeRoot,
|
|
320
|
+
updated: updates.map((item) =>
|
|
321
|
+
relative(root, item.path).replaceAll('\\', '/'),
|
|
322
|
+
),
|
|
323
|
+
specsUpdated: updates.map((item) => item.capability),
|
|
324
|
+
warnings,
|
|
325
|
+
};
|
|
643
326
|
}
|
|
644
327
|
|
|
645
|
-
function
|
|
646
|
-
|
|
647
|
-
attemptRecovery(recoveryErrors, 'report recovery failed', () => {
|
|
648
|
-
injectFault('report-restore');
|
|
649
|
-
writeFileSync(reportPath, originalReport);
|
|
650
|
-
});
|
|
651
|
-
attemptRecovery(recoveryErrors, 'canonical recovery failed', () =>
|
|
652
|
-
rollbackCanonicalUpdates(transaction),
|
|
653
|
-
);
|
|
654
|
-
return recoveryErrors;
|
|
328
|
+
export function archiveWork({ projectRoot, changeRoot, date }) {
|
|
329
|
+
return archiveChange({ change: changeRoot, date, projectRoot });
|
|
655
330
|
}
|
|
656
331
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if (basename(changesRoot) !== 'changes') {
|
|
662
|
-
throw new Error(
|
|
663
|
-
'Change must be an immediate child of an openspec/changes directory',
|
|
664
|
-
);
|
|
665
|
-
}
|
|
666
|
-
if (!existsSync(join(change, 'spec.md'))) {
|
|
667
|
-
throw new Error('spec.md is required before archive');
|
|
668
|
-
}
|
|
669
|
-
const contract = parseChangeSpec(
|
|
670
|
-
readFileSync(join(change, 'spec.md'), 'utf8'),
|
|
671
|
-
);
|
|
672
|
-
const originalReport = assertArchivable(change, contract);
|
|
673
|
-
|
|
674
|
-
const archiveRoot = join(changesRoot, 'archive');
|
|
675
|
-
const changeName = basename(change);
|
|
676
|
-
const target = join(archiveRoot, `${options.date}-${changeName}`);
|
|
677
|
-
if (existsSync(target)) {
|
|
678
|
-
throw new Error(`Archive target already exists: ${target}`);
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
const specRoot = join(dirname(changesRoot), 'specs');
|
|
682
|
-
const updates = planCanonicalUpdates(specRoot, contract.deltas);
|
|
683
|
-
const updatedCapabilities = updates
|
|
684
|
-
.map((update) => update.capability)
|
|
685
|
-
.sort((left, right) => left.localeCompare(right));
|
|
686
|
-
const reportPath = join(change, 'archive-report.md');
|
|
687
|
-
const transaction = applyCanonicalUpdates(stageCanonicalUpdates(updates));
|
|
688
|
-
|
|
332
|
+
if (
|
|
333
|
+
process.argv[1] &&
|
|
334
|
+
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
335
|
+
) {
|
|
689
336
|
try {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
337
|
+
const options = {};
|
|
338
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
339
|
+
if (process.argv[i] === '--json') options.json = true;
|
|
340
|
+
else if (['--project', '--change', '--date'].includes(process.argv[i])) {
|
|
341
|
+
const key = process.argv[i].slice(2);
|
|
342
|
+
options[key === 'project' ? 'projectRoot' : key] = process.argv[++i];
|
|
343
|
+
} else throw new Error(`Unknown argument: ${process.argv[i]}`);
|
|
344
|
+
}
|
|
345
|
+
if (!options.change || !options.date)
|
|
346
|
+
throw new Error('--change and --date are required');
|
|
347
|
+
const result = archiveChange(options);
|
|
348
|
+
process.stdout.write(
|
|
349
|
+
`${options.json ? JSON.stringify(result) : result.recordPath}\n`,
|
|
693
350
|
);
|
|
694
|
-
injectFault('after-report-write');
|
|
695
|
-
mkdirSync(archiveRoot, { recursive: true });
|
|
696
|
-
injectFault('before-change-move');
|
|
697
|
-
renameSync(change, target);
|
|
698
351
|
} catch (error) {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
originalReport,
|
|
702
|
-
transaction,
|
|
703
|
-
);
|
|
704
|
-
if (recoveryErrors.length > 0) {
|
|
705
|
-
const cause = errorMessage(error);
|
|
706
|
-
throw new Error(`${cause}; ${recoveryErrors.join('; ')}`);
|
|
707
|
-
}
|
|
708
|
-
throw error;
|
|
352
|
+
process.stderr.write(`${error.message}\n`);
|
|
353
|
+
process.exitCode = 1;
|
|
709
354
|
}
|
|
710
|
-
const warnings = finalizeCanonicalUpdates(transaction);
|
|
711
|
-
|
|
712
|
-
const result = {
|
|
713
|
-
status: 'archived',
|
|
714
|
-
archivePath: target,
|
|
715
|
-
specsUpdated: updatedCapabilities,
|
|
716
|
-
warnings,
|
|
717
|
-
};
|
|
718
|
-
process.stdout.write(`${options.json ? JSON.stringify(result) : target}\n`);
|
|
719
|
-
} catch (error) {
|
|
720
|
-
process.stderr.write(`${errorMessage(error)}\n`);
|
|
721
|
-
process.exitCode = 1;
|
|
722
355
|
}
|