@namewta/speculo 0.7.5 → 0.8.0
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 +4 -5
- package/dist/src/cli.js +18 -13
- package/dist/src/cli.js.map +1 -1
- package/dist/src/config.d.ts +20 -0
- package/dist/src/config.js +94 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.js +56 -27
- package/dist/src/index.js.map +1 -1
- package/dist/src/manifest.d.ts +20 -0
- package/dist/src/manifest.js +57 -0
- package/dist/src/manifest.js.map +1 -0
- package/dist/src/refresh.d.ts +30 -0
- package/dist/src/refresh.js +465 -0
- package/dist/src/refresh.js.map +1 -0
- package/dist/src/structured.d.ts +12 -0
- package/dist/src/structured.js +236 -0
- package/dist/src/structured.js.map +1 -0
- package/package.json +2 -2
- package/template/.speculo/README.md +12 -11
- package/template/.speculo/refresh-contract.json +30 -0
- package/template/canonical/canonical-specdev-goal-plan.md +436 -68
- package/template/skills/github-npm-ops/references/preflight-checklist.md +1 -1
- package/template/skills/source-code-zip/SKILL.md +568 -0
- package/template/skills/source-code-zip/scripts/zip_source_code.js +1363 -0
- package/template/workflows/person/runtime-contract.json +9 -0
- package/template/workflows/specdev/I-init-setup/I-init-setup.md +1 -1
- package/template/workflows/specdev/INDEX.md +7 -8
- package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +88 -27
- package/template/workflows/specdev/common/skills/subagent-delivery/references/external-web-subagent.md +121 -9
- package/template/workflows/specdev/common/skills/subagent-delivery/references/native-subagent.md +10 -7
- package/template/workflows/specdev/common/skills/subagent-delivery/references/source-package.md +229 -9
- package/template/workflows/specdev/runtime-contract.json +26 -0
- package/dist/src/migrations.d.ts +0 -23
- package/dist/src/migrations.js +0 -1202
- package/dist/src/migrations.js.map +0 -1
- package/template/commands/migrate-runtime-state.md +0 -43
- package/template/skills/migrate-runtime-state/SKILL.md +0 -93
- package/template/skills/migrate-runtime-state/references/migration-contract.md +0 -64
- package/template/skills/migrate-runtime-state/scripts/migrate-runtime-state.mjs +0 -916
- package/template/skills/source-code-zip-skill/SKILL.md +0 -343
- package/template/skills/source-code-zip-skill/scripts/zip_source_code.py +0 -638
- package/template/workflows/specdev/common/skills/subagent-delivery/references/github-checkpoints.md +0 -24
|
@@ -1,916 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
|
-
import {
|
|
5
|
-
cp,
|
|
6
|
-
lstat,
|
|
7
|
-
mkdir,
|
|
8
|
-
mkdtemp,
|
|
9
|
-
readFile,
|
|
10
|
-
readdir,
|
|
11
|
-
readlink,
|
|
12
|
-
rename,
|
|
13
|
-
rm,
|
|
14
|
-
writeFile,
|
|
15
|
-
} from "node:fs/promises";
|
|
16
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
17
|
-
|
|
18
|
-
const STAGE_PREFIX = ".speculo-runtime-migrate-stage-";
|
|
19
|
-
const CONFIG_SCHEMA_VERSION = 5;
|
|
20
|
-
const GOAL_PLAN_SCHEMA_VERSION = 6;
|
|
21
|
-
const CHANGE_STATUS_SCHEMA_VERSION = 6;
|
|
22
|
-
const ROLLBACK_NAME = ".speculo-runtime-migrate-rollback";
|
|
23
|
-
const VALID_ACTIONS = new Set(["copy", "replace-json", "keep-current", "remove-current"]);
|
|
24
|
-
const VALID_DECISIONS = new Set(["restore", "merge-json", "replace-json", "keep-current", "remove-current"]);
|
|
25
|
-
|
|
26
|
-
function usage() {
|
|
27
|
-
process.stderr.write([
|
|
28
|
-
"Usage:",
|
|
29
|
-
" node migrate-runtime-state.mjs inspect --project-root <path>",
|
|
30
|
-
" node migrate-runtime-state.mjs fingerprint --project-root <path> --target <relative-path>",
|
|
31
|
-
" node migrate-runtime-state.mjs apply --project-root <path> --plan <plan.json> --confirmed",
|
|
32
|
-
"",
|
|
33
|
-
"inspect and fingerprint are read-only. apply requires an explicit confirmed schema-v2 plan.",
|
|
34
|
-
"",
|
|
35
|
-
].join("\n"));
|
|
36
|
-
return 2;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function parseArgs(argv) {
|
|
40
|
-
const [operation, ...rest] = argv;
|
|
41
|
-
const options = { operation, confirmed: false };
|
|
42
|
-
for (let index = 0; index < rest.length; index += 1) {
|
|
43
|
-
const item = rest[index];
|
|
44
|
-
if (item === "--confirmed") {
|
|
45
|
-
options.confirmed = true;
|
|
46
|
-
} else if (item === "--project-root" || item === "--plan" || item === "--target") {
|
|
47
|
-
options[item.slice(2).replaceAll("-", "_")] = rest[index + 1];
|
|
48
|
-
index += 1;
|
|
49
|
-
} else if (item === "--help" || item === "-h") {
|
|
50
|
-
options.help = true;
|
|
51
|
-
} else {
|
|
52
|
-
throw new Error("Unknown argument: " + item);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return options;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async function exists(path) {
|
|
59
|
-
try {
|
|
60
|
-
await lstat(path);
|
|
61
|
-
return true;
|
|
62
|
-
} catch {
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function toPosix(path) {
|
|
68
|
-
return path.split(sep).join("/");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function safeRelative(value, label) {
|
|
72
|
-
if (typeof value !== "string" || !value || value.includes("\\")) {
|
|
73
|
-
throw new Error(label + " must be a non-empty POSIX relative path");
|
|
74
|
-
}
|
|
75
|
-
const parts = value.split("/");
|
|
76
|
-
if (value.startsWith("/") || /^[A-Za-z]:/.test(value) || parts.some((part) => !part || part === "." || part === "..")) {
|
|
77
|
-
throw new Error(label + " escapes its allowed root: " + value);
|
|
78
|
-
}
|
|
79
|
-
return value;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function inside(root, relativePath) {
|
|
83
|
-
const target = resolve(root, safeRelative(relativePath, "path"));
|
|
84
|
-
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
85
|
-
if (target !== root && !target.startsWith(prefix)) throw new Error("Path escapes root: " + relativePath);
|
|
86
|
-
return target;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async function readJson(path) {
|
|
90
|
-
return JSON.parse(await readFile(path, "utf8"));
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async function sha256(path) {
|
|
94
|
-
return createHash("sha256").update(await readFile(path)).digest("hex");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function walk(root, current = root, options = {}) {
|
|
98
|
-
if (!(await exists(current))) return [];
|
|
99
|
-
const values = [];
|
|
100
|
-
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
101
|
-
const path = join(current, entry.name);
|
|
102
|
-
const item = toPosix(relative(root, path));
|
|
103
|
-
if (options.exclude?.(item)) continue;
|
|
104
|
-
if (entry.isDirectory()) {
|
|
105
|
-
values.push({ path: item, type: "directory" });
|
|
106
|
-
values.push(...await walk(root, path, options));
|
|
107
|
-
} else if (entry.isSymbolicLink()) {
|
|
108
|
-
values.push({ path: item, type: "symlink", target: await readlink(path) });
|
|
109
|
-
} else if (entry.isFile()) {
|
|
110
|
-
const stat = await lstat(path);
|
|
111
|
-
values.push({ path: item, type: "file", bytes: stat.size, sha256: await sha256(path) });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return values.sort((left, right) => left.path.localeCompare(right.path));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function fingerprint(path) {
|
|
118
|
-
if (!(await exists(path))) return "absent";
|
|
119
|
-
const stat = await lstat(path);
|
|
120
|
-
if (stat.isSymbolicLink()) throw new Error("Target is a symbolic link: " + path);
|
|
121
|
-
if (stat.isFile()) return "file:" + await sha256(path);
|
|
122
|
-
if (!stat.isDirectory()) throw new Error("Unsupported target type: " + path);
|
|
123
|
-
const entries = await walk(path);
|
|
124
|
-
const digest = createHash("sha256");
|
|
125
|
-
for (const entry of entries) digest.update(JSON.stringify(entry) + "\n");
|
|
126
|
-
return "directory:" + digest.digest("hex");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async function assertNoSymlinkPath(root, relativePath) {
|
|
130
|
-
let current = root;
|
|
131
|
-
for (const part of safeRelative(relativePath, "target").split("/")) {
|
|
132
|
-
current = join(current, part);
|
|
133
|
-
if (!(await exists(current))) continue;
|
|
134
|
-
if ((await lstat(current)).isSymbolicLink()) throw new Error("Target path traverses a symbolic link: " + relativePath);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async function context(projectRootArg) {
|
|
139
|
-
if (!projectRootArg) throw new Error("--project-root is required");
|
|
140
|
-
const projectRoot = resolve(projectRootArg);
|
|
141
|
-
const speculoRoot = join(projectRoot, "speculo");
|
|
142
|
-
const stateRoot = join(speculoRoot, ".speculo");
|
|
143
|
-
const backupRoot = join(stateRoot, "back");
|
|
144
|
-
const markerPath = join(stateRoot, "migration.json");
|
|
145
|
-
const manifestPath = join(backupRoot, "manifest.json");
|
|
146
|
-
for (const [label, path] of [["Speculo installation", speculoRoot], ["pending marker", markerPath], ["backup manifest", manifestPath]]) {
|
|
147
|
-
if (!(await exists(path))) throw new Error(label + " does not exist: " + path);
|
|
148
|
-
}
|
|
149
|
-
for (const [label, path] of [["Speculo installation", speculoRoot], ["runtime state", stateRoot], ["backup root", backupRoot]]) {
|
|
150
|
-
if ((await lstat(path)).isSymbolicLink()) throw new Error(label + " must not be a symbolic link: " + path);
|
|
151
|
-
}
|
|
152
|
-
const marker = await readJson(markerPath);
|
|
153
|
-
if (marker.schema_version !== 1 || marker.status !== "pending") throw new Error("migration.json is not a pending schema-v1 marker");
|
|
154
|
-
const manifest = await readJson(manifestPath);
|
|
155
|
-
if (manifest.schema_version !== 1 || !Array.isArray(manifest.files)) throw new Error("back/manifest.json is not a schema-v1 manifest");
|
|
156
|
-
return { projectRoot, speculoRoot, stateRoot, backupRoot, markerPath, manifestPath, marker, manifest };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async function validateBackup(ctx, checkMigrationWorkspace = true) {
|
|
160
|
-
const issues = [];
|
|
161
|
-
const expected = new Map();
|
|
162
|
-
for (const entry of ctx.manifest.files) {
|
|
163
|
-
try {
|
|
164
|
-
const item = safeRelative(entry.path, "manifest path");
|
|
165
|
-
if (item === "manifest.json") throw new Error("manifest cannot include itself");
|
|
166
|
-
if (expected.has(item)) throw new Error("duplicate manifest entry: " + item);
|
|
167
|
-
if (entry.type !== "file" && entry.type !== "symlink") throw new Error("invalid manifest entry type: " + item);
|
|
168
|
-
if (entry.type === "file" && (typeof entry.sha256 !== "string" || typeof entry.bytes !== "number")) {
|
|
169
|
-
throw new Error("file manifest entry has no hash or size: " + item);
|
|
170
|
-
}
|
|
171
|
-
if (entry.type === "symlink" && typeof entry.target !== "string") {
|
|
172
|
-
throw new Error("symlink manifest entry has no target: " + item);
|
|
173
|
-
}
|
|
174
|
-
expected.set(item, entry);
|
|
175
|
-
} catch (error) {
|
|
176
|
-
issues.push(String(error));
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
const actual = await walk(ctx.backupRoot, ctx.backupRoot, { exclude: (item) => item === "manifest.json" });
|
|
180
|
-
const actualFiles = actual.filter((entry) => entry.type !== "directory");
|
|
181
|
-
for (const entry of actualFiles) {
|
|
182
|
-
const declared = expected.get(entry.path);
|
|
183
|
-
if (!declared) {
|
|
184
|
-
issues.push("undeclared backup entry: " + entry.path);
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
if (entry.type !== declared.type) {
|
|
188
|
-
issues.push("backup entry type mismatch: " + entry.path);
|
|
189
|
-
} else if (entry.type === "symlink") {
|
|
190
|
-
if (entry.target !== declared.target) issues.push("backup symlink target mismatch: " + entry.path);
|
|
191
|
-
} else if (entry.sha256 !== declared.sha256 || entry.bytes !== declared.bytes) {
|
|
192
|
-
issues.push("backup hash or size mismatch: " + entry.path);
|
|
193
|
-
}
|
|
194
|
-
expected.delete(entry.path);
|
|
195
|
-
}
|
|
196
|
-
for (const path of expected.keys()) issues.push("missing backup entry: " + path);
|
|
197
|
-
if (checkMigrationWorkspace) {
|
|
198
|
-
const projectEntries = await readdir(ctx.projectRoot);
|
|
199
|
-
for (const name of projectEntries) {
|
|
200
|
-
if (name.startsWith(STAGE_PREFIX) || name === ROLLBACK_NAME) issues.push("unfinished migration workspace: " + name);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
return issues;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async function inspect(projectRoot) {
|
|
207
|
-
const ctx = await context(projectRoot);
|
|
208
|
-
const issues = await validateBackup(ctx);
|
|
209
|
-
return {
|
|
210
|
-
ok: issues.length === 0,
|
|
211
|
-
pending: ctx.marker,
|
|
212
|
-
backup: {
|
|
213
|
-
source_version: ctx.manifest.source_version,
|
|
214
|
-
target_version: ctx.manifest.target_version,
|
|
215
|
-
entries: ctx.manifest.files.length,
|
|
216
|
-
manifest_sha256: await sha256(ctx.manifestPath),
|
|
217
|
-
files: ctx.manifest.files,
|
|
218
|
-
},
|
|
219
|
-
issues,
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function allowedTarget(target, installedWorkflows) {
|
|
224
|
-
safeRelative(target, "target");
|
|
225
|
-
if (target === "config.json") return true;
|
|
226
|
-
if (!target.startsWith(".speculo/")) return false;
|
|
227
|
-
for (const protectedPath of [
|
|
228
|
-
".speculo/back",
|
|
229
|
-
".speculo/workspace.json",
|
|
230
|
-
".speculo/install.json",
|
|
231
|
-
".speculo/migration.json",
|
|
232
|
-
".speculo/README.md",
|
|
233
|
-
]) {
|
|
234
|
-
if (target === protectedPath || target.startsWith(protectedPath + "/")) return false;
|
|
235
|
-
}
|
|
236
|
-
if (target.startsWith(".speculo/commands/")) return true;
|
|
237
|
-
return installedWorkflows.some((workflow) => target === `.speculo/${workflow}` || target.startsWith(`.speculo/${workflow}/`));
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function allowedDecisionTarget(target, disposition, installedWorkflows) {
|
|
241
|
-
safeRelative(target, "decision target");
|
|
242
|
-
if (allowedTarget(target, installedWorkflows)) return true;
|
|
243
|
-
if (disposition !== "keep-current") return false;
|
|
244
|
-
return new Set([
|
|
245
|
-
".speculo/README.md",
|
|
246
|
-
".speculo/workspace.json",
|
|
247
|
-
".speculo/install.json",
|
|
248
|
-
]).has(target);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function pathsOverlap(left, right) {
|
|
252
|
-
return left === right || left.startsWith(right + "/") || right.startsWith(left + "/");
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
async function validatePlan(ctx, plan) {
|
|
256
|
-
if (plan.schema_version !== 2 || !Array.isArray(plan.source_decisions) || !Array.isArray(plan.actions)) {
|
|
257
|
-
throw new Error("Plan must use schema_version 2 and contain source_decisions and actions");
|
|
258
|
-
}
|
|
259
|
-
if (plan.backup_manifest_sha256 !== await sha256(ctx.manifestPath)) throw new Error("Plan backup manifest fingerprint does not match");
|
|
260
|
-
const install = await readJson(join(ctx.stateRoot, "install.json"));
|
|
261
|
-
const workflows = Array.isArray(install.workflows) ? install.workflows.filter((item) => typeof item === "string") : [];
|
|
262
|
-
const expectedSources = new Set(ctx.manifest.files.map((entry) => entry.path));
|
|
263
|
-
const decisions = new Map();
|
|
264
|
-
for (const [index, decision] of plan.source_decisions.entries()) {
|
|
265
|
-
if (!decision || typeof decision !== "object" || !VALID_DECISIONS.has(decision.disposition)) {
|
|
266
|
-
throw new Error(`source_decisions[${index}] has an invalid disposition`);
|
|
267
|
-
}
|
|
268
|
-
const source = safeRelative(decision.path, `source_decisions[${index}] path`);
|
|
269
|
-
if (!expectedSources.has(source)) throw new Error(`source_decisions[${index}] is not in the backup manifest: ${source}`);
|
|
270
|
-
if (decisions.has(source)) throw new Error(`source_decisions[${index}] repeats ${source}`);
|
|
271
|
-
if (typeof decision.target !== "string" || !allowedDecisionTarget(decision.target, decision.disposition, workflows)) {
|
|
272
|
-
throw new Error(`source_decisions[${index}] target is outside runtime ownership: ${decision.target}`);
|
|
273
|
-
}
|
|
274
|
-
decisions.set(source, decision);
|
|
275
|
-
}
|
|
276
|
-
for (const source of expectedSources) {
|
|
277
|
-
if (!decisions.has(source)) throw new Error("Plan has no decision for backup entry: " + source);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const actionSources = new Set();
|
|
281
|
-
const seenTargets = new Set();
|
|
282
|
-
for (const [index, action] of plan.actions.entries()) {
|
|
283
|
-
if (!action || typeof action !== "object" || !VALID_ACTIONS.has(action.kind)) throw new Error(`actions[${index}] has an invalid kind`);
|
|
284
|
-
if (typeof action.source_decision !== "string") throw new Error(`actions[${index}] must explicitly name source_decision`);
|
|
285
|
-
const actionSource = safeRelative(action.source_decision, `actions[${index}] source_decision`);
|
|
286
|
-
const decision = decisions.get(actionSource);
|
|
287
|
-
if (!decision) throw new Error(`actions[${index}] source_decision is not in source_decisions: ${actionSource}`);
|
|
288
|
-
if (actionSources.has(actionSource)) throw new Error(`actions[${index}] duplicates source action: ${actionSource}`);
|
|
289
|
-
actionSources.add(actionSource);
|
|
290
|
-
if (typeof action.to !== "string" || !allowedTarget(action.to, workflows)) {
|
|
291
|
-
throw new Error(`actions[${index}] target is outside runtime ownership: ${action.to}`);
|
|
292
|
-
}
|
|
293
|
-
if (decision.target !== action.to) throw new Error(`actions[${index}] target must match source decision target`);
|
|
294
|
-
for (const target of seenTargets) {
|
|
295
|
-
if (pathsOverlap(target, action.to)) throw new Error(`actions[${index}] overlaps target ${target}`);
|
|
296
|
-
}
|
|
297
|
-
seenTargets.add(action.to);
|
|
298
|
-
if (action.kind === "copy") {
|
|
299
|
-
if (decision.disposition !== "restore") throw new Error(`actions[${index}] copy must implement a restore decision`);
|
|
300
|
-
if (action.from !== actionSource) throw new Error(`actions[${index}] copy source must match source_decision`);
|
|
301
|
-
const source = inside(ctx.backupRoot, actionSource);
|
|
302
|
-
if (!(await exists(source))) throw new Error(`actions[${index}] source does not exist: ${actionSource}`);
|
|
303
|
-
} else if (action.kind === "replace-json") {
|
|
304
|
-
if (!new Set(["merge-json", "replace-json"]).has(decision.disposition)) {
|
|
305
|
-
throw new Error(`actions[${index}] replace-json must implement a merge-json or replace-json decision`);
|
|
306
|
-
}
|
|
307
|
-
if (!action.to.endsWith(".json") || action.value === undefined) throw new Error(`actions[${index}] replace-json needs a JSON target and value`);
|
|
308
|
-
JSON.stringify(action.value);
|
|
309
|
-
} else if (action.kind === "keep-current") {
|
|
310
|
-
if (decision.disposition !== "keep-current") throw new Error(`actions[${index}] keep-current must implement a keep-current decision`);
|
|
311
|
-
} else if (decision.disposition !== "remove-current") {
|
|
312
|
-
throw new Error(`actions[${index}] remove-current must implement a remove-current decision`);
|
|
313
|
-
}
|
|
314
|
-
if (typeof action.expected_target !== "string") throw new Error(`actions[${index}] must contain expected_target`);
|
|
315
|
-
const currentFingerprint = await fingerprint(inside(ctx.speculoRoot, action.to));
|
|
316
|
-
if (currentFingerprint !== action.expected_target) throw new Error(`actions[${index}] target drifted: ${action.to}`);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
for (const [source, decision] of decisions) {
|
|
320
|
-
if (decision.disposition !== "keep-current" && !actionSources.has(source)) {
|
|
321
|
-
throw new Error(`source_decisions entry requires an action: ${source}`);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
return workflows;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
async function validateJsonTree(root) {
|
|
328
|
-
const failures = [];
|
|
329
|
-
for (const entry of await walk(root, root, { exclude: (item) => item === ".speculo/back" || item.startsWith(".speculo/back/") })) {
|
|
330
|
-
if (entry.type !== "file" || !entry.path.endsWith(".json")) continue;
|
|
331
|
-
try {
|
|
332
|
-
await readJson(join(root, entry.path));
|
|
333
|
-
} catch (error) {
|
|
334
|
-
failures.push(entry.path + ": " + String(error));
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
return failures;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function isObject(value) {
|
|
341
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function hasExactKeys(value, expected) {
|
|
345
|
-
const actual = Object.keys(value).sort();
|
|
346
|
-
const wanted = [...expected].sort();
|
|
347
|
-
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function nonEmptyString(value) {
|
|
351
|
-
return typeof value === "string" && value.length > 0;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function stringOrNull(value) {
|
|
355
|
-
return value === null || typeof value === "string";
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function stringArray(value) {
|
|
359
|
-
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const CHANGE_NAME_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
363
|
-
const ARCHIVE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/archive\/[^<]+<\/Path>$/;
|
|
364
|
-
const EVIDENCE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/changes\/[^<]+\/evidence\/T-[0-9]{2,}\.md<\/Path>$/;
|
|
365
|
-
|
|
366
|
-
function validIntegrationV4(integration, worktreeStatus, sourceCheckpoint, change, ticketId) {
|
|
367
|
-
const required = [
|
|
368
|
-
"status", "parent_before_sha", "source_sha", "candidate_sha", "candidate_branch",
|
|
369
|
-
"candidate_workspace_ref", "result_sha", "method", "conflict_paths", "verification",
|
|
370
|
-
"e2e", "evidence", "attempts",
|
|
371
|
-
];
|
|
372
|
-
if (!isObject(integration) || !hasExactKeys(integration, required)) return false;
|
|
373
|
-
if (!new Set(["pending", "candidate", "passed", "failed", "stale"]).has(integration.status)) return false;
|
|
374
|
-
if (!new Set([null, "fast-forward", "merge-commit"]).has(integration.method)) return false;
|
|
375
|
-
if (!new Set(["pending", "passed", "failed"]).has(integration.verification)) return false;
|
|
376
|
-
for (const key of ["parent_before_sha", "source_sha", "candidate_sha", "candidate_branch", "result_sha"]) {
|
|
377
|
-
if (!stringOrNull(integration[key])) return false;
|
|
378
|
-
}
|
|
379
|
-
if (
|
|
380
|
-
integration.candidate_workspace_ref !== null &&
|
|
381
|
-
(typeof integration.candidate_workspace_ref !== "string" ||
|
|
382
|
-
!/^specdev-worktree\/\.integration\/T-[0-9]{2,}$/.test(integration.candidate_workspace_ref))
|
|
383
|
-
) return false;
|
|
384
|
-
if (!stringArray(integration.conflict_paths)) return false;
|
|
385
|
-
if (!Number.isInteger(integration.attempts) || integration.attempts < 0) return false;
|
|
386
|
-
if (
|
|
387
|
-
typeof integration.evidence !== "string" ||
|
|
388
|
-
!EVIDENCE_PATH_PATTERN.test(integration.evidence) ||
|
|
389
|
-
integration.evidence !== `<Path>{roots.state}/specdev/changes/${change}/evidence/${ticketId}.md</Path>`
|
|
390
|
-
) return false;
|
|
391
|
-
|
|
392
|
-
const e2e = integration.e2e;
|
|
393
|
-
if (!isObject(e2e) || !hasExactKeys(e2e, ["required", "status", "evidence"])) return false;
|
|
394
|
-
if (typeof e2e.required !== "boolean" || !new Set(["not-required", "pending", "passed", "failed"]).has(e2e.status)) return false;
|
|
395
|
-
if (!stringOrNull(e2e.evidence)) return false;
|
|
396
|
-
if (e2e.required === false && e2e.status !== "not-required") return false;
|
|
397
|
-
if (e2e.required === true && e2e.status === "not-required") return false;
|
|
398
|
-
if (e2e.required === true && e2e.status === "passed" && !nonEmptyString(e2e.evidence)) return false;
|
|
399
|
-
|
|
400
|
-
if (new Set(["integrating", "integrated", "removed"]).has(worktreeStatus)) {
|
|
401
|
-
if (
|
|
402
|
-
!nonEmptyString(integration.parent_before_sha) ||
|
|
403
|
-
!nonEmptyString(integration.source_sha) ||
|
|
404
|
-
integration.source_sha !== sourceCheckpoint ||
|
|
405
|
-
!nonEmptyString(integration.candidate_sha) ||
|
|
406
|
-
integration.candidate_branch !== `speculo/integration/${change}/${ticketId}` ||
|
|
407
|
-
integration.candidate_workspace_ref !== `specdev-worktree/.integration/${ticketId}` ||
|
|
408
|
-
!new Set(["fast-forward", "merge-commit"]).has(integration.method) ||
|
|
409
|
-
!Number.isInteger(integration.attempts) ||
|
|
410
|
-
integration.attempts < 1
|
|
411
|
-
) return false;
|
|
412
|
-
}
|
|
413
|
-
if (worktreeStatus === "integrating" && integration.status !== "candidate") return false;
|
|
414
|
-
if (new Set(["integrated", "removed"]).has(worktreeStatus)) {
|
|
415
|
-
if (
|
|
416
|
-
integration.status !== "passed" ||
|
|
417
|
-
integration.verification !== "passed" ||
|
|
418
|
-
!nonEmptyString(integration.result_sha) ||
|
|
419
|
-
integration.result_sha !== integration.candidate_sha ||
|
|
420
|
-
!new Set(["not-required", "passed"]).has(e2e.status)
|
|
421
|
-
) return false;
|
|
422
|
-
if (integration.method === "fast-forward" && (integration.candidate_sha !== sourceCheckpoint || integration.conflict_paths.length > 0)) return false;
|
|
423
|
-
if (integration.method === "merge-commit" && (integration.candidate_sha === sourceCheckpoint || integration.candidate_sha === integration.parent_before_sha)) return false;
|
|
424
|
-
}
|
|
425
|
-
return true;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function validChangeStatusV4(status, expectedChange, expectedStatus) {
|
|
429
|
-
const required = [
|
|
430
|
-
"schema_version", "artifact", "change", "change_status", "current_work", "created_at",
|
|
431
|
-
"updated_at", "completed_at", "archived", "archive_path", "blockers", "deviations", "worktrees",
|
|
432
|
-
];
|
|
433
|
-
if (
|
|
434
|
-
!isObject(status) ||
|
|
435
|
-
!hasExactKeys(status, required) ||
|
|
436
|
-
status.schema_version !== 4 ||
|
|
437
|
-
status.artifact !== "change-status" ||
|
|
438
|
-
status.change !== expectedChange ||
|
|
439
|
-
!CHANGE_NAME_PATTERN.test(String(status.change)) ||
|
|
440
|
-
!expectedStatus.has(status.change_status) ||
|
|
441
|
-
!(status.current_work === null || typeof status.current_work === "string") ||
|
|
442
|
-
!nonEmptyString(status.created_at) ||
|
|
443
|
-
!nonEmptyString(status.updated_at) ||
|
|
444
|
-
!(status.completed_at === null || nonEmptyString(status.completed_at)) ||
|
|
445
|
-
typeof status.archived !== "boolean" ||
|
|
446
|
-
!(status.archive_path === null || (typeof status.archive_path === "string" && ARCHIVE_PATH_PATTERN.test(status.archive_path))) ||
|
|
447
|
-
!stringArray(status.blockers) ||
|
|
448
|
-
!stringArray(status.deviations) ||
|
|
449
|
-
!Array.isArray(status.worktrees)
|
|
450
|
-
) return false;
|
|
451
|
-
if (status.change_status === "archived") {
|
|
452
|
-
if (status.archived !== true || typeof status.archive_path !== "string" || !ARCHIVE_PATH_PATTERN.test(status.archive_path)) return false;
|
|
453
|
-
} else if (status.archived !== false) {
|
|
454
|
-
return false;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const seenTickets = new Set();
|
|
458
|
-
return status.worktrees.every((worktree) => {
|
|
459
|
-
const requiredWorktree = [
|
|
460
|
-
"ticket_id", "owner", "implementation_owner", "integration_owner", "provider", "base_sha",
|
|
461
|
-
"parent_branch", "branch", "workspace_ref", "source_checkpoint", "integration", "status", "updated_at",
|
|
462
|
-
];
|
|
463
|
-
if (!isObject(worktree) || !hasExactKeys(worktree, requiredWorktree) || worktree.provider !== "git") return false;
|
|
464
|
-
if (typeof worktree.ticket_id !== "string" || !/^T-[0-9]{2,}$/.test(worktree.ticket_id)) return false;
|
|
465
|
-
if (seenTickets.has(worktree.ticket_id)) return false;
|
|
466
|
-
seenTickets.add(worktree.ticket_id);
|
|
467
|
-
for (const key of ["owner", "implementation_owner", "integration_owner", "base_sha", "parent_branch", "branch", "updated_at"]) {
|
|
468
|
-
if (!nonEmptyString(worktree[key])) return false;
|
|
469
|
-
}
|
|
470
|
-
if (worktree.parent_branch === worktree.branch) return false;
|
|
471
|
-
if (worktree.workspace_ref !== `specdev-worktree/${worktree.ticket_id}`) return false;
|
|
472
|
-
if (!new Set(["planned", "active", "review", "integrating", "integrated", "removed", "blocked"]).has(worktree.status)) return false;
|
|
473
|
-
const sourceRequired = new Set(["review", "integrating", "integrated", "removed"]).has(worktree.status);
|
|
474
|
-
if (sourceRequired ? !nonEmptyString(worktree.source_checkpoint) : !stringOrNull(worktree.source_checkpoint)) return false;
|
|
475
|
-
return validIntegrationV4(worktree.integration, worktree.status, worktree.source_checkpoint, expectedChange, worktree.ticket_id);
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
function validateChangeStatusV6(status, expectedChange, expectedStatus) {
|
|
480
|
-
const failures = [];
|
|
481
|
-
if (!isObject(status) || status.schema_version !== CHANGE_STATUS_SCHEMA_VERSION || status.artifact !== "change-status" || status.change !== expectedChange || !expectedStatus.has(status.change_status)) {
|
|
482
|
-
return ["invalid or incomplete change-status v6 contract: " + expectedChange];
|
|
483
|
-
}
|
|
484
|
-
if (!Array.isArray(status.worktrees)) return ["change-status v6 worktrees must be an array: " + expectedChange];
|
|
485
|
-
const previous = { ...status, schema_version: 5 };
|
|
486
|
-
if (!isObject(previous.execution_authorization) || !isObject(previous.leadership) || !Array.isArray(previous.works_run) || !Array.isArray(previous.claimed_investigations)) {
|
|
487
|
-
failures.push("change-status v6 is missing execution authority or leadership state: " + expectedChange);
|
|
488
|
-
}
|
|
489
|
-
const worktreeKeys = [
|
|
490
|
-
"ticket_id", "owner", "implementation_owner", "integration_owner", "provider", "base_sha",
|
|
491
|
-
"parent_branch", "branch", "workspace_ref", "source_checkpoint", "integration", "status", "updated_at",
|
|
492
|
-
];
|
|
493
|
-
const integrationKeys = [
|
|
494
|
-
"status", "parent_ref", "parent_before_sha", "source_sha", "candidate_sha", "candidate_tree_sha",
|
|
495
|
-
"candidate_branch", "candidate_workspace_ref", "result_sha", "method", "conflict_paths", "verification",
|
|
496
|
-
"full_suite", "e2e", "evidence", "attempts", "promotion_status",
|
|
497
|
-
];
|
|
498
|
-
for (const worktree of status.worktrees) {
|
|
499
|
-
if (!isObject(worktree)) { failures.push("change-status v6 contains an invalid worktree"); continue; }
|
|
500
|
-
if (!hasExactKeys(worktree, worktreeKeys) || worktree.provider !== "git" || !/^T-[0-9]{2,}$/.test(String(worktree.ticket_id))) {
|
|
501
|
-
failures.push("change-status v6 contains an incomplete worktree: " + String(worktree.ticket_id));
|
|
502
|
-
continue;
|
|
503
|
-
}
|
|
504
|
-
const current = worktree.workspace_ref === "current";
|
|
505
|
-
if (current && worktree.parent_branch !== worktree.branch) failures.push(`${worktree.ticket_id}: current branch must equal parent_branch`);
|
|
506
|
-
if (!current && worktree.parent_branch === worktree.branch) failures.push(`${worktree.ticket_id}: required branch must differ from parent_branch`);
|
|
507
|
-
const integration = isObject(worktree.integration) ? worktree.integration : {};
|
|
508
|
-
if (!hasExactKeys(integration, integrationKeys) || !Number.isInteger(integration.attempts) || integration.attempts < 0 || !Array.isArray(integration.conflict_paths)) {
|
|
509
|
-
failures.push(`${worktree.ticket_id}: integration contract is incomplete`);
|
|
510
|
-
continue;
|
|
511
|
-
}
|
|
512
|
-
for (const key of ["full_suite", "e2e"]) {
|
|
513
|
-
const suite = integration[key];
|
|
514
|
-
if (!isObject(suite) || !hasExactKeys(suite, ["required", "status", "reason", "evidence"]) || typeof suite.required !== "boolean") {
|
|
515
|
-
failures.push(`${worktree.ticket_id}: ${key} contract is incomplete`);
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
if (current && ["candidate_sha", "candidate_tree_sha", "candidate_branch", "candidate_workspace_ref"].some((key) => integration[key] !== null)) failures.push(`${worktree.ticket_id}: current workspace cannot contain candidate fields`);
|
|
519
|
-
if (current && integration.method !== null && integration.method !== "direct-parent") failures.push(`${worktree.ticket_id}: current workspace requires direct-parent`);
|
|
520
|
-
if (!current && integration.method === "direct-parent") failures.push(`${worktree.ticket_id}: required workspace cannot use direct-parent`);
|
|
521
|
-
}
|
|
522
|
-
return failures;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
function validateChangeStatusV4(status, expectedChange, expectedStatus) {
|
|
526
|
-
return validChangeStatusV4(status, expectedChange, expectedStatus)
|
|
527
|
-
? []
|
|
528
|
-
: ["invalid or incomplete change-status v4 contract: " + expectedChange];
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
function parseGoalPlanScalar(raw) {
|
|
532
|
-
const value = raw.trim();
|
|
533
|
-
if (value === "true") return true;
|
|
534
|
-
if (value === "false") return false;
|
|
535
|
-
if (/^-?\d+$/.test(value)) return Number(value);
|
|
536
|
-
if (value.startsWith("[") && value.endsWith("]")) {
|
|
537
|
-
const inner = value.slice(1, -1).trim();
|
|
538
|
-
return inner ? inner.split(",").map((item) => parseGoalPlanScalar(item)) : [];
|
|
539
|
-
}
|
|
540
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
|
|
541
|
-
return value;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
function parseGoalPlanFrontmatter(text) {
|
|
545
|
-
const lines = text.split(/\r?\n/);
|
|
546
|
-
if (lines[0]?.trim() !== "---") return null;
|
|
547
|
-
const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
548
|
-
if (end < 0) return null;
|
|
549
|
-
const meta = {};
|
|
550
|
-
let currentListKey = null;
|
|
551
|
-
for (const line of lines.slice(1, end)) {
|
|
552
|
-
const trimmed = line.trim();
|
|
553
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
554
|
-
if (currentListKey && /^\s+-\s+/.test(line)) {
|
|
555
|
-
meta[currentListKey].push(parseGoalPlanScalar(line.replace(/^\s+-\s+/, "")));
|
|
556
|
-
continue;
|
|
557
|
-
}
|
|
558
|
-
currentListKey = null;
|
|
559
|
-
const colon = line.indexOf(":");
|
|
560
|
-
if (colon < 1) return null;
|
|
561
|
-
const key = line.slice(0, colon).trim();
|
|
562
|
-
if (key in meta) return null;
|
|
563
|
-
const raw = line.slice(colon + 1).trim();
|
|
564
|
-
if (!raw) {
|
|
565
|
-
meta[key] = [];
|
|
566
|
-
currentListKey = key;
|
|
567
|
-
} else {
|
|
568
|
-
meta[key] = parseGoalPlanScalar(raw);
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
return meta;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
function validGoalPlanV4(meta, change) {
|
|
575
|
-
const required = [
|
|
576
|
-
"schema_version", "artifact", "change", "status", "modes", "orchestration", "lead",
|
|
577
|
-
"implementation_agent_limit", "ticket_workspace_policy", "integration_gate", "ready_for_execution",
|
|
578
|
-
];
|
|
579
|
-
if (!isObject(meta) || !hasExactKeys(meta, required)) return false;
|
|
580
|
-
if (
|
|
581
|
-
meta.schema_version !== 4 ||
|
|
582
|
-
meta.artifact !== "goal-plan" ||
|
|
583
|
-
meta.change !== change ||
|
|
584
|
-
!new Set(["draft", "ready", "in_progress", "completed", "blocked"]).has(meta.status) ||
|
|
585
|
-
meta.orchestration !== "lead-directed" ||
|
|
586
|
-
!nonEmptyString(meta.lead) ||
|
|
587
|
-
!Number.isInteger(meta.implementation_agent_limit) ||
|
|
588
|
-
meta.implementation_agent_limit < 1 ||
|
|
589
|
-
!new Set(["current", "required"]).has(meta.ticket_workspace_policy) ||
|
|
590
|
-
!new Set(["direct-parent", "candidate-merge"]).has(meta.integration_gate) ||
|
|
591
|
-
(meta.ticket_workspace_policy === "current" && meta.integration_gate !== "direct-parent") ||
|
|
592
|
-
(meta.ticket_workspace_policy === "required" && meta.integration_gate !== "candidate-merge") ||
|
|
593
|
-
typeof meta.ready_for_execution !== "boolean" ||
|
|
594
|
-
!Array.isArray(meta.modes)
|
|
595
|
-
) return false;
|
|
596
|
-
return meta.modes.every((mode) => new Set(["migration", "high-assurance", "reference-conformance", "release-coordination"]).has(mode)) &&
|
|
597
|
-
new Set(meta.modes).size === meta.modes.length;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
function validGoalPlanV6(meta, change) {
|
|
601
|
-
const required = [
|
|
602
|
-
"schema_version", "artifact", "change", "status", "modes", "orchestration", "lead",
|
|
603
|
-
"implementation_agent_limit", "integration_attempt_limit", "ticket_workspace_policy", "integration_gate", "ready_for_execution",
|
|
604
|
-
];
|
|
605
|
-
if (!isObject(meta) || !hasExactKeys(meta, required) || meta.schema_version !== GOAL_PLAN_SCHEMA_VERSION) return false;
|
|
606
|
-
const { integration_attempt_limit: integrationAttemptLimit, ...previous } = meta;
|
|
607
|
-
return validGoalPlanV4({ ...previous, schema_version: 4 }, change) &&
|
|
608
|
-
Number.isInteger(integrationAttemptLimit) && integrationAttemptLimit >= 1;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
async function validateGoalPlanV4(changeRoot, change) {
|
|
612
|
-
const path = join(changeRoot, "goal-plan.md");
|
|
613
|
-
if (!(await exists(path))) return [];
|
|
614
|
-
const text = await readFile(path, "utf8");
|
|
615
|
-
return validGoalPlanV4(parseGoalPlanFrontmatter(text), change)
|
|
616
|
-
? []
|
|
617
|
-
: ["Goal Plan is not the complete fixed Lead/candidate-integration v4 contract: " + change];
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
async function validateGoalPlanV6(changeRoot, change) {
|
|
621
|
-
const path = join(changeRoot, "goal-plan.md");
|
|
622
|
-
if (!(await exists(path))) return [];
|
|
623
|
-
return validGoalPlanV6(parseGoalPlanFrontmatter(await readFile(path, "utf8")), change)
|
|
624
|
-
? []
|
|
625
|
-
: ["Goal Plan is not the complete Lead/workspace v6 contract: " + change];
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
function validSpecdevConfigV4(config) {
|
|
629
|
-
const rootKeys = ["schema_version", "interaction_language", "artifact_language", "git", "execution", "verification", "planning"];
|
|
630
|
-
if (
|
|
631
|
-
!isObject(config) ||
|
|
632
|
-
!hasExactKeys(config, rootKeys) ||
|
|
633
|
-
config.schema_version !== 4 ||
|
|
634
|
-
!nonEmptyString(config.interaction_language) ||
|
|
635
|
-
!nonEmptyString(config.artifact_language) ||
|
|
636
|
-
!isObject(config.git) ||
|
|
637
|
-
!isObject(config.execution) ||
|
|
638
|
-
!isObject(config.verification) ||
|
|
639
|
-
!isObject(config.planning)
|
|
640
|
-
) return false;
|
|
641
|
-
if (!hasExactKeys(config.git, ["default_branch"]) || !(config.git.default_branch === null || typeof config.git.default_branch === "string")) return false;
|
|
642
|
-
if (!hasExactKeys(config.execution, ["max_implementation_agents", "deep_ticket_human_approval", "shared_path_owner"])) return false;
|
|
643
|
-
if (
|
|
644
|
-
!Number.isInteger(config.execution.max_implementation_agents) ||
|
|
645
|
-
config.execution.max_implementation_agents < 1 ||
|
|
646
|
-
typeof config.execution.deep_ticket_human_approval !== "boolean" ||
|
|
647
|
-
!nonEmptyString(config.execution.shared_path_owner)
|
|
648
|
-
) return false;
|
|
649
|
-
for (const key of ["test", "typecheck", "lint", "build"]) {
|
|
650
|
-
if (!(key in config.verification) || !stringOrNull(config.verification[key])) return false;
|
|
651
|
-
}
|
|
652
|
-
return new Set(["lite", "standard", "deep"]).has(config.planning.default_depth) &&
|
|
653
|
-
typeof config.planning.require_ready_gate === "boolean" &&
|
|
654
|
-
typeof config.planning.require_evidence === "boolean";
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function validSpecdevConfigV5(config) {
|
|
658
|
-
const rootKeys = ["schema_version", "interaction_language", "artifact_language", "git", "execution", "verification", "planning"];
|
|
659
|
-
if (!isObject(config) || !hasExactKeys(config, rootKeys) || config.schema_version !== CONFIG_SCHEMA_VERSION || !isObject(config.git) || !isObject(config.execution) || !isObject(config.verification) || !isObject(config.planning)) return false;
|
|
660
|
-
if (!hasExactKeys(config.git, ["default_branch"]) || !(config.git.default_branch === null || typeof config.git.default_branch === "string")) return false;
|
|
661
|
-
if (!hasExactKeys(config.execution, ["max_implementation_agents", "max_integration_attempts", "deep_ticket_human_approval", "shared_path_owner"])) return false;
|
|
662
|
-
if (!Number.isInteger(config.execution.max_implementation_agents) || config.execution.max_implementation_agents < 1 || !Number.isInteger(config.execution.max_integration_attempts) || config.execution.max_integration_attempts < 1 || typeof config.execution.deep_ticket_human_approval !== "boolean" || !nonEmptyString(config.execution.shared_path_owner)) return false;
|
|
663
|
-
for (const key of ["test", "typecheck", "lint", "build"]) if (!(key in config.verification) || !stringOrNull(config.verification[key])) return false;
|
|
664
|
-
return new Set(["lite", "standard", "deep"]).has(config.planning.default_depth) && typeof config.planning.require_ready_gate === "boolean" && typeof config.planning.require_evidence === "boolean" && Number.isInteger(config.planning.ui_prototype_default_variants) && config.planning.ui_prototype_default_variants >= 1 && Number.isInteger(config.planning.ui_prototype_max_variants) && config.planning.ui_prototype_max_variants >= config.planning.ui_prototype_default_variants;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
async function validateSpecdev(speculoRoot) {
|
|
668
|
-
const statusPath = join(speculoRoot, ".speculo", "specdev", "status.json");
|
|
669
|
-
if (!(await exists(statusPath))) return [];
|
|
670
|
-
const failures = [];
|
|
671
|
-
const status = await readJson(statusPath);
|
|
672
|
-
if (status.schema_version !== 5 || status.workflow !== "specdev" || !Array.isArray(status.active) || !Array.isArray(status.archived)) {
|
|
673
|
-
return [".speculo/specdev/status.json is not SpecDev global status v5"];
|
|
674
|
-
}
|
|
675
|
-
const active = new Set();
|
|
676
|
-
for (const entry of status.active) {
|
|
677
|
-
if (!entry || typeof entry.change !== "string") {
|
|
678
|
-
failures.push("SpecDev active entry has no change name");
|
|
679
|
-
continue;
|
|
680
|
-
}
|
|
681
|
-
if (active.has(entry.change)) failures.push("duplicate SpecDev active entry: " + entry.change);
|
|
682
|
-
active.add(entry.change);
|
|
683
|
-
const path = join(speculoRoot, ".speculo", "specdev", "changes", entry.change, ".status.json");
|
|
684
|
-
if (!(await exists(path))) {
|
|
685
|
-
failures.push("missing active change state: " + entry.change);
|
|
686
|
-
} else {
|
|
687
|
-
const changeStatus = await readJson(path);
|
|
688
|
-
if (changeStatus.schema_version === CHANGE_STATUS_SCHEMA_VERSION) {
|
|
689
|
-
failures.push(...validateChangeStatusV6(changeStatus, entry.change, new Set(["active", "blocked", "completed"])));
|
|
690
|
-
} else {
|
|
691
|
-
failures.push(...validateChangeStatusV4(changeStatus, entry.change, new Set(["active", "blocked", "completed"])));
|
|
692
|
-
}
|
|
693
|
-
failures.push(...(await exists(join(dirname(path), "goal-plan.md")) && (await readFile(join(dirname(path), "goal-plan.md"), "utf8")).startsWith("---\nschema_version: 6")
|
|
694
|
-
? await validateGoalPlanV6(dirname(path), entry.change)
|
|
695
|
-
: await validateGoalPlanV4(dirname(path), entry.change)));
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
const archived = new Set();
|
|
699
|
-
for (const name of status.archived) {
|
|
700
|
-
if (typeof name !== "string") {
|
|
701
|
-
failures.push("SpecDev archived entry is not a string");
|
|
702
|
-
continue;
|
|
703
|
-
}
|
|
704
|
-
if (archived.has(name)) failures.push("duplicate SpecDev archived entry: " + name);
|
|
705
|
-
archived.add(name);
|
|
706
|
-
if (active.has(name)) failures.push("SpecDev active/archive overlap: " + name);
|
|
707
|
-
const path = join(speculoRoot, ".speculo", "specdev", "archive", name.slice(0, 7), name, ".status.json");
|
|
708
|
-
if (!(await exists(path))) {
|
|
709
|
-
failures.push("missing archived change state: " + name);
|
|
710
|
-
} else {
|
|
711
|
-
const archivedStatus = await readJson(path);
|
|
712
|
-
failures.push(...(archivedStatus.schema_version === CHANGE_STATUS_SCHEMA_VERSION
|
|
713
|
-
? validateChangeStatusV6(archivedStatus, name, new Set(["archived"]))
|
|
714
|
-
: validateChangeStatusV4(archivedStatus, name, new Set(["archived"]))));
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
const changesRoot = join(speculoRoot, ".speculo", "specdev", "changes");
|
|
718
|
-
if (await exists(changesRoot)) {
|
|
719
|
-
for (const entry of await readdir(changesRoot, { withFileTypes: true })) {
|
|
720
|
-
if (!entry.isDirectory()) continue;
|
|
721
|
-
if (!(await exists(join(changesRoot, entry.name, ".status.json")))) failures.push("change directory has no state: " + entry.name);
|
|
722
|
-
else if (!active.has(entry.name)) failures.push("unindexed active change: " + entry.name);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
const archiveRoot = join(speculoRoot, ".speculo", "specdev", "archive");
|
|
726
|
-
if (await exists(archiveRoot)) {
|
|
727
|
-
for (const monthEntry of await readdir(archiveRoot, { withFileTypes: true })) {
|
|
728
|
-
if (!monthEntry.isDirectory()) continue;
|
|
729
|
-
const monthRoot = join(archiveRoot, monthEntry.name);
|
|
730
|
-
for (const changeEntry of await readdir(monthRoot, { withFileTypes: true })) {
|
|
731
|
-
if (!changeEntry.isDirectory()) continue;
|
|
732
|
-
if (!(await exists(join(monthRoot, changeEntry.name, ".status.json")))) failures.push("archived change directory has no state: " + changeEntry.name);
|
|
733
|
-
else if (!archived.has(changeEntry.name)) failures.push("unindexed archived change: " + changeEntry.name);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
const configPath = join(speculoRoot, ".speculo", "specdev", "config.json");
|
|
738
|
-
if (await exists(configPath)) {
|
|
739
|
-
const config = await readJson(configPath);
|
|
740
|
-
if (!validSpecdevConfigV5(config)) failures.push(".speculo/specdev/config.json is not the complete schema-v5 execution contract");
|
|
741
|
-
}
|
|
742
|
-
return failures;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
async function validatePerson(speculoRoot) {
|
|
746
|
-
const path = join(speculoRoot, ".speculo", "person", "status.json");
|
|
747
|
-
if (!(await exists(path))) return [];
|
|
748
|
-
const status = await readJson(path);
|
|
749
|
-
return status.schema_version === 1 && status.workflow === "person" && Array.isArray(status.active)
|
|
750
|
-
? []
|
|
751
|
-
: [".speculo/person/status.json is not person status schema v1"];
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
async function validateActive(speculoRoot, allowPending = false) {
|
|
755
|
-
const failures = [];
|
|
756
|
-
let config;
|
|
757
|
-
let workspace;
|
|
758
|
-
let install;
|
|
759
|
-
try {
|
|
760
|
-
config = await readJson(join(speculoRoot, "config.json"));
|
|
761
|
-
if (config.schema_version !== 1) failures.push("config.json is not schema v1");
|
|
762
|
-
} catch (error) {
|
|
763
|
-
failures.push("config.json: " + String(error));
|
|
764
|
-
}
|
|
765
|
-
try {
|
|
766
|
-
workspace = await readJson(join(speculoRoot, ".speculo", "workspace.json"));
|
|
767
|
-
const roots = workspace.roots;
|
|
768
|
-
if (
|
|
769
|
-
workspace.schema_version !== 1 || workspace.path_base !== "project-root" ||
|
|
770
|
-
!roots || ["config", "speculo", "state", "commands", "skills", "workflows"].some((key) => typeof roots[key] !== "string")
|
|
771
|
-
) failures.push(".speculo/workspace.json is not a project-root schema-v1 workspace");
|
|
772
|
-
} catch (error) {
|
|
773
|
-
failures.push(".speculo/workspace.json: " + String(error));
|
|
774
|
-
}
|
|
775
|
-
try {
|
|
776
|
-
install = await readJson(join(speculoRoot, ".speculo", "install.json"));
|
|
777
|
-
if (
|
|
778
|
-
install.schema_version !== 1 || typeof install.package_version !== "string" ||
|
|
779
|
-
!Array.isArray(install.workflows) || install.workflows.some((item) => typeof item !== "string") ||
|
|
780
|
-
new Set(install.workflows).size !== install.workflows.length
|
|
781
|
-
) {
|
|
782
|
-
failures.push(".speculo/install.json is not a valid schema-v1 install manifest");
|
|
783
|
-
} else {
|
|
784
|
-
for (const workflow of install.workflows) {
|
|
785
|
-
if (!(await exists(join(speculoRoot, "workflows", workflow, "INDEX.md")))) failures.push("missing installed workflow INDEX: " + workflow);
|
|
786
|
-
if (!(await exists(join(speculoRoot, ".speculo", workflow, "status.json")))) failures.push("missing installed workflow state: " + workflow);
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
} catch (error) {
|
|
790
|
-
failures.push(".speculo/install.json: " + String(error));
|
|
791
|
-
}
|
|
792
|
-
if (!allowPending && await exists(join(speculoRoot, ".speculo", "migration.json"))) failures.push("pending migration marker still exists");
|
|
793
|
-
failures.push(...await validateJsonTree(speculoRoot));
|
|
794
|
-
failures.push(...await validateSpecdev(speculoRoot));
|
|
795
|
-
failures.push(...await validatePerson(speculoRoot));
|
|
796
|
-
if (failures.length) throw new Error("Migrated runtime validation failed:\n- " + failures.join("\n- "));
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
async function assertNoSymlinks(root) {
|
|
800
|
-
for (const entry of await walk(root)) {
|
|
801
|
-
if (entry.type === "symlink") throw new Error("Runtime contains a symbolic link: " + entry.path);
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
async function applyAction(ctx, stagedSpeculo, action) {
|
|
806
|
-
if (action.kind === "keep-current") return;
|
|
807
|
-
const destination = inside(stagedSpeculo, action.to);
|
|
808
|
-
await assertNoSymlinkPath(stagedSpeculo, action.to);
|
|
809
|
-
if (action.kind === "remove-current") {
|
|
810
|
-
await rm(destination, { recursive: true, force: true });
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
await mkdir(dirname(destination), { recursive: true });
|
|
814
|
-
if (action.kind === "copy") {
|
|
815
|
-
const source = inside(ctx.backupRoot, action.from);
|
|
816
|
-
const stat = await lstat(source);
|
|
817
|
-
await rm(destination, { recursive: true, force: true });
|
|
818
|
-
await cp(source, destination, {
|
|
819
|
-
recursive: stat.isDirectory(),
|
|
820
|
-
force: true,
|
|
821
|
-
verbatimSymlinks: true,
|
|
822
|
-
});
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
await writeFile(destination, JSON.stringify(action.value, null, 2) + "\n", "utf8");
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
async function apply(projectRoot, planPath, confirmed) {
|
|
829
|
-
if (!confirmed) throw new Error("apply requires --confirmed");
|
|
830
|
-
if (!planPath) throw new Error("apply requires --plan");
|
|
831
|
-
const ctx = await context(projectRoot);
|
|
832
|
-
const issues = await validateBackup(ctx);
|
|
833
|
-
if (issues.length) throw new Error("Backup validation failed:\n- " + issues.join("\n- "));
|
|
834
|
-
const plan = await readJson(resolve(planPath));
|
|
835
|
-
await validatePlan(ctx, plan);
|
|
836
|
-
|
|
837
|
-
const stageContainer = await mkdtemp(join(ctx.projectRoot, STAGE_PREFIX));
|
|
838
|
-
const stagedSpeculo = join(stageContainer, "speculo");
|
|
839
|
-
const rollbackRoot = join(ctx.projectRoot, ROLLBACK_NAME);
|
|
840
|
-
let oldMoved = false;
|
|
841
|
-
let newInstalled = false;
|
|
842
|
-
try {
|
|
843
|
-
await cp(ctx.speculoRoot, stagedSpeculo, { recursive: true, force: true });
|
|
844
|
-
for (const action of plan.actions) await applyAction(ctx, stagedSpeculo, action);
|
|
845
|
-
await assertNoSymlinks(stagedSpeculo);
|
|
846
|
-
await validateActive(stagedSpeculo, true);
|
|
847
|
-
await rm(join(stagedSpeculo, ".speculo", "migration.json"), { force: true });
|
|
848
|
-
await assertNoSymlinks(stagedSpeculo);
|
|
849
|
-
await rename(ctx.speculoRoot, rollbackRoot);
|
|
850
|
-
oldMoved = true;
|
|
851
|
-
await rename(stagedSpeculo, ctx.speculoRoot);
|
|
852
|
-
newInstalled = true;
|
|
853
|
-
await assertNoSymlinks(ctx.speculoRoot);
|
|
854
|
-
await validateActive(ctx.speculoRoot);
|
|
855
|
-
const installedCtx = await contextWithCompletedMigration(ctx.projectRoot);
|
|
856
|
-
const postIssues = await validateBackup(installedCtx, false);
|
|
857
|
-
if (postIssues.length) throw new Error("Backup changed during migration:\n- " + postIssues.join("\n- "));
|
|
858
|
-
await rm(rollbackRoot, { recursive: true, force: true });
|
|
859
|
-
await rm(stageContainer, { recursive: true, force: true });
|
|
860
|
-
return { ok: true, actions: plan.actions.length, rollback: "not-required", backup: "speculo/.speculo/back" };
|
|
861
|
-
} catch (error) {
|
|
862
|
-
if (newInstalled && await exists(ctx.speculoRoot)) await rm(ctx.speculoRoot, { recursive: true, force: true });
|
|
863
|
-
if (oldMoved && await exists(rollbackRoot)) await rename(rollbackRoot, ctx.speculoRoot);
|
|
864
|
-
await rm(stageContainer, { recursive: true, force: true });
|
|
865
|
-
throw error;
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
async function contextWithCompletedMigration(projectRoot) {
|
|
870
|
-
const speculoRoot = join(projectRoot, "speculo");
|
|
871
|
-
const stateRoot = join(speculoRoot, ".speculo");
|
|
872
|
-
const backupRoot = join(stateRoot, "back");
|
|
873
|
-
const manifestPath = join(backupRoot, "manifest.json");
|
|
874
|
-
return {
|
|
875
|
-
projectRoot,
|
|
876
|
-
speculoRoot,
|
|
877
|
-
stateRoot,
|
|
878
|
-
backupRoot,
|
|
879
|
-
manifestPath,
|
|
880
|
-
manifest: await readJson(manifestPath),
|
|
881
|
-
};
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
async function main(argv) {
|
|
885
|
-
let args;
|
|
886
|
-
try {
|
|
887
|
-
args = parseArgs(argv);
|
|
888
|
-
} catch (error) {
|
|
889
|
-
process.stderr.write(String(error) + "\n");
|
|
890
|
-
return usage();
|
|
891
|
-
}
|
|
892
|
-
if (args.help || !args.operation) return usage();
|
|
893
|
-
try {
|
|
894
|
-
if (args.operation === "inspect") {
|
|
895
|
-
process.stdout.write(JSON.stringify(await inspect(args.project_root), null, 2) + "\n");
|
|
896
|
-
return 0;
|
|
897
|
-
}
|
|
898
|
-
if (args.operation === "fingerprint") {
|
|
899
|
-
if (!args.target) throw new Error("fingerprint requires --target");
|
|
900
|
-
const ctx = await context(args.project_root);
|
|
901
|
-
if (!allowedTarget(args.target, (await readJson(join(ctx.stateRoot, "install.json"))).workflows ?? [])) throw new Error("target is outside runtime ownership");
|
|
902
|
-
process.stdout.write(await fingerprint(inside(ctx.speculoRoot, args.target)) + "\n");
|
|
903
|
-
return 0;
|
|
904
|
-
}
|
|
905
|
-
if (args.operation === "apply") {
|
|
906
|
-
process.stdout.write(JSON.stringify(await apply(args.project_root, args.plan, args.confirmed), null, 2) + "\n");
|
|
907
|
-
return 0;
|
|
908
|
-
}
|
|
909
|
-
return usage();
|
|
910
|
-
} catch (error) {
|
|
911
|
-
process.stderr.write((error instanceof Error ? error.message : String(error)) + "\n");
|
|
912
|
-
return 1;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
process.exitCode = await main(process.argv.slice(2));
|