@namewta/speculo 0.7.0 → 0.7.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.
@@ -0,0 +1,545 @@
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
+ rename,
12
+ rm,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import { dirname, join, relative, resolve, sep } from "node:path";
16
+
17
+ const STAGE_PREFIX = ".speculo-runtime-migrate-stage-";
18
+ const ROLLBACK_NAME = ".speculo-runtime-migrate-rollback";
19
+ const VALID_ACTIONS = new Set(["copy", "replace-json", "keep-current", "remove-current"]);
20
+ const VALID_DECISIONS = new Set(["restore", "merge-json", "replace-json", "keep-current", "remove-current"]);
21
+
22
+ function usage() {
23
+ process.stderr.write([
24
+ "Usage:",
25
+ " node migrate-runtime-state.mjs inspect --project-root <path>",
26
+ " node migrate-runtime-state.mjs fingerprint --project-root <path> --target <relative-path>",
27
+ " node migrate-runtime-state.mjs apply --project-root <path> --plan <plan.json> --confirmed",
28
+ "",
29
+ "inspect and fingerprint are read-only. apply requires an explicit confirmed schema-v1 plan.",
30
+ "",
31
+ ].join("\n"));
32
+ return 2;
33
+ }
34
+
35
+ function parseArgs(argv) {
36
+ const [operation, ...rest] = argv;
37
+ const options = { operation, confirmed: false };
38
+ for (let index = 0; index < rest.length; index += 1) {
39
+ const item = rest[index];
40
+ if (item === "--confirmed") {
41
+ options.confirmed = true;
42
+ } else if (item === "--project-root" || item === "--plan" || item === "--target") {
43
+ options[item.slice(2).replaceAll("-", "_")] = rest[index + 1];
44
+ index += 1;
45
+ } else if (item === "--help" || item === "-h") {
46
+ options.help = true;
47
+ } else {
48
+ throw new Error("Unknown argument: " + item);
49
+ }
50
+ }
51
+ return options;
52
+ }
53
+
54
+ async function exists(path) {
55
+ try {
56
+ await lstat(path);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ function toPosix(path) {
64
+ return path.split(sep).join("/");
65
+ }
66
+
67
+ function safeRelative(value, label) {
68
+ if (typeof value !== "string" || !value || value.includes("\\")) {
69
+ throw new Error(label + " must be a non-empty POSIX relative path");
70
+ }
71
+ const parts = value.split("/");
72
+ if (value.startsWith("/") || /^[A-Za-z]:/.test(value) || parts.some((part) => !part || part === "." || part === "..")) {
73
+ throw new Error(label + " escapes its allowed root: " + value);
74
+ }
75
+ return value;
76
+ }
77
+
78
+ function inside(root, relativePath) {
79
+ const target = resolve(root, safeRelative(relativePath, "path"));
80
+ const prefix = root.endsWith(sep) ? root : root + sep;
81
+ if (target !== root && !target.startsWith(prefix)) throw new Error("Path escapes root: " + relativePath);
82
+ return target;
83
+ }
84
+
85
+ async function readJson(path) {
86
+ return JSON.parse(await readFile(path, "utf8"));
87
+ }
88
+
89
+ async function sha256(path) {
90
+ return createHash("sha256").update(await readFile(path)).digest("hex");
91
+ }
92
+
93
+ async function walk(root, current = root, options = {}) {
94
+ if (!(await exists(current))) return [];
95
+ const values = [];
96
+ for (const entry of await readdir(current, { withFileTypes: true })) {
97
+ const path = join(current, entry.name);
98
+ const item = toPosix(relative(root, path));
99
+ if (options.exclude?.(item)) continue;
100
+ if (entry.isDirectory()) {
101
+ values.push({ path: item, type: "directory" });
102
+ values.push(...await walk(root, path, options));
103
+ } else if (entry.isSymbolicLink()) {
104
+ values.push({ path: item, type: "symlink" });
105
+ } else if (entry.isFile()) {
106
+ const stat = await lstat(path);
107
+ values.push({ path: item, type: "file", bytes: stat.size, sha256: await sha256(path) });
108
+ }
109
+ }
110
+ return values.sort((left, right) => left.path.localeCompare(right.path));
111
+ }
112
+
113
+ async function fingerprint(path) {
114
+ if (!(await exists(path))) return "absent";
115
+ const stat = await lstat(path);
116
+ if (stat.isSymbolicLink()) throw new Error("Target is a symbolic link: " + path);
117
+ if (stat.isFile()) return "file:" + await sha256(path);
118
+ if (!stat.isDirectory()) throw new Error("Unsupported target type: " + path);
119
+ const entries = await walk(path);
120
+ const digest = createHash("sha256");
121
+ for (const entry of entries) digest.update(JSON.stringify(entry) + "\n");
122
+ return "directory:" + digest.digest("hex");
123
+ }
124
+
125
+ async function assertNoSymlinkPath(root, relativePath) {
126
+ let current = root;
127
+ for (const part of safeRelative(relativePath, "target").split("/")) {
128
+ current = join(current, part);
129
+ if (!(await exists(current))) continue;
130
+ if ((await lstat(current)).isSymbolicLink()) throw new Error("Target path traverses a symbolic link: " + relativePath);
131
+ }
132
+ }
133
+
134
+ async function context(projectRootArg) {
135
+ if (!projectRootArg) throw new Error("--project-root is required");
136
+ const projectRoot = resolve(projectRootArg);
137
+ const speculoRoot = join(projectRoot, "speculo");
138
+ const stateRoot = join(speculoRoot, ".speculo");
139
+ const backupRoot = join(stateRoot, "back");
140
+ const markerPath = join(stateRoot, "migration.json");
141
+ const manifestPath = join(backupRoot, "manifest.json");
142
+ for (const [label, path] of [["Speculo installation", speculoRoot], ["pending marker", markerPath], ["backup manifest", manifestPath]]) {
143
+ if (!(await exists(path))) throw new Error(label + " does not exist: " + path);
144
+ }
145
+ for (const [label, path] of [["Speculo installation", speculoRoot], ["runtime state", stateRoot], ["backup root", backupRoot]]) {
146
+ if ((await lstat(path)).isSymbolicLink()) throw new Error(label + " must not be a symbolic link: " + path);
147
+ }
148
+ const marker = await readJson(markerPath);
149
+ if (marker.schema_version !== 1 || marker.status !== "pending") throw new Error("migration.json is not a pending schema-v1 marker");
150
+ const manifest = await readJson(manifestPath);
151
+ if (manifest.schema_version !== 1 || !Array.isArray(manifest.files)) throw new Error("back/manifest.json is not a schema-v1 manifest");
152
+ return { projectRoot, speculoRoot, stateRoot, backupRoot, markerPath, manifestPath, marker, manifest };
153
+ }
154
+
155
+ async function validateBackup(ctx, checkMigrationWorkspace = true) {
156
+ const issues = [];
157
+ const expected = new Map();
158
+ for (const entry of ctx.manifest.files) {
159
+ try {
160
+ const item = safeRelative(entry.path, "manifest path");
161
+ if (item === "manifest.json") throw new Error("manifest cannot include itself");
162
+ if (expected.has(item)) throw new Error("duplicate manifest entry: " + item);
163
+ if (entry.type !== "file" && entry.type !== "symlink") throw new Error("invalid manifest entry type: " + item);
164
+ if (entry.type === "file" && (typeof entry.sha256 !== "string" || typeof entry.bytes !== "number")) {
165
+ throw new Error("file manifest entry has no hash or size: " + item);
166
+ }
167
+ expected.set(item, entry);
168
+ } catch (error) {
169
+ issues.push(String(error));
170
+ }
171
+ }
172
+ const actual = await walk(ctx.backupRoot, ctx.backupRoot, { exclude: (item) => item === "manifest.json" });
173
+ const actualFiles = actual.filter((entry) => entry.type !== "directory");
174
+ for (const entry of actualFiles) {
175
+ const declared = expected.get(entry.path);
176
+ if (!declared) {
177
+ issues.push("undeclared backup entry: " + entry.path);
178
+ continue;
179
+ }
180
+ if (entry.type === "symlink" || declared.type === "symlink") {
181
+ issues.push("backup symlink requires manual recovery outside this command: " + entry.path);
182
+ } else if (entry.sha256 !== declared.sha256 || entry.bytes !== declared.bytes) {
183
+ issues.push("backup hash or size mismatch: " + entry.path);
184
+ }
185
+ expected.delete(entry.path);
186
+ }
187
+ for (const path of expected.keys()) issues.push("missing backup entry: " + path);
188
+ if (checkMigrationWorkspace) {
189
+ const projectEntries = await readdir(ctx.projectRoot);
190
+ for (const name of projectEntries) {
191
+ if (name.startsWith(STAGE_PREFIX) || name === ROLLBACK_NAME) issues.push("unfinished migration workspace: " + name);
192
+ }
193
+ }
194
+ return issues;
195
+ }
196
+
197
+ async function inspect(projectRoot) {
198
+ const ctx = await context(projectRoot);
199
+ const issues = await validateBackup(ctx);
200
+ return {
201
+ ok: issues.length === 0,
202
+ pending: ctx.marker,
203
+ backup: {
204
+ source_version: ctx.manifest.source_version,
205
+ target_version: ctx.manifest.target_version,
206
+ entries: ctx.manifest.files.length,
207
+ manifest_sha256: await sha256(ctx.manifestPath),
208
+ files: ctx.manifest.files,
209
+ },
210
+ issues,
211
+ };
212
+ }
213
+
214
+ function allowedTarget(target, installedWorkflows) {
215
+ safeRelative(target, "target");
216
+ if (target === "config.json") return true;
217
+ if (!target.startsWith(".speculo/")) return false;
218
+ for (const protectedPath of [
219
+ ".speculo/back",
220
+ ".speculo/workspace.json",
221
+ ".speculo/install.json",
222
+ ".speculo/migration.json",
223
+ ".speculo/README.md",
224
+ ]) {
225
+ if (target === protectedPath || target.startsWith(protectedPath + "/")) return false;
226
+ }
227
+ if (target.startsWith(".speculo/commands/")) return true;
228
+ return installedWorkflows.some((workflow) => target === `.speculo/${workflow}` || target.startsWith(`.speculo/${workflow}/`));
229
+ }
230
+
231
+ function allowedDecisionTarget(target, disposition, installedWorkflows) {
232
+ safeRelative(target, "decision target");
233
+ if (allowedTarget(target, installedWorkflows)) return true;
234
+ if (disposition !== "keep-current") return false;
235
+ return new Set([
236
+ ".speculo/README.md",
237
+ ".speculo/workspace.json",
238
+ ".speculo/install.json",
239
+ ]).has(target);
240
+ }
241
+
242
+ function pathsOverlap(left, right) {
243
+ return left === right || left.startsWith(right + "/") || right.startsWith(left + "/");
244
+ }
245
+
246
+ async function validatePlan(ctx, plan) {
247
+ if (plan.schema_version !== 1 || !Array.isArray(plan.source_decisions) || !Array.isArray(plan.actions)) {
248
+ throw new Error("Plan must use schema_version 1 and contain source_decisions and actions");
249
+ }
250
+ if (plan.backup_manifest_sha256 !== await sha256(ctx.manifestPath)) throw new Error("Plan backup manifest fingerprint does not match");
251
+ const install = await readJson(join(ctx.stateRoot, "install.json"));
252
+ const workflows = Array.isArray(install.workflows) ? install.workflows.filter((item) => typeof item === "string") : [];
253
+ const expectedSources = new Set(ctx.manifest.files.map((entry) => entry.path));
254
+ const seenSources = new Set();
255
+ for (const [index, decision] of plan.source_decisions.entries()) {
256
+ if (!decision || typeof decision !== "object" || !VALID_DECISIONS.has(decision.disposition)) {
257
+ throw new Error(`source_decisions[${index}] has an invalid disposition`);
258
+ }
259
+ const source = safeRelative(decision.path, `source_decisions[${index}] path`);
260
+ if (!expectedSources.has(source)) throw new Error(`source_decisions[${index}] is not in the backup manifest: ${source}`);
261
+ if (seenSources.has(source)) throw new Error(`source_decisions[${index}] repeats ${source}`);
262
+ if (typeof decision.target !== "string" || !allowedDecisionTarget(decision.target, decision.disposition, workflows)) {
263
+ throw new Error(`source_decisions[${index}] target is outside runtime ownership: ${decision.target}`);
264
+ }
265
+ seenSources.add(source);
266
+ }
267
+ for (const source of expectedSources) {
268
+ if (!seenSources.has(source)) throw new Error("Plan has no decision for backup entry: " + source);
269
+ }
270
+ const seenTargets = new Set();
271
+ for (const [index, action] of plan.actions.entries()) {
272
+ if (!action || typeof action !== "object" || !VALID_ACTIONS.has(action.kind)) throw new Error(`actions[${index}] has an invalid kind`);
273
+ if (!allowedTarget(action.to, workflows)) throw new Error(`actions[${index}] target is outside runtime ownership: ${action.to}`);
274
+ for (const target of seenTargets) {
275
+ if (pathsOverlap(target, action.to)) throw new Error(`actions[${index}] overlaps target ${target}`);
276
+ }
277
+ seenTargets.add(action.to);
278
+ if (action.kind === "copy") {
279
+ const sourcePath = safeRelative(action.from, `actions[${index}] source`);
280
+ if (sourcePath !== "config.json" && !sourcePath.startsWith("state/")) throw new Error(`actions[${index}] source is outside backup data: ${action.from}`);
281
+ const source = inside(ctx.backupRoot, action.from);
282
+ if (!(await exists(source))) throw new Error(`actions[${index}] source does not exist: ${action.from}`);
283
+ }
284
+ if (action.kind === "replace-json") {
285
+ if (!action.to.endsWith(".json") || action.value === undefined) throw new Error(`actions[${index}] replace-json needs a JSON target and value`);
286
+ JSON.stringify(action.value);
287
+ }
288
+ if (typeof action.expected_target !== "string") throw new Error(`actions[${index}] must contain expected_target`);
289
+ const currentFingerprint = await fingerprint(inside(ctx.speculoRoot, action.to));
290
+ if (currentFingerprint !== action.expected_target) throw new Error(`actions[${index}] target drifted: ${action.to}`);
291
+ }
292
+ return workflows;
293
+ }
294
+
295
+ async function validateJsonTree(root) {
296
+ const failures = [];
297
+ for (const entry of await walk(root, root, { exclude: (item) => item === ".speculo/back" || item.startsWith(".speculo/back/") })) {
298
+ if (entry.type !== "file" || !entry.path.endsWith(".json")) continue;
299
+ try {
300
+ await readJson(join(root, entry.path));
301
+ } catch (error) {
302
+ failures.push(entry.path + ": " + String(error));
303
+ }
304
+ }
305
+ return failures;
306
+ }
307
+
308
+ async function validateSpecdev(speculoRoot) {
309
+ const statusPath = join(speculoRoot, ".speculo", "specdev", "status.json");
310
+ if (!(await exists(statusPath))) return [];
311
+ const failures = [];
312
+ const status = await readJson(statusPath);
313
+ if (status.schema_version !== 4 || status.workflow !== "specdev" || !Array.isArray(status.active) || !Array.isArray(status.archived)) {
314
+ return [".speculo/specdev/status.json is not SpecDev global status v4"];
315
+ }
316
+ const active = new Set();
317
+ for (const entry of status.active) {
318
+ if (!entry || typeof entry.change !== "string") {
319
+ failures.push("SpecDev active entry has no change name");
320
+ continue;
321
+ }
322
+ if (active.has(entry.change)) failures.push("duplicate SpecDev active entry: " + entry.change);
323
+ active.add(entry.change);
324
+ const path = join(speculoRoot, ".speculo", "specdev", "changes", entry.change, ".status.json");
325
+ if (!(await exists(path))) {
326
+ failures.push("missing active change state: " + entry.change);
327
+ } else {
328
+ const changeStatus = await readJson(path);
329
+ if (
330
+ changeStatus.schema_version !== 3 ||
331
+ changeStatus.artifact !== "change-status" ||
332
+ changeStatus.change !== entry.change ||
333
+ !new Set(["active", "blocked", "completed"]).has(changeStatus.change_status)
334
+ ) failures.push("invalid active change state: " + entry.change);
335
+ }
336
+ }
337
+ const archived = new Set();
338
+ for (const name of status.archived) {
339
+ if (typeof name !== "string") {
340
+ failures.push("SpecDev archived entry is not a string");
341
+ continue;
342
+ }
343
+ if (archived.has(name)) failures.push("duplicate SpecDev archived entry: " + name);
344
+ archived.add(name);
345
+ if (active.has(name)) failures.push("SpecDev active/archive overlap: " + name);
346
+ const path = join(speculoRoot, ".speculo", "specdev", "archive", name.slice(0, 7), name, ".status.json");
347
+ if (!(await exists(path))) {
348
+ failures.push("missing archived change state: " + name);
349
+ } else {
350
+ const archivedStatus = await readJson(path);
351
+ if (
352
+ archivedStatus.schema_version !== 3 ||
353
+ archivedStatus.artifact !== "change-status" ||
354
+ archivedStatus.change !== name ||
355
+ archivedStatus.change_status !== "archived"
356
+ ) failures.push("invalid archived change state: " + name);
357
+ }
358
+ }
359
+ const changesRoot = join(speculoRoot, ".speculo", "specdev", "changes");
360
+ if (await exists(changesRoot)) {
361
+ for (const entry of await readdir(changesRoot, { withFileTypes: true })) {
362
+ if (!entry.isDirectory()) continue;
363
+ if (!(await exists(join(changesRoot, entry.name, ".status.json")))) failures.push("change directory has no state: " + entry.name);
364
+ else if (!active.has(entry.name)) failures.push("unindexed active change: " + entry.name);
365
+ }
366
+ }
367
+ const archiveRoot = join(speculoRoot, ".speculo", "specdev", "archive");
368
+ if (await exists(archiveRoot)) {
369
+ for (const monthEntry of await readdir(archiveRoot, { withFileTypes: true })) {
370
+ if (!monthEntry.isDirectory()) continue;
371
+ const monthRoot = join(archiveRoot, monthEntry.name);
372
+ for (const changeEntry of await readdir(monthRoot, { withFileTypes: true })) {
373
+ if (!changeEntry.isDirectory()) continue;
374
+ if (!(await exists(join(monthRoot, changeEntry.name, ".status.json")))) failures.push("archived change directory has no state: " + changeEntry.name);
375
+ else if (!archived.has(changeEntry.name)) failures.push("unindexed archived change: " + changeEntry.name);
376
+ }
377
+ }
378
+ }
379
+ const configPath = join(speculoRoot, ".speculo", "specdev", "config.json");
380
+ if (await exists(configPath)) {
381
+ const config = await readJson(configPath);
382
+ if (config.schema_version !== 3) failures.push(".speculo/specdev/config.json is not schema v3");
383
+ }
384
+ return failures;
385
+ }
386
+
387
+ async function validatePerson(speculoRoot) {
388
+ const path = join(speculoRoot, ".speculo", "person", "status.json");
389
+ if (!(await exists(path))) return [];
390
+ const status = await readJson(path);
391
+ return status.schema_version === 1 && status.workflow === "person" && Array.isArray(status.active)
392
+ ? []
393
+ : [".speculo/person/status.json is not person status schema v1"];
394
+ }
395
+
396
+ async function validateActive(speculoRoot, allowPending = false) {
397
+ const failures = [];
398
+ let config;
399
+ let workspace;
400
+ let install;
401
+ try {
402
+ config = await readJson(join(speculoRoot, "config.json"));
403
+ if (config.schema_version !== 1) failures.push("config.json is not schema v1");
404
+ } catch (error) {
405
+ failures.push("config.json: " + String(error));
406
+ }
407
+ try {
408
+ workspace = await readJson(join(speculoRoot, ".speculo", "workspace.json"));
409
+ const roots = workspace.roots;
410
+ if (
411
+ workspace.schema_version !== 1 || workspace.path_base !== "project-root" ||
412
+ !roots || ["config", "speculo", "state", "commands", "skills", "workflows"].some((key) => typeof roots[key] !== "string")
413
+ ) failures.push(".speculo/workspace.json is not a project-root schema-v1 workspace");
414
+ } catch (error) {
415
+ failures.push(".speculo/workspace.json: " + String(error));
416
+ }
417
+ try {
418
+ install = await readJson(join(speculoRoot, ".speculo", "install.json"));
419
+ if (
420
+ install.schema_version !== 1 || typeof install.package_version !== "string" ||
421
+ !Array.isArray(install.workflows) || install.workflows.some((item) => typeof item !== "string") ||
422
+ new Set(install.workflows).size !== install.workflows.length
423
+ ) {
424
+ failures.push(".speculo/install.json is not a valid schema-v1 install manifest");
425
+ } else {
426
+ for (const workflow of install.workflows) {
427
+ if (!(await exists(join(speculoRoot, "workflows", workflow, "INDEX.md")))) failures.push("missing installed workflow INDEX: " + workflow);
428
+ if (!(await exists(join(speculoRoot, ".speculo", workflow, "status.json")))) failures.push("missing installed workflow state: " + workflow);
429
+ }
430
+ }
431
+ } catch (error) {
432
+ failures.push(".speculo/install.json: " + String(error));
433
+ }
434
+ if (!allowPending && await exists(join(speculoRoot, ".speculo", "migration.json"))) failures.push("pending migration marker still exists");
435
+ failures.push(...await validateJsonTree(speculoRoot));
436
+ failures.push(...await validateSpecdev(speculoRoot));
437
+ failures.push(...await validatePerson(speculoRoot));
438
+ if (failures.length) throw new Error("Migrated runtime validation failed:\n- " + failures.join("\n- "));
439
+ }
440
+
441
+ async function applyAction(ctx, stagedSpeculo, action) {
442
+ if (action.kind === "keep-current") return;
443
+ const destination = inside(stagedSpeculo, action.to);
444
+ await assertNoSymlinkPath(stagedSpeculo, action.to);
445
+ if (action.kind === "remove-current") {
446
+ await rm(destination, { recursive: true, force: true });
447
+ return;
448
+ }
449
+ await mkdir(dirname(destination), { recursive: true });
450
+ if (action.kind === "copy") {
451
+ const source = inside(ctx.backupRoot, action.from);
452
+ const stat = await lstat(source);
453
+ await rm(destination, { recursive: true, force: true });
454
+ await cp(source, destination, { recursive: stat.isDirectory(), force: true });
455
+ return;
456
+ }
457
+ await writeFile(destination, JSON.stringify(action.value, null, 2) + "\n", "utf8");
458
+ }
459
+
460
+ async function apply(projectRoot, planPath, confirmed) {
461
+ if (!confirmed) throw new Error("apply requires --confirmed");
462
+ if (!planPath) throw new Error("apply requires --plan");
463
+ const ctx = await context(projectRoot);
464
+ const issues = await validateBackup(ctx);
465
+ if (issues.length) throw new Error("Backup validation failed:\n- " + issues.join("\n- "));
466
+ const plan = await readJson(resolve(planPath));
467
+ await validatePlan(ctx, plan);
468
+
469
+ const stageContainer = await mkdtemp(join(ctx.projectRoot, STAGE_PREFIX));
470
+ const stagedSpeculo = join(stageContainer, "speculo");
471
+ const rollbackRoot = join(ctx.projectRoot, ROLLBACK_NAME);
472
+ let oldMoved = false;
473
+ let newInstalled = false;
474
+ try {
475
+ await cp(ctx.speculoRoot, stagedSpeculo, { recursive: true, force: true });
476
+ for (const action of plan.actions) await applyAction(ctx, stagedSpeculo, action);
477
+ await validateActive(stagedSpeculo, true);
478
+ await rm(join(stagedSpeculo, ".speculo", "migration.json"), { force: true });
479
+ await rename(ctx.speculoRoot, rollbackRoot);
480
+ oldMoved = true;
481
+ await rename(stagedSpeculo, ctx.speculoRoot);
482
+ newInstalled = true;
483
+ await validateActive(ctx.speculoRoot);
484
+ const installedCtx = await contextWithCompletedMigration(ctx.projectRoot);
485
+ const postIssues = await validateBackup(installedCtx, false);
486
+ if (postIssues.length) throw new Error("Backup changed during migration:\n- " + postIssues.join("\n- "));
487
+ await rm(rollbackRoot, { recursive: true, force: true });
488
+ await rm(stageContainer, { recursive: true, force: true });
489
+ return { ok: true, actions: plan.actions.length, rollback: "not-required", backup: "speculo/.speculo/back" };
490
+ } catch (error) {
491
+ if (newInstalled && await exists(ctx.speculoRoot)) await rm(ctx.speculoRoot, { recursive: true, force: true });
492
+ if (oldMoved && await exists(rollbackRoot)) await rename(rollbackRoot, ctx.speculoRoot);
493
+ await rm(stageContainer, { recursive: true, force: true });
494
+ throw error;
495
+ }
496
+ }
497
+
498
+ async function contextWithCompletedMigration(projectRoot) {
499
+ const speculoRoot = join(projectRoot, "speculo");
500
+ const stateRoot = join(speculoRoot, ".speculo");
501
+ const backupRoot = join(stateRoot, "back");
502
+ const manifestPath = join(backupRoot, "manifest.json");
503
+ return {
504
+ projectRoot,
505
+ speculoRoot,
506
+ stateRoot,
507
+ backupRoot,
508
+ manifestPath,
509
+ manifest: await readJson(manifestPath),
510
+ };
511
+ }
512
+
513
+ async function main(argv) {
514
+ let args;
515
+ try {
516
+ args = parseArgs(argv);
517
+ } catch (error) {
518
+ process.stderr.write(String(error) + "\n");
519
+ return usage();
520
+ }
521
+ if (args.help || !args.operation) return usage();
522
+ try {
523
+ if (args.operation === "inspect") {
524
+ process.stdout.write(JSON.stringify(await inspect(args.project_root), null, 2) + "\n");
525
+ return 0;
526
+ }
527
+ if (args.operation === "fingerprint") {
528
+ if (!args.target) throw new Error("fingerprint requires --target");
529
+ const ctx = await context(args.project_root);
530
+ if (!allowedTarget(args.target, (await readJson(join(ctx.stateRoot, "install.json"))).workflows ?? [])) throw new Error("target is outside runtime ownership");
531
+ process.stdout.write(await fingerprint(inside(ctx.speculoRoot, args.target)) + "\n");
532
+ return 0;
533
+ }
534
+ if (args.operation === "apply") {
535
+ process.stdout.write(JSON.stringify(await apply(args.project_root, args.plan, args.confirmed), null, 2) + "\n");
536
+ return 0;
537
+ }
538
+ return usage();
539
+ } catch (error) {
540
+ process.stderr.write((error instanceof Error ? error.message : String(error)) + "\n");
541
+ return 1;
542
+ }
543
+ }
544
+
545
+ process.exitCode = await main(process.argv.slice(2));
@@ -83,7 +83,7 @@ keywords: [初始化, 配置, status, tracking, 验证命令]
83
83
  - `<Path>{roots.state}/specdev/research/</Path>`
84
84
  - `<Path>{roots.state}/specdev/archive/</Path>`
85
85
 
86
- 若全局状态已存在,先检查 `schema_version`。版本未知、JSON 不可解析或状态与当前 workflow 契约不一致时,停止当前 Work,并提示用户重新运行 `speculo init` 刷新受 Speculo 管理的状态;不得在 Work 内迁移、兼容或猜测旧状态。只有状态不存在时才从 schema v4 模板创建。
86
+ 若全局状态已存在,先检查 `schema_version`。版本未知、JSON 不可解析或状态与当前 workflow 契约不一致时,停止当前 Work,并提示用户运行 `speculo init` 建立备份与 pending marker,再运行 `migrate-runtime-state` command 对账修复;不得在 Work 内迁移、兼容或猜测旧状态。只有状态不存在时才从 schema v4 模板创建。
87
87
 
88
88
  从模板生成:
89
89
 
@@ -70,6 +70,8 @@ Archive 归档历史并将经验证知识提升为当前长期知识
70
70
  - 活跃 change:`<Path>{roots.state}/specdev/changes/</Path>`
71
71
  - 历史归档:`<Path>{roots.state}/specdev/archive/</Path>`
72
72
 
73
+ 刷新时 CLI 在 `<Path>{roots.state}/back/</Path>` 保留最近一次旧配置与完整 runtime state,并对 v0.7+ 状态执行兼容迁移。若 `<Path>{roots.state}/migration.json</Path>` 存在且为 pending,所有 SpecDev Works 必须在读取 workflow state 前停止;只有 `<Path>{roots.commands}/migrate-runtime-state.md</Path>` 可以读取备份并在用户确认后修复。`back/`、`install.json` 与 `migration.json` 均不属于 SpecDev 写入 namespace。
74
+
73
75
  初始化设置 work 首次运行时生成配置并创建空的永久 namespace:
74
76
 
75
77
  - 全局配置:`<Path>{roots.state}/specdev/config.json</Path>`
@@ -136,11 +138,12 @@ Archive 归档历史并将经验证知识提升为当前长期知识
136
138
  ## 启动协议
137
139
 
138
140
  1. 解析 workflow 和 state roots。
139
- 2. 读取 `<Path>{roots.state}/specdev/config.json</Path>`;不存在时运行 `<Path>{roots.workflows}/specdev/I-init-setup/I-init-setup.md</Path>`。
140
- 3. 读取 `<Path>{roots.state}/specdev/status.json</Path>`:用户指定 change 优先;唯一活跃 change 直接使用;无活跃时创建;多个候选时请求消歧。
141
- 4. 若当前 change 已有非空 `current_work`,先恢复或显式结束该 Work;否则将 `current_work` 设置为本次 work id。
142
- 5. 只加载当前步骤需要的 work 子文件和共享规则。
143
- 6. 完成后写入产物、运行适用校验、更新状态和 `works_run`。
141
+ 2. 检查 `<Path>{roots.state}/migration.json</Path>`;存在且 `status: pending` 时停止,不读取或写入 SpecDev state,并路由到 `<Path>{roots.commands}/migrate-runtime-state.md</Path>`。
142
+ 3. 读取 `<Path>{roots.state}/specdev/config.json</Path>`;不存在时运行 `<Path>{roots.workflows}/specdev/I-init-setup/I-init-setup.md</Path>`。
143
+ 4. 读取 `<Path>{roots.state}/specdev/status.json</Path>`:用户指定 change 优先;唯一活跃 change 直接使用;无活跃时创建;多个候选时请求消歧。
144
+ 5. 若当前 change 已有非空 `current_work`,先恢复或显式结束该 Work;否则将 `current_work` 设置为本次 work id。
145
+ 6. 只加载当前步骤需要的 work 子文件和共享规则。
146
+ 7. 完成后写入产物、运行适用校验、更新状态和 `works_run`。
144
147
 
145
148
  Change 从 active/blocked 转为 completed 时加载 `<Path>{roots.workflows}/specdev/common/rules/change-completion.md</Path>`:Goal Plan 含完整委派附录时由 Lead 拥有转换;普通 Goal Plan 或无 Goal Plan 的实现由最后一个 I 拥有;非实现型终点由最终验收工件 owner 拥有。Archive 不补造 completed。
146
149