@namewta/speculo 0.7.6 → 0.8.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.
Files changed (42) hide show
  1. package/README.md +5 -6
  2. package/dist/src/cli.js +18 -13
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/config.d.ts +20 -0
  5. package/dist/src/config.js +94 -0
  6. package/dist/src/config.js.map +1 -0
  7. package/dist/src/index.d.ts +3 -2
  8. package/dist/src/index.js +56 -27
  9. package/dist/src/index.js.map +1 -1
  10. package/dist/src/manifest.d.ts +20 -0
  11. package/dist/src/manifest.js +57 -0
  12. package/dist/src/manifest.js.map +1 -0
  13. package/dist/src/refresh.d.ts +30 -0
  14. package/dist/src/refresh.js +465 -0
  15. package/dist/src/refresh.js.map +1 -0
  16. package/dist/src/structured.d.ts +12 -0
  17. package/dist/src/structured.js +236 -0
  18. package/dist/src/structured.js.map +1 -0
  19. package/package.json +2 -2
  20. package/template/.speculo/README.md +12 -11
  21. package/template/.speculo/refresh-contract.json +30 -0
  22. package/template/canonical/canonical-specdev-engineering-cognitive-mentor.md +1 -0
  23. package/template/canonical/canonical-specdev-goal-plan.md +1 -0
  24. package/template/canonical/canonical-specdev-grill-with-docs.md +1 -0
  25. package/template/canonical/canonical-specdev-spec.md +1 -0
  26. package/template/canonical/canonical-specdev-tickets.md +1 -0
  27. package/template/skills/github-npm-ops/references/preflight-checklist.md +1 -1
  28. package/template/workflows/person/runtime-contract.json +9 -0
  29. package/template/workflows/specdev/E-eli5/E-eli5.md +35 -0
  30. package/template/workflows/specdev/I-init-setup/I-init-setup.md +1 -1
  31. package/template/workflows/specdev/INDEX.md +15 -12
  32. package/template/workflows/specdev/common/rules/artifact-contract.md +1 -0
  33. package/template/workflows/specdev/common/tools/README.md +1 -1
  34. package/template/workflows/specdev/common/tools/validate-specdev.mjs +28 -0
  35. package/template/workflows/specdev/runtime-contract.json +26 -0
  36. package/dist/src/migrations.d.ts +0 -23
  37. package/dist/src/migrations.js +0 -1202
  38. package/dist/src/migrations.js.map +0 -1
  39. package/template/commands/migrate-runtime-state.md +0 -43
  40. package/template/skills/migrate-runtime-state/SKILL.md +0 -93
  41. package/template/skills/migrate-runtime-state/references/migration-contract.md +0 -64
  42. package/template/skills/migrate-runtime-state/scripts/migrate-runtime-state.mjs +0 -916
@@ -1,1202 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { cp, lstat, mkdir, readFile, readdir, readlink, rm, writeFile, } from "node:fs/promises";
3
- import { dirname, join, relative, sep } from "node:path";
4
- import { pathExists } from "./utils.js";
5
- const BACKUP_RELATIVE = ".speculo/back";
6
- const INSTALL_RELATIVE = ".speculo/install.json";
7
- const MIGRATION_RELATIVE = ".speculo/migration.json";
8
- const SNAPSHOT_DIR = ".migration-snapshot";
9
- const CONFIG_SCHEMA_VERSION = 5;
10
- const GOAL_PLAN_SCHEMA_VERSION = 6;
11
- const CHANGE_STATUS_SCHEMA_VERSION = 6;
12
- const MANAGED_STATE_ENTRIES = new Set([
13
- "README.md",
14
- "workspace.json",
15
- "install.json",
16
- "migration.json",
17
- "back",
18
- ]);
19
- function toPosix(path) {
20
- return path.split(sep).join("/");
21
- }
22
- async function readJson(path) {
23
- return JSON.parse(await readFile(path, "utf8"));
24
- }
25
- async function nodeExists(path) {
26
- try {
27
- await lstat(path);
28
- return true;
29
- }
30
- catch {
31
- return false;
32
- }
33
- }
34
- async function readTargetVersion(packageRoot) {
35
- const manifest = await readJson(join(packageRoot, "package.json"));
36
- if (typeof manifest.version !== "string" || !/^\d+\.\d+\.\d+(?:[-+].+)?$/.test(manifest.version)) {
37
- throw new Error("package.json has no valid semantic version");
38
- }
39
- return manifest.version;
40
- }
41
- function versionBefore07(version) {
42
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
43
- if (!match)
44
- return true;
45
- const major = Number(match[1]);
46
- const minor = Number(match[2]);
47
- return major === 0 && minor < 7;
48
- }
49
- async function sourceVersion(snapshotState) {
50
- const installPath = join(snapshotState, "install.json");
51
- if (!(await pathExists(installPath)))
52
- return { version: "0.7.0-unversioned", blockers: [] };
53
- try {
54
- const install = await readJson(installPath);
55
- if (install.schema_version !== 1 || typeof install.package_version !== "string") {
56
- return {
57
- version: null,
58
- blockers: [{ code: "invalid-install-manifest", path: ".speculo/install.json", message: "install manifest must use schema v1 and contain package_version" }],
59
- };
60
- }
61
- return { version: install.package_version, blockers: [] };
62
- }
63
- catch (error) {
64
- return {
65
- version: null,
66
- blockers: [{ code: "invalid-json", path: ".speculo/install.json", message: String(error) }],
67
- };
68
- }
69
- }
70
- async function copySnapshot(previousRoot, snapshotRoot) {
71
- await mkdir(snapshotRoot, { recursive: true });
72
- const configPath = join(previousRoot, "config.json");
73
- if (await nodeExists(configPath))
74
- await cp(configPath, join(snapshotRoot, "config.json"), { force: true, verbatimSymlinks: true });
75
- const previousState = join(previousRoot, ".speculo");
76
- if (!(await nodeExists(previousState)))
77
- return;
78
- await cp(previousState, join(snapshotRoot, "state"), {
79
- recursive: true,
80
- force: true,
81
- verbatimSymlinks: true,
82
- filter: (source) => {
83
- const item = toPosix(relative(previousState, source));
84
- return item !== "back" && !item.startsWith("back/") && item !== "migration.json";
85
- },
86
- });
87
- }
88
- async function collectBackupEntries(root, current = root) {
89
- if (!(await pathExists(current)))
90
- return [];
91
- const entries = [];
92
- for (const entry of await readdir(current, { withFileTypes: true })) {
93
- const path = join(current, entry.name);
94
- if (entry.isDirectory()) {
95
- entries.push(...await collectBackupEntries(root, path));
96
- continue;
97
- }
98
- const itemPath = toPosix(relative(root, path));
99
- if (entry.isSymbolicLink()) {
100
- entries.push({ path: itemPath, type: "symlink", target: await readlink(path) });
101
- continue;
102
- }
103
- if (!entry.isFile())
104
- continue;
105
- const content = await readFile(path);
106
- entries.push({
107
- path: itemPath,
108
- type: "file",
109
- bytes: content.byteLength,
110
- sha256: createHash("sha256").update(content).digest("hex"),
111
- });
112
- }
113
- return entries.sort((left, right) => left.path.localeCompare(right.path));
114
- }
115
- async function writeBackup(snapshotRoot, stagedRoot, source, target) {
116
- const backupRoot = join(stagedRoot, BACKUP_RELATIVE);
117
- await rm(backupRoot, { recursive: true, force: true });
118
- await mkdir(backupRoot, { recursive: true });
119
- const snapshotConfig = join(snapshotRoot, "config.json");
120
- const snapshotState = join(snapshotRoot, "state");
121
- if (await nodeExists(snapshotConfig))
122
- await cp(snapshotConfig, join(backupRoot, "config.json"), { force: true, verbatimSymlinks: true });
123
- if (await nodeExists(snapshotState))
124
- await cp(snapshotState, join(backupRoot, "state"), { recursive: true, force: true, verbatimSymlinks: true });
125
- const files = await collectBackupEntries(backupRoot);
126
- await writeFile(join(backupRoot, "manifest.json"), JSON.stringify({
127
- schema_version: 1,
128
- source_version: source,
129
- target_version: target,
130
- created_at: new Date().toISOString(),
131
- files,
132
- }, null, 2) + "\n", "utf8");
133
- }
134
- async function inspectJsonTree(root) {
135
- if (!(await pathExists(root)))
136
- return [];
137
- const blockers = [];
138
- async function visit(current) {
139
- for (const entry of await readdir(current, { withFileTypes: true })) {
140
- const path = join(current, entry.name);
141
- const label = toPosix(relative(dirname(root), path));
142
- if (entry.isDirectory()) {
143
- await visit(path);
144
- }
145
- else if (entry.isSymbolicLink()) {
146
- blockers.push({ code: "state-symlink", path: label, message: "runtime state symlinks require manual migration review" });
147
- }
148
- else if (entry.isFile() && entry.name.endsWith(".json")) {
149
- try {
150
- JSON.parse(await readFile(path, "utf8"));
151
- }
152
- catch (error) {
153
- blockers.push({ code: "invalid-json", path: label, message: String(error) });
154
- }
155
- }
156
- }
157
- }
158
- await visit(root);
159
- return blockers;
160
- }
161
- function expectArray(value) {
162
- return Array.isArray(value);
163
- }
164
- function isJsonObject(value) {
165
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
166
- }
167
- function hasExactKeys(value, expected) {
168
- const actual = Object.keys(value).sort();
169
- const wanted = [...expected].sort();
170
- return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
171
- }
172
- function isNonEmptyString(value) {
173
- return typeof value === "string" && value.length > 0;
174
- }
175
- function isStringOrNull(value) {
176
- return value === null || typeof value === "string";
177
- }
178
- function isStringArray(value) {
179
- return Array.isArray(value) && value.every((item) => typeof item === "string");
180
- }
181
- const CHANGE_NAME_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
182
- const ARCHIVE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/archive\/[^<]+<\/Path>$/;
183
- const EVIDENCE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/changes\/[^<]+\/evidence\/T-[0-9]{2,}\.md<\/Path>$/;
184
- function isChangeName(value) {
185
- return typeof value === "string" && CHANGE_NAME_PATTERN.test(value);
186
- }
187
- function hasCompleteV4Integration(integration, worktreeStatus, sourceCheckpoint, change, ticketId) {
188
- const required = [
189
- "status", "parent_before_sha", "source_sha", "candidate_sha", "candidate_branch",
190
- "candidate_workspace_ref", "result_sha", "method", "conflict_paths", "verification",
191
- "e2e", "evidence", "attempts",
192
- ];
193
- if (!hasExactKeys(integration, required))
194
- return false;
195
- if (!new Set(["pending", "candidate", "passed", "failed", "stale"]).has(String(integration.status)))
196
- return false;
197
- if (![null, "direct-parent", "fast-forward", "merge-commit"].includes(integration.method))
198
- return false;
199
- if (!new Set(["pending", "passed", "failed"]).has(String(integration.verification)))
200
- return false;
201
- for (const key of ["parent_before_sha", "source_sha", "candidate_sha", "candidate_branch", "result_sha"]) {
202
- if (!isStringOrNull(integration[key]))
203
- return false;
204
- }
205
- if (integration.candidate_workspace_ref !== null &&
206
- (typeof integration.candidate_workspace_ref !== "string" ||
207
- !/^specdev-worktree\/\.integration\/T-[0-9]{2,}$/.test(integration.candidate_workspace_ref)))
208
- return false;
209
- if (!isStringArray(integration.conflict_paths))
210
- return false;
211
- if (!Number.isInteger(integration.attempts) || Number(integration.attempts) < 0)
212
- return false;
213
- if (typeof integration.evidence !== "string" ||
214
- !EVIDENCE_PATH_PATTERN.test(integration.evidence) ||
215
- integration.evidence !== `<Path>{roots.state}/specdev/changes/${change}/evidence/${ticketId}.md</Path>`)
216
- return false;
217
- const e2e = integration.e2e;
218
- if (!isJsonObject(e2e) || !hasExactKeys(e2e, ["required", "status", "evidence"]))
219
- return false;
220
- if (typeof e2e.required !== "boolean" || !new Set(["not-required", "pending", "passed", "failed"]).has(String(e2e.status)))
221
- return false;
222
- if (!isStringOrNull(e2e.evidence))
223
- return false;
224
- if (e2e.required === false && e2e.status !== "not-required")
225
- return false;
226
- if (e2e.required === true && e2e.status === "not-required")
227
- return false;
228
- if (e2e.required === true && e2e.status === "passed" && !isNonEmptyString(e2e.evidence))
229
- return false;
230
- const currentWorkspace = integration.method === "direct-parent";
231
- const lifecycleNeedsCandidate = new Set(["integrating", "integrated", "removed"]).has(String(worktreeStatus));
232
- if (lifecycleNeedsCandidate) {
233
- if (!isNonEmptyString(integration.parent_before_sha) ||
234
- !isNonEmptyString(integration.source_sha) ||
235
- integration.source_sha !== sourceCheckpoint ||
236
- (currentWorkspace
237
- ? integration.candidate_sha !== null || integration.candidate_branch !== null || integration.candidate_workspace_ref !== null || integration.method !== "direct-parent"
238
- : !isNonEmptyString(integration.candidate_sha) ||
239
- integration.candidate_branch !== `speculo/integration/${change}/${ticketId}` ||
240
- integration.candidate_workspace_ref !== `specdev-worktree/.integration/${ticketId}` ||
241
- !new Set(["fast-forward", "merge-commit"]).has(String(integration.method))) ||
242
- !Number.isInteger(integration.attempts) ||
243
- Number(integration.attempts) < 1)
244
- return false;
245
- }
246
- if (worktreeStatus === "integrating" && integration.status !== "candidate")
247
- return false;
248
- if (new Set(["integrated", "removed"]).has(String(worktreeStatus))) {
249
- if (integration.status !== "passed" ||
250
- integration.verification !== "passed" ||
251
- !isNonEmptyString(integration.result_sha) ||
252
- integration.result_sha !== (currentWorkspace ? integration.source_sha : integration.candidate_sha) ||
253
- !new Set(["not-required", "passed"]).has(String(e2e.status)))
254
- return false;
255
- if (integration.method === "fast-forward" &&
256
- (integration.candidate_sha !== sourceCheckpoint || integration.conflict_paths.length > 0))
257
- return false;
258
- if (integration.method === "merge-commit" &&
259
- (integration.candidate_sha === sourceCheckpoint || integration.candidate_sha === integration.parent_before_sha))
260
- return false;
261
- }
262
- return true;
263
- }
264
- function hasLegacyWorktreeState(status) {
265
- return status.schema_version === 3 &&
266
- status.worktrees !== undefined &&
267
- (!Array.isArray(status.worktrees) || status.worktrees.length > 0);
268
- }
269
- function hasUnsupportedV3ChangeStatusFields(status) {
270
- const allowed = new Set([
271
- "schema_version", "artifact", "change", "change_status", "current_work",
272
- "created_at", "updated_at", "completed_at", "archived", "archive_path",
273
- "blockers", "deviations", "worktrees",
274
- ]);
275
- return status.schema_version === 3 && Object.keys(status).some((key) => !allowed.has(key));
276
- }
277
- function hasUnsupportedV3SpecdevConfigFields(config) {
278
- if (config.schema_version !== 3)
279
- return false;
280
- const rootAllowed = new Set([
281
- "schema_version", "interaction_language", "artifact_language", "git",
282
- "execution", "verification", "planning",
283
- ]);
284
- if (Object.keys(config).some((key) => !rootAllowed.has(key)))
285
- return true;
286
- const git = isJsonObject(config.git) ? config.git : {};
287
- const execution = isJsonObject(config.execution) ? config.execution : {};
288
- return Object.keys(git).some((key) => !new Set(["auto_commit", "default_branch", "worktree_for_parallel"]).has(key)) ||
289
- Object.keys(execution).some((key) => !new Set(["max_parallel", "deep_ticket_human_approval", "shared_path_owner"]).has(key));
290
- }
291
- function hasCompleteV4ChangeStatus(status) {
292
- const required = [
293
- "schema_version", "artifact", "change", "change_status", "current_work", "created_at",
294
- "updated_at", "completed_at", "archived", "archive_path", "blockers", "deviations", "worktrees",
295
- ];
296
- if (status.schema_version !== 4 ||
297
- status.artifact !== "change-status" ||
298
- !hasExactKeys(status, required) ||
299
- typeof status.change !== "string" ||
300
- !CHANGE_NAME_PATTERN.test(status.change) ||
301
- !new Set(["active", "blocked", "completed", "archived"]).has(String(status.change_status)) ||
302
- !(status.current_work === null || typeof status.current_work === "string") ||
303
- !isNonEmptyString(status.created_at) ||
304
- !isNonEmptyString(status.updated_at) ||
305
- !(status.completed_at === null || isNonEmptyString(status.completed_at)) ||
306
- typeof status.archived !== "boolean" ||
307
- !(status.archive_path === null || (typeof status.archive_path === "string" && ARCHIVE_PATH_PATTERN.test(status.archive_path))) ||
308
- !isStringArray(status.blockers) ||
309
- !isStringArray(status.deviations) ||
310
- !Array.isArray(status.worktrees))
311
- return false;
312
- if (status.change_status === "archived") {
313
- if (status.archived !== true || typeof status.archive_path !== "string" || !ARCHIVE_PATH_PATTERN.test(status.archive_path))
314
- return false;
315
- }
316
- else if (status.archived !== false) {
317
- return false;
318
- }
319
- const seenTickets = new Set();
320
- return status.worktrees.every((entry) => {
321
- const requiredWorktree = [
322
- "ticket_id", "owner", "implementation_owner", "integration_owner", "provider", "base_sha",
323
- "parent_branch", "branch", "workspace_ref", "source_checkpoint", "integration", "status", "updated_at",
324
- ];
325
- if (!isJsonObject(entry) || !hasExactKeys(entry, requiredWorktree) || entry.provider !== "git")
326
- return false;
327
- if (typeof entry.ticket_id !== "string" || !/^T-[0-9]{2,}$/.test(entry.ticket_id))
328
- return false;
329
- if (seenTickets.has(entry.ticket_id))
330
- return false;
331
- seenTickets.add(entry.ticket_id);
332
- for (const key of ["owner", "implementation_owner", "integration_owner", "base_sha", "parent_branch", "branch", "updated_at"]) {
333
- if (!isNonEmptyString(entry[key]))
334
- return false;
335
- }
336
- const integration = isJsonObject(entry.integration) ? entry.integration : {};
337
- const currentWorkspace = entry.workspace_ref === "current" || integration.method === "direct-parent";
338
- if (currentWorkspace ? entry.parent_branch !== entry.branch : entry.parent_branch === entry.branch)
339
- return false;
340
- if (!currentWorkspace && entry.workspace_ref !== `specdev-worktree/${entry.ticket_id}`)
341
- return false;
342
- if (!new Set(["planned", "active", "review", "integrating", "integrated", "removed", "blocked"]).has(String(entry.status)))
343
- return false;
344
- const sourceRequired = new Set(["review", "integrating", "integrated", "removed"]).has(String(entry.status));
345
- if (sourceRequired ? !isNonEmptyString(entry.source_checkpoint) : !isStringOrNull(entry.source_checkpoint))
346
- return false;
347
- return isJsonObject(integration) && hasCompleteV4Integration(integration, entry.status, entry.source_checkpoint, String(status.change), entry.ticket_id);
348
- });
349
- }
350
- function hasCompleteV4SpecdevConfig(config) {
351
- const rootKeys = ["schema_version", "interaction_language", "artifact_language", "git", "execution", "verification", "planning"];
352
- if (config.schema_version !== 4 ||
353
- !hasExactKeys(config, rootKeys) ||
354
- !isNonEmptyString(config.interaction_language) ||
355
- !isNonEmptyString(config.artifact_language) ||
356
- !isJsonObject(config.git) ||
357
- !isJsonObject(config.execution) ||
358
- !isJsonObject(config.verification) ||
359
- !isJsonObject(config.planning)) {
360
- return false;
361
- }
362
- if (!hasExactKeys(config.git, ["default_branch"]) || !(config.git.default_branch === null || typeof config.git.default_branch === "string"))
363
- return false;
364
- if (!hasExactKeys(config.execution, ["max_implementation_agents", "deep_ticket_human_approval", "shared_path_owner"]))
365
- return false;
366
- const limit = config.execution.max_implementation_agents;
367
- if (!Number.isInteger(limit) || Number(limit) < 1 ||
368
- typeof config.execution.deep_ticket_human_approval !== "boolean" ||
369
- !isNonEmptyString(config.execution.shared_path_owner))
370
- return false;
371
- for (const key of ["test", "typecheck", "lint", "build"]) {
372
- if (!(key in config.verification) || !isStringOrNull(config.verification[key]))
373
- return false;
374
- }
375
- return new Set(["lite", "standard", "deep"]).has(String(config.planning.default_depth)) &&
376
- typeof config.planning.require_ready_gate === "boolean" &&
377
- typeof config.planning.require_evidence === "boolean";
378
- }
379
- function hasCompleteV5SpecdevConfig(config) {
380
- const rootKeys = ["schema_version", "interaction_language", "artifact_language", "git", "execution", "verification", "planning"];
381
- if (config.schema_version !== CONFIG_SCHEMA_VERSION || !hasExactKeys(config, rootKeys) ||
382
- !isNonEmptyString(config.interaction_language) || !isNonEmptyString(config.artifact_language) ||
383
- !isJsonObject(config.git) || !isJsonObject(config.execution) || !isJsonObject(config.verification) || !isJsonObject(config.planning))
384
- return false;
385
- if (!hasExactKeys(config.git, ["default_branch"]) || !(config.git.default_branch === null || typeof config.git.default_branch === "string"))
386
- return false;
387
- if (!hasExactKeys(config.execution, ["max_implementation_agents", "max_integration_attempts", "deep_ticket_human_approval", "shared_path_owner"]))
388
- return false;
389
- if (!Number.isInteger(config.execution.max_implementation_agents) || Number(config.execution.max_implementation_agents) < 1 ||
390
- !Number.isInteger(config.execution.max_integration_attempts) || Number(config.execution.max_integration_attempts) < 1 ||
391
- typeof config.execution.deep_ticket_human_approval !== "boolean" || !isNonEmptyString(config.execution.shared_path_owner))
392
- return false;
393
- for (const key of ["test", "typecheck", "lint", "build"]) {
394
- if (!(key in config.verification) || !isStringOrNull(config.verification[key]))
395
- return false;
396
- }
397
- return new Set(["lite", "standard", "deep"]).has(String(config.planning.default_depth)) &&
398
- typeof config.planning.require_ready_gate === "boolean" &&
399
- typeof config.planning.require_evidence === "boolean" &&
400
- Number.isInteger(config.planning.ui_prototype_default_variants) && Number(config.planning.ui_prototype_default_variants) >= 1 &&
401
- Number.isInteger(config.planning.ui_prototype_max_variants) && Number(config.planning.ui_prototype_max_variants) >= Number(config.planning.ui_prototype_default_variants);
402
- }
403
- function parseGoalPlanFrontmatter(text) {
404
- const lines = text.split(/\r?\n/);
405
- if (lines[0]?.trim() !== "---")
406
- return null;
407
- const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
408
- if (end < 0)
409
- return null;
410
- const meta = {};
411
- let currentListKey = null;
412
- for (const line of lines.slice(1, end)) {
413
- const trimmed = line.trim();
414
- if (!trimmed || trimmed.startsWith("#"))
415
- continue;
416
- if (currentListKey && /^\s+-\s+/.test(line)) {
417
- meta[currentListKey].push(parseGoalPlanScalar(line.replace(/^\s+-\s+/, "")));
418
- continue;
419
- }
420
- currentListKey = null;
421
- const colon = line.indexOf(":");
422
- if (colon < 1)
423
- return null;
424
- const key = line.slice(0, colon).trim();
425
- if (key in meta)
426
- return null;
427
- const raw = line.slice(colon + 1).trim();
428
- if (!raw) {
429
- meta[key] = [];
430
- currentListKey = key;
431
- }
432
- else {
433
- meta[key] = parseGoalPlanScalar(raw);
434
- }
435
- }
436
- return meta;
437
- }
438
- function parseGoalPlanScalar(raw) {
439
- const value = raw.trim();
440
- if (value === "true")
441
- return true;
442
- if (value === "false")
443
- return false;
444
- if (/^-?\d+$/.test(value))
445
- return Number(value);
446
- if (value.startsWith("[") && value.endsWith("]")) {
447
- const inner = value.slice(1, -1).trim();
448
- return inner ? inner.split(",").map((item) => parseGoalPlanScalar(item)) : [];
449
- }
450
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
451
- return value.slice(1, -1);
452
- }
453
- return value;
454
- }
455
- function hasCompleteV5GoalPlan(meta, change) {
456
- if (meta.schema_version !== 5)
457
- return false;
458
- const v4Shape = { ...meta, schema_version: 4 };
459
- if (!hasCompleteV4GoalPlan(v4Shape, change))
460
- return false;
461
- const status = String(meta.status);
462
- return (new Set(["ready", "in_progress"]).has(status) && meta.ready_for_execution === true) ||
463
- (new Set(["draft", "blocked", "completed"]).has(status) && meta.ready_for_execution === false);
464
- }
465
- function hasCompleteV6GoalPlan(meta, change) {
466
- const required = [
467
- "schema_version", "artifact", "change", "status", "modes", "orchestration", "lead",
468
- "implementation_agent_limit", "integration_attempt_limit", "ticket_workspace_policy", "integration_gate", "ready_for_execution",
469
- ];
470
- if (!hasExactKeys(meta, required) || meta.schema_version !== GOAL_PLAN_SCHEMA_VERSION)
471
- return false;
472
- const { integration_attempt_limit: integrationAttemptLimit, ...previous } = meta;
473
- return hasCompleteV5GoalPlan({ ...previous, schema_version: 5 }, change) &&
474
- Number.isInteger(integrationAttemptLimit) && Number(integrationAttemptLimit) >= 1;
475
- }
476
- function normalizeGoalPlanV5(text) {
477
- const meta = parseGoalPlanFrontmatter(text);
478
- if (meta === null || !hasCompleteV4GoalPlan(meta, String(meta.change)))
479
- return text;
480
- return text.replace(/^schema_version:\s*4\s*$/m, "schema_version: 5");
481
- }
482
- function normalizeGoalPlanV6(text, config) {
483
- const meta = parseGoalPlanFrontmatter(text);
484
- if (meta === null || meta.schema_version !== 5 || !hasCompleteV5GoalPlan(meta, String(meta.change)))
485
- return text;
486
- const execution = isJsonObject(config.execution) ? config.execution : {};
487
- const attempts = Number.isInteger(execution.max_integration_attempts) && Number(execution.max_integration_attempts) >= 1
488
- ? Number(execution.max_integration_attempts)
489
- : 3;
490
- return text
491
- .replace(/^schema_version:\s*5\s*$/m, "schema_version: 6")
492
- .replace(/^(implementation_agent_limit:\s*[^\n]+)$/m, `$1\nintegration_attempt_limit: ${attempts}`);
493
- }
494
- function v5Authorization(scope) {
495
- return { status: "not-authorized", source: null, granted_at: null, scope };
496
- }
497
- function normalizeChangeStatusV5(previous, globalEntry) {
498
- if (previous.schema_version === 5)
499
- return previous;
500
- const change = String(previous.change);
501
- const worktrees = Array.isArray(previous.worktrees) ? previous.worktrees.map((entry) => {
502
- if (!isJsonObject(entry))
503
- return entry;
504
- const ticketId = String(entry.ticket_id);
505
- const integration = isJsonObject(entry.integration) ? entry.integration : {};
506
- const sourceCheckpoint = isStringOrNull(entry.source_checkpoint) ? entry.source_checkpoint : null;
507
- return {
508
- ...entry,
509
- workspace_ref: entry.workspace_ref === "current" || integration.method === "direct-parent"
510
- ? "current"
511
- : `specdev-worktree/${change}/${ticketId}`,
512
- branch: entry.workspace_ref === "current" || integration.method === "direct-parent"
513
- ? entry.parent_branch
514
- : entry.branch,
515
- integration: {
516
- ...integration,
517
- parent_ref: typeof entry.parent_branch === "string" ? entry.parent_branch : null,
518
- candidate_sha: entry.workspace_ref === "current" || integration.method === "direct-parent" ? null : integration.candidate_sha ?? null,
519
- candidate_tree_sha: entry.workspace_ref === "current" || integration.method === "direct-parent" ? null : integration.candidate_sha ?? null,
520
- candidate_branch: entry.workspace_ref === "current" || integration.method === "direct-parent" ? null : integration.candidate_branch ?? null,
521
- candidate_workspace_ref: entry.workspace_ref === "current" || integration.method === "direct-parent"
522
- ? null
523
- : integration.candidate_workspace_ref === null ? null : `specdev-worktree/.integration/${change}/${ticketId}`,
524
- full_suite: { required: true, status: "pending", reason: null, evidence: null },
525
- promotion_status: integration.status === "passed" ? "applied" : "pending",
526
- source_sha: integration.source_sha ?? sourceCheckpoint,
527
- },
528
- };
529
- }) : [];
530
- const status = String(previous.change_status);
531
- return {
532
- ...previous,
533
- schema_version: 5,
534
- current_work: typeof previous.current_work === "string" ? previous.current_work : globalEntry?.current_work ?? null,
535
- works_run: Array.isArray(globalEntry?.works_run) && globalEntry.works_run.every((item) => typeof item === "string") ? globalEntry.works_run : [],
536
- claimed_investigations: Array.isArray(globalEntry?.claimed_investigations) ? globalEntry.claimed_investigations.map((claim) => isJsonObject(claim) ? {
537
- id: String(claim.id ?? ""), owner: String(claim.owner ?? ""), session: typeof claim.session === "string" ? claim.session : null, claimed_at: String(claim.claimed_at ?? ""),
538
- } : claim) : [],
539
- execution_authorization: {
540
- implementation_commit: v5Authorization("Ticket implementation commits"),
541
- local_candidate_integration: v5Authorization("Lead-owned local direct-parent or candidate integration and parent update"),
542
- source_cleanup: v5Authorization("Source worktree and branch cleanup"),
543
- },
544
- leadership: { current: "unassigned", epoch: 1, assigned_at: String(previous.updated_at), history: [] },
545
- archived: status === "archived",
546
- archive_path: status === "archived" ? `<Path>{roots.state}/specdev/archive/${change.slice(0, 7)}/${change}</Path>` : null,
547
- worktrees,
548
- };
549
- }
550
- function hasCompleteV5ChangeStatus(status) {
551
- const required = [
552
- "schema_version", "artifact", "change", "change_status", "current_work", "works_run", "claimed_investigations",
553
- "execution_authorization", "leadership", "created_at", "updated_at", "completed_at", "archived", "archive_path", "blockers", "deviations", "worktrees",
554
- ];
555
- if (status.schema_version !== 5 || !hasExactKeys(status, required) || !isChangeName(status.change) ||
556
- !isStringArray(status.works_run) || new Set(status.works_run).size !== status.works_run.length ||
557
- !Array.isArray(status.claimed_investigations) || !isJsonObject(status.execution_authorization) || !isJsonObject(status.leadership))
558
- return false;
559
- const authorization = status.execution_authorization;
560
- for (const key of ["implementation_commit", "local_candidate_integration", "source_cleanup"]) {
561
- const entry = authorization[key];
562
- if (!isJsonObject(entry) || !hasExactKeys(entry, ["status", "source", "granted_at", "scope"]) ||
563
- !new Set(["authorized", "not-authorized", "revoked"]).has(String(entry.status)) || !isNonEmptyString(entry.scope) ||
564
- !isStringOrNull(entry.source) || !isStringOrNull(entry.granted_at))
565
- return false;
566
- }
567
- const leadership = status.leadership;
568
- if (!isNonEmptyString(leadership.current) || !Number.isInteger(leadership.epoch) || Number(leadership.epoch) < 1 ||
569
- !isNonEmptyString(leadership.assigned_at) || !Array.isArray(leadership.history) || !Array.isArray(status.worktrees))
570
- return false;
571
- const change = String(status.change);
572
- const worktreeKeys = [
573
- "ticket_id", "owner", "implementation_owner", "integration_owner", "provider", "base_sha",
574
- "parent_branch", "branch", "workspace_ref", "source_checkpoint", "integration", "status", "updated_at",
575
- ];
576
- const integrationKeys = [
577
- "status", "parent_ref", "parent_before_sha", "source_sha", "candidate_sha", "candidate_tree_sha",
578
- "candidate_branch", "candidate_workspace_ref", "result_sha", "method", "conflict_paths", "verification",
579
- "full_suite", "e2e", "evidence", "attempts", "promotion_status",
580
- ];
581
- return status.worktrees.every((entry) => {
582
- if (!isJsonObject(entry) || !hasExactKeys(entry, worktreeKeys) || entry.provider !== "git" ||
583
- typeof entry.ticket_id !== "string" || !/^T-[0-9]{2,}$/.test(entry.ticket_id) || !isJsonObject(entry.integration))
584
- return false;
585
- const current = entry.workspace_ref === "current";
586
- if (current ? entry.branch !== entry.parent_branch : entry.workspace_ref !== `specdev-worktree/${change}/${entry.ticket_id}` || entry.branch === entry.parent_branch)
587
- return false;
588
- const integration = entry.integration;
589
- if (!hasExactKeys(integration, integrationKeys) || !Number.isInteger(integration.attempts) || Number(integration.attempts) < 0 ||
590
- !isStringArray(integration.conflict_paths) || !isNonEmptyString(integration.evidence))
591
- return false;
592
- for (const key of ["full_suite", "e2e"]) {
593
- const suite = integration[key];
594
- if (!isJsonObject(suite) || !hasExactKeys(suite, ["required", "status", "reason", "evidence"]) || typeof suite.required !== "boolean")
595
- return false;
596
- }
597
- return true;
598
- });
599
- }
600
- function hasCompleteV6ChangeStatus(status) {
601
- if (status.schema_version !== CHANGE_STATUS_SCHEMA_VERSION)
602
- return false;
603
- const previous = { ...status, schema_version: 5 };
604
- if (!hasCompleteV5ChangeStatus(previous) || !Array.isArray(status.worktrees))
605
- return false;
606
- return status.worktrees.every((entry) => {
607
- if (!isJsonObject(entry) || !isJsonObject(entry.integration))
608
- return false;
609
- const current = entry.workspace_ref === "current";
610
- if (current && entry.parent_branch !== entry.branch)
611
- return false;
612
- if (!current && entry.parent_branch === entry.branch)
613
- return false;
614
- const integration = entry.integration;
615
- if (current && ["candidate_sha", "candidate_tree_sha", "candidate_branch", "candidate_workspace_ref"].some((key) => integration[key] !== null))
616
- return false;
617
- if (current && integration.method !== null && integration.method !== "direct-parent")
618
- return false;
619
- if (!current && integration.method === "direct-parent")
620
- return false;
621
- return true;
622
- });
623
- }
624
- function hasCompleteV4GoalPlan(meta, change) {
625
- const required = [
626
- "schema_version", "artifact", "change", "status", "modes", "orchestration", "lead",
627
- "implementation_agent_limit", "ticket_workspace_policy", "integration_gate", "ready_for_execution",
628
- ];
629
- if (!hasExactKeys(meta, required))
630
- return false;
631
- if (meta.schema_version !== 4 ||
632
- meta.artifact !== "goal-plan" ||
633
- meta.change !== change ||
634
- !new Set(["draft", "ready", "in_progress", "completed", "blocked"]).has(String(meta.status)) ||
635
- meta.orchestration !== "lead-directed" ||
636
- !isNonEmptyString(meta.lead) ||
637
- !Number.isInteger(meta.implementation_agent_limit) ||
638
- Number(meta.implementation_agent_limit) < 1 ||
639
- !new Set(["current", "required"]).has(String(meta.ticket_workspace_policy)) ||
640
- !new Set(["direct-parent", "candidate-merge"]).has(String(meta.integration_gate)) ||
641
- (meta.ticket_workspace_policy === "current" && meta.integration_gate !== "direct-parent") ||
642
- (meta.ticket_workspace_policy === "required" && meta.integration_gate !== "candidate-merge") ||
643
- typeof meta.ready_for_execution !== "boolean" ||
644
- !Array.isArray(meta.modes))
645
- return false;
646
- const modes = meta.modes;
647
- return modes.every((mode) => new Set(["migration", "high-assurance", "reference-conformance", "release-coordination"]).has(String(mode))) &&
648
- new Set(modes.map(String)).size === modes.length;
649
- }
650
- async function inspectGoalPlanVersion(stateRoot, change, blockers) {
651
- const path = join(stateRoot, "specdev", "changes", change, "goal-plan.md");
652
- if (!(await pathExists(path)))
653
- return;
654
- const frontmatter = parseGoalPlanFrontmatter(await readFile(path, "utf8"));
655
- const currentContract = frontmatter !== null && (hasCompleteV6GoalPlan(frontmatter, change) || hasCompleteV5GoalPlan(frontmatter, change));
656
- if (!currentContract) {
657
- blockers.push({
658
- code: "unsupported-goal-plan-contract",
659
- path: `.speculo/specdev/changes/${change}/goal-plan.md`,
660
- message: "Goal Plan must contain the complete Lead and workspace/integration v6 frontmatter contract",
661
- });
662
- }
663
- }
664
- async function inspectSpecdevState(stateRoot) {
665
- const blockers = [];
666
- const statusPath = join(stateRoot, "specdev", "status.json");
667
- if (!(await pathExists(statusPath)))
668
- return blockers;
669
- let status;
670
- try {
671
- status = await readJson(statusPath);
672
- }
673
- catch {
674
- return blockers;
675
- }
676
- if (!isJsonObject(status) || !new Set([4, 5]).has(Number(status.schema_version)) || status.workflow !== "specdev" || !expectArray(status.active) || !expectArray(status.archived)) {
677
- blockers.push({ code: "unsupported-specdev-status", path: ".speculo/specdev/status.json", message: "automatic migration supports SpecDev global status schema v4/v5 only" });
678
- return blockers;
679
- }
680
- const activeNames = new Set();
681
- for (const item of status.active) {
682
- if (!item || typeof item !== "object" || typeof item.change !== "string") {
683
- blockers.push({ code: "invalid-active-entry", path: ".speculo/specdev/status.json", message: "active entries must contain a change name" });
684
- continue;
685
- }
686
- const name = item.change;
687
- if (!isChangeName(name)) {
688
- blockers.push({ code: "invalid-change-name", path: ".speculo/specdev/status.json", message: "active entries must use canonical change names" });
689
- continue;
690
- }
691
- if (activeNames.has(name)) {
692
- blockers.push({ code: "duplicate-active-change", path: ".speculo/specdev/status.json", message: `${name} appears more than once in active` });
693
- }
694
- activeNames.add(name);
695
- const changeStatusPath = join(stateRoot, "specdev", "changes", name, ".status.json");
696
- if (!(await pathExists(changeStatusPath))) {
697
- blockers.push({ code: "missing-active-change", path: `.speculo/specdev/changes/${name}/.status.json`, message: "active status entry has no matching change state" });
698
- }
699
- else {
700
- try {
701
- const changeStatus = await readJson(changeStatusPath);
702
- if (!new Set([3, 4, 5, 6]).has(Number(changeStatus.schema_version)) ||
703
- changeStatus.artifact !== "change-status" ||
704
- changeStatus.change !== name ||
705
- !new Set(["active", "blocked", "completed"]).has(String(changeStatus.change_status))) {
706
- blockers.push({ code: "unsupported-change-status", path: `.speculo/specdev/changes/${name}/.status.json`, message: "active change state must use schema v3/v4/v5/v6 and match its index entry" });
707
- }
708
- else if (hasLegacyWorktreeState(changeStatus)) {
709
- blockers.push({
710
- code: "ambiguous-ticket-worktree-contract",
711
- path: `.speculo/specdev/changes/${name}/.status.json`,
712
- message: "legacy worktree state cannot determine required implementation owner, source commit, candidate gate, or E2E disposition",
713
- });
714
- }
715
- else if (hasUnsupportedV3ChangeStatusFields(changeStatus)) {
716
- blockers.push({
717
- code: "unmapped-change-status-fields",
718
- path: `.speculo/specdev/changes/${name}/.status.json`,
719
- message: "legacy change-status contains additional fields that cannot be dropped during automatic migration",
720
- });
721
- }
722
- else if (changeStatus.schema_version === 4) {
723
- blockers.push({
724
- code: "unmapped-change-runtime-authority",
725
- path: `.speculo/specdev/changes/${name}/.status.json`,
726
- message: "v4 change-status cannot infer execution authorization or Lead leadership; reconcile explicitly before v5 migration",
727
- });
728
- }
729
- else if (changeStatus.schema_version === 5 && !hasCompleteV5ChangeStatus(changeStatus)) {
730
- blockers.push({
731
- code: "invalid-change-status-v5",
732
- path: `.speculo/specdev/changes/${name}/.status.json`,
733
- message: "change-status v5 is missing required runtime authority fields",
734
- });
735
- }
736
- else if (changeStatus.schema_version === 6 && !hasCompleteV6ChangeStatus(changeStatus)) {
737
- blockers.push({ code: "invalid-change-status-v6", path: `.speculo/specdev/changes/${name}/.status.json`, message: "change-status v6 has inconsistent current/required workspace fields" });
738
- }
739
- await inspectGoalPlanVersion(stateRoot, name, blockers);
740
- }
741
- catch {
742
- // The JSON tree check reports the parse failure with the precise path.
743
- }
744
- }
745
- }
746
- const archivedNames = new Set();
747
- for (const item of status.archived) {
748
- if (typeof item !== "string") {
749
- blockers.push({ code: "invalid-archived-entry", path: ".speculo/specdev/status.json", message: "archived entries must be change names" });
750
- continue;
751
- }
752
- if (!isChangeName(item)) {
753
- blockers.push({ code: "invalid-change-name", path: ".speculo/specdev/status.json", message: "archived entries must use canonical change names" });
754
- continue;
755
- }
756
- if (archivedNames.has(item))
757
- blockers.push({ code: "duplicate-archived-change", path: ".speculo/specdev/status.json", message: `${item} appears more than once in archived` });
758
- archivedNames.add(item);
759
- if (activeNames.has(item))
760
- blockers.push({ code: "status-overlap", path: ".speculo/specdev/status.json", message: `${item} appears in both active and archived` });
761
- const month = item.slice(0, 7);
762
- const archivedStatusPath = join(stateRoot, "specdev", "archive", month, item, ".status.json");
763
- if (!(await pathExists(archivedStatusPath))) {
764
- blockers.push({ code: "missing-archived-change", path: `.speculo/specdev/archive/${month}/${item}/.status.json`, message: "archived status entry has no matching archived change state" });
765
- }
766
- else {
767
- try {
768
- const archivedStatus = await readJson(archivedStatusPath);
769
- if (!new Set([3, 4, 5, 6]).has(Number(archivedStatus.schema_version)) ||
770
- archivedStatus.artifact !== "change-status" ||
771
- archivedStatus.change !== item ||
772
- archivedStatus.change_status !== "archived") {
773
- blockers.push({ code: "unsupported-archived-status", path: `.speculo/specdev/archive/${month}/${item}/.status.json`, message: "archived change state must use schema v3/v4 and match its index entry" });
774
- }
775
- else if (hasLegacyWorktreeState(archivedStatus)) {
776
- blockers.push({
777
- code: "ambiguous-archived-worktree-contract",
778
- path: `.speculo/specdev/archive/${month}/${item}/.status.json`,
779
- message: "legacy archived worktree state requires an explicit v4 reconciliation decision",
780
- });
781
- }
782
- else if (hasUnsupportedV3ChangeStatusFields(archivedStatus)) {
783
- blockers.push({
784
- code: "unmapped-archived-status-fields",
785
- path: `.speculo/specdev/archive/${month}/${item}/.status.json`,
786
- message: "legacy archived status contains additional fields that require an explicit migration decision",
787
- });
788
- }
789
- else if (archivedStatus.schema_version === 4 && !hasCompleteV4ChangeStatus(archivedStatus)) {
790
- blockers.push({
791
- code: "invalid-archived-status-v4",
792
- path: `.speculo/specdev/archive/${month}/${item}/.status.json`,
793
- message: "archived change-status v4 is incomplete",
794
- });
795
- }
796
- else if (archivedStatus.schema_version === 5 && !hasCompleteV5ChangeStatus(archivedStatus)) {
797
- blockers.push({ code: "invalid-archived-status-v5", path: `.speculo/specdev/archive/${month}/${item}/.status.json`, message: "archived change-status v5 is incomplete" });
798
- }
799
- else if (archivedStatus.schema_version === 6 && !hasCompleteV6ChangeStatus(archivedStatus)) {
800
- blockers.push({ code: "invalid-archived-status-v6", path: `.speculo/specdev/archive/${month}/${item}/.status.json`, message: "archived change-status v6 has inconsistent current/required workspace fields" });
801
- }
802
- }
803
- catch {
804
- // The JSON tree check reports the parse failure with the precise path.
805
- }
806
- }
807
- }
808
- const changesRoot = join(stateRoot, "specdev", "changes");
809
- if (await pathExists(changesRoot)) {
810
- for (const entry of await readdir(changesRoot, { withFileTypes: true })) {
811
- if (!entry.isDirectory())
812
- continue;
813
- const changeStatusPath = join(changesRoot, entry.name, ".status.json");
814
- if (!(await pathExists(changeStatusPath))) {
815
- blockers.push({ code: "missing-change-status", path: `.speculo/specdev/changes/${entry.name}/.status.json`, message: "change directory has no status file" });
816
- }
817
- else if (!activeNames.has(entry.name)) {
818
- blockers.push({ code: "unindexed-active-change", path: `.speculo/specdev/changes/${entry.name}`, message: "change directory is missing from the active index" });
819
- }
820
- }
821
- }
822
- const archiveRoot = join(stateRoot, "specdev", "archive");
823
- if (await pathExists(archiveRoot)) {
824
- for (const monthEntry of await readdir(archiveRoot, { withFileTypes: true })) {
825
- if (!monthEntry.isDirectory())
826
- continue;
827
- const monthRoot = join(archiveRoot, monthEntry.name);
828
- for (const changeEntry of await readdir(monthRoot, { withFileTypes: true })) {
829
- if (!changeEntry.isDirectory())
830
- continue;
831
- const archivedStatusPath = join(monthRoot, changeEntry.name, ".status.json");
832
- if (!(await pathExists(archivedStatusPath))) {
833
- blockers.push({ code: "missing-archived-status", path: `.speculo/specdev/archive/${monthEntry.name}/${changeEntry.name}/.status.json`, message: "archived change directory has no status file" });
834
- }
835
- else if (!archivedNames.has(changeEntry.name)) {
836
- blockers.push({ code: "unindexed-archived-change", path: `.speculo/specdev/archive/${monthEntry.name}/${changeEntry.name}`, message: "archived change directory is missing from the archived index" });
837
- }
838
- }
839
- }
840
- }
841
- const configPath = join(stateRoot, "specdev", "config.json");
842
- if (await pathExists(configPath)) {
843
- try {
844
- const config = await readJson(configPath);
845
- if (!new Set([3, 4, 5]).has(Number(config.schema_version))) {
846
- blockers.push({ code: "unsupported-specdev-config", path: ".speculo/specdev/config.json", message: "automatic migration supports SpecDev config schema v3/v4/v5 only" });
847
- }
848
- else if (hasUnsupportedV3SpecdevConfigFields(config)) {
849
- blockers.push({ code: "unmapped-specdev-config-fields", path: ".speculo/specdev/config.json", message: "SpecDev config v3 contains additional fields that require an explicit migration decision" });
850
- }
851
- else if (config.schema_version === 4 && !hasCompleteV4SpecdevConfig(config)) {
852
- blockers.push({ code: "invalid-specdev-config-v4", path: ".speculo/specdev/config.json", message: "SpecDev config v4 is incomplete or contains legacy execution fields" });
853
- }
854
- else if (config.schema_version === 5 && !hasCompleteV5SpecdevConfig(config)) {
855
- blockers.push({ code: "invalid-specdev-config-v5", path: ".speculo/specdev/config.json", message: "SpecDev config v5 is incomplete or contains invalid execution limits" });
856
- }
857
- }
858
- catch {
859
- // The JSON tree check reports the parse failure with the precise path.
860
- }
861
- }
862
- return blockers;
863
- }
864
- async function inspectCommandState(stateRoot) {
865
- const commandsRoot = join(stateRoot, "commands");
866
- if (!(await pathExists(commandsRoot)))
867
- return [];
868
- const blockers = [];
869
- for (const entry of await readdir(commandsRoot, { withFileTypes: true })) {
870
- if (!entry.isDirectory())
871
- continue;
872
- const statePath = join(commandsRoot, entry.name, "state.json");
873
- if (!(await pathExists(statePath)))
874
- continue;
875
- if (entry.name !== "docs-sync") {
876
- blockers.push({ code: "unknown-command-state", path: `.speculo/commands/${entry.name}/state.json`, message: "command state has no current migration contract" });
877
- continue;
878
- }
879
- try {
880
- const state = await readJson(statePath);
881
- if (state.schema_version !== 4 || state.command !== "docs-sync") {
882
- blockers.push({ code: "unsupported-command-state", path: ".speculo/commands/docs-sync/state.json", message: "automatic migration supports docs-sync state schema v4 only" });
883
- }
884
- }
885
- catch {
886
- // The JSON tree check reports the parse failure with the precise path.
887
- }
888
- }
889
- return blockers;
890
- }
891
- async function inspectPersonState(stateRoot) {
892
- const statusPath = join(stateRoot, "person", "status.json");
893
- if (!(await pathExists(statusPath)))
894
- return [];
895
- try {
896
- const status = await readJson(statusPath);
897
- if (status.schema_version !== 1 || status.workflow !== "person" || !expectArray(status.active)) {
898
- return [{ code: "unsupported-person-status", path: ".speculo/person/status.json", message: "automatic migration supports person status schema v1 only" }];
899
- }
900
- }
901
- catch {
902
- // The JSON tree check reports the parse failure with the precise path.
903
- }
904
- return [];
905
- }
906
- async function inspectSnapshot(snapshotRoot, source) {
907
- const blockers = [];
908
- const configPath = join(snapshotRoot, "config.json");
909
- if (!(await pathExists(configPath))) {
910
- blockers.push({ code: "missing-config", path: "config.json", message: "existing installation has no project configuration" });
911
- }
912
- else if ((await lstat(configPath)).isSymbolicLink()) {
913
- blockers.push({ code: "state-symlink", path: "config.json", message: "project configuration symlinks require manual migration review" });
914
- }
915
- else {
916
- try {
917
- const config = await readJson(configPath);
918
- if (config.schema_version !== 1)
919
- blockers.push({ code: "unsupported-config", path: "config.json", message: "automatic migration supports project config schema v1 only" });
920
- }
921
- catch (error) {
922
- blockers.push({ code: "invalid-json", path: "config.json", message: String(error) });
923
- }
924
- }
925
- const stateRoot = join(snapshotRoot, "state");
926
- const stateRootIsSymlink = await pathExists(stateRoot) && (await lstat(stateRoot)).isSymbolicLink();
927
- if (stateRootIsSymlink) {
928
- blockers.push({ code: "state-symlink", path: ".speculo", message: "runtime state root symlinks require manual migration review" });
929
- }
930
- const workspacePath = join(stateRoot, "workspace.json");
931
- if (!stateRootIsSymlink && !(await pathExists(workspacePath))) {
932
- blockers.push({ code: "missing-workspace", path: ".speculo/workspace.json", message: "existing installation has no workspace root manifest" });
933
- }
934
- else if (!stateRootIsSymlink) {
935
- try {
936
- const workspace = await readJson(workspacePath);
937
- const roots = workspace.roots;
938
- if (workspace.schema_version !== 1 || workspace.path_base !== "project-root" ||
939
- !roots || typeof roots !== "object" ||
940
- ["config", "speculo", "state", "commands", "skills", "workflows"].some((key) => typeof roots[key] !== "string")) {
941
- blockers.push({ code: "unsupported-workspace", path: ".speculo/workspace.json", message: "automatic migration supports workspace schema v1 with project-root aliases only" });
942
- }
943
- }
944
- catch {
945
- // The JSON tree check reports the parse failure with the precise path.
946
- }
947
- }
948
- if (source === null || versionBefore07(source)) {
949
- blockers.push({ code: "unsupported-source-version", path: ".speculo/install.json", message: "automatic migration supports v0.7 and newer installations only" });
950
- }
951
- if (!stateRootIsSymlink) {
952
- blockers.push(...await inspectJsonTree(stateRoot));
953
- blockers.push(...await inspectSpecdevState(stateRoot));
954
- blockers.push(...await inspectPersonState(stateRoot));
955
- blockers.push(...await inspectCommandState(stateRoot));
956
- }
957
- return blockers;
958
- }
959
- function mergeDefaults(defaults, previous) {
960
- if (defaults && typeof defaults === "object" && !Array.isArray(defaults) &&
961
- previous && typeof previous === "object" && !Array.isArray(previous)) {
962
- const merged = { ...defaults };
963
- for (const [key, value] of Object.entries(previous)) {
964
- merged[key] = key in merged ? mergeDefaults(merged[key], value) : value;
965
- }
966
- return merged;
967
- }
968
- return previous;
969
- }
970
- function normalizeSpecdevConfigV5(previous, defaults) {
971
- if (previous.schema_version === CONFIG_SCHEMA_VERSION)
972
- return previous;
973
- const previousGit = isJsonObject(previous.git) ? previous.git : {};
974
- const previousExecution = isJsonObject(previous.execution) ? previous.execution : {};
975
- const defaultGit = isJsonObject(defaults.git) ? defaults.git : {};
976
- const defaultExecution = isJsonObject(defaults.execution) ? defaults.execution : {};
977
- const configuredLimit = Number(previousExecution.max_implementation_agents);
978
- const legacyLimit = Number(previousExecution.max_parallel);
979
- const maxImplementationAgents = Number.isInteger(configuredLimit) && configuredLimit >= 1
980
- ? configuredLimit
981
- : Number.isInteger(legacyLimit) && legacyLimit >= 1
982
- ? legacyLimit
983
- : Number(defaultExecution.max_implementation_agents ?? 3);
984
- const merged = mergeDefaults(defaults, previous);
985
- return {
986
- ...merged,
987
- schema_version: CONFIG_SCHEMA_VERSION,
988
- interaction_language: typeof previous.interaction_language === "string"
989
- ? previous.interaction_language
990
- : defaults.interaction_language,
991
- artifact_language: typeof previous.artifact_language === "string"
992
- ? previous.artifact_language
993
- : defaults.artifact_language,
994
- git: {
995
- default_branch: typeof previousGit.default_branch === "string" || previousGit.default_branch === null
996
- ? previousGit.default_branch
997
- : defaultGit.default_branch ?? null,
998
- },
999
- execution: {
1000
- max_implementation_agents: maxImplementationAgents,
1001
- max_integration_attempts: Number.isInteger(previousExecution.max_integration_attempts) && Number(previousExecution.max_integration_attempts) >= 1
1002
- ? previousExecution.max_integration_attempts
1003
- : Number(defaultExecution.max_integration_attempts ?? 3),
1004
- deep_ticket_human_approval: typeof previousExecution.deep_ticket_human_approval === "boolean"
1005
- ? previousExecution.deep_ticket_human_approval
1006
- : defaultExecution.deep_ticket_human_approval,
1007
- shared_path_owner: typeof previousExecution.shared_path_owner === "string"
1008
- ? previousExecution.shared_path_owner
1009
- : defaultExecution.shared_path_owner,
1010
- },
1011
- verification: isJsonObject(previous.verification)
1012
- ? mergeDefaults(defaults.verification, previous.verification)
1013
- : defaults.verification,
1014
- planning: isJsonObject(previous.planning)
1015
- ? mergeDefaults(defaults.planning, previous.planning)
1016
- : defaults.planning,
1017
- };
1018
- }
1019
- function normalizeChangeStatusV4(previous) {
1020
- if (previous.schema_version === 4)
1021
- return previous;
1022
- const change = String(previous.change);
1023
- const status = String(previous.change_status);
1024
- const timestamp = typeof previous.updated_at === "string" && previous.updated_at
1025
- ? previous.updated_at
1026
- : typeof previous.created_at === "string" && previous.created_at
1027
- ? previous.created_at
1028
- : new Date().toISOString();
1029
- const archived = status === "archived";
1030
- return {
1031
- schema_version: 4,
1032
- artifact: "change-status",
1033
- change,
1034
- change_status: status,
1035
- current_work: typeof previous.current_work === "string" ? previous.current_work : null,
1036
- created_at: typeof previous.created_at === "string" && previous.created_at ? previous.created_at : timestamp,
1037
- updated_at: timestamp,
1038
- completed_at: typeof previous.completed_at === "string" ? previous.completed_at : null,
1039
- archived,
1040
- archive_path: archived
1041
- ? typeof previous.archive_path === "string" && previous.archive_path
1042
- ? previous.archive_path
1043
- : `<Path>{roots.state}/specdev/archive/${change.slice(0, 7)}/${change}</Path>`
1044
- : null,
1045
- blockers: Array.isArray(previous.blockers) ? previous.blockers : [],
1046
- deviations: Array.isArray(previous.deviations) ? previous.deviations : [],
1047
- worktrees: [],
1048
- };
1049
- }
1050
- async function upgradeSpecdevRuntimeV5(stagedRoot) {
1051
- const stateRoot = join(stagedRoot, ".speculo", "specdev");
1052
- if (!(await pathExists(stateRoot)))
1053
- return;
1054
- const configPath = join(stateRoot, "config.json");
1055
- if (await pathExists(configPath)) {
1056
- const previous = await readJson(configPath);
1057
- const defaults = await readJson(join(stagedRoot, "workflows", "specdev", "I-init-setup", "config-template.json"));
1058
- if (previous.schema_version !== CONFIG_SCHEMA_VERSION) {
1059
- await writeFile(configPath, JSON.stringify(normalizeSpecdevConfigV5(previous, defaults), null, 2) + "\n", "utf8");
1060
- }
1061
- }
1062
- const statusPath = join(stateRoot, "status.json");
1063
- if (!(await pathExists(statusPath)))
1064
- return;
1065
- const status = await readJson(statusPath);
1066
- const activeEntries = expectArray(status.active) ? status.active.filter(isJsonObject) : [];
1067
- const changeStatusPaths = [];
1068
- for (const entry of activeEntries) {
1069
- if (isChangeName(entry.change))
1070
- changeStatusPaths.push({ path: join(stateRoot, "changes", entry.change, ".status.json"), entry });
1071
- }
1072
- for (const entry of expectArray(status.archived) ? status.archived : []) {
1073
- if (isChangeName(entry))
1074
- changeStatusPaths.push({ path: join(stateRoot, "archive", entry.slice(0, 7), entry, ".status.json"), entry: null });
1075
- }
1076
- for (const { path, entry } of changeStatusPaths) {
1077
- if (!(await pathExists(path)))
1078
- continue;
1079
- const previous = await readJson(path);
1080
- if (previous.schema_version === 3) {
1081
- await writeFile(path, JSON.stringify(normalizeChangeStatusV4(previous), null, 2) + "\n", "utf8");
1082
- }
1083
- let current = await readJson(path);
1084
- if (current.schema_version === 4) {
1085
- await writeFile(path, JSON.stringify(normalizeChangeStatusV5(current, entry), null, 2) + "\n", "utf8");
1086
- }
1087
- current = await readJson(path);
1088
- if (current.schema_version === 5) {
1089
- await writeFile(path, JSON.stringify({ ...current, schema_version: CHANGE_STATUS_SCHEMA_VERSION }, null, 2) + "\n", "utf8");
1090
- }
1091
- }
1092
- const config = await pathExists(configPath) ? await readJson(configPath) : {};
1093
- for (const { path } of changeStatusPaths) {
1094
- const goalPlanPath = join(dirname(path), "goal-plan.md");
1095
- if (!(await pathExists(goalPlanPath)))
1096
- continue;
1097
- const goalPlan = await readFile(goalPlanPath, "utf8");
1098
- if (parseGoalPlanFrontmatter(goalPlan)?.schema_version === 5) {
1099
- await writeFile(goalPlanPath, normalizeGoalPlanV6(goalPlan, config), "utf8");
1100
- }
1101
- }
1102
- if (status.schema_version === 4) {
1103
- await writeFile(statusPath, JSON.stringify({
1104
- schema_version: 5,
1105
- workflow: "specdev",
1106
- active: activeEntries.map((entry) => ({ change: entry.change })),
1107
- archived: expectArray(status.archived) ? status.archived : [],
1108
- }, null, 2) + "\n", "utf8");
1109
- }
1110
- }
1111
- async function copyNode(source, target, recursive = false) {
1112
- await cp(source, target, { recursive, force: true, verbatimSymlinks: true });
1113
- }
1114
- async function restoreCompatibleState(snapshotRoot, stagedRoot) {
1115
- const snapshotState = join(snapshotRoot, "state");
1116
- const stagedState = join(stagedRoot, ".speculo");
1117
- if (await nodeExists(snapshotState)) {
1118
- for (const entry of await readdir(snapshotState, { withFileTypes: true })) {
1119
- if (MANAGED_STATE_ENTRIES.has(entry.name))
1120
- continue;
1121
- await copyNode(join(snapshotState, entry.name), join(stagedState, entry.name), entry.isDirectory());
1122
- }
1123
- }
1124
- const previousConfig = join(snapshotRoot, "config.json");
1125
- if (await nodeExists(previousConfig)) {
1126
- const defaults = await readJson(join(stagedRoot, "config.json"));
1127
- const previous = await readJson(previousConfig);
1128
- await writeFile(join(stagedRoot, "config.json"), JSON.stringify(mergeDefaults(defaults, previous), null, 2) + "\n", "utf8");
1129
- }
1130
- await upgradeSpecdevRuntimeV5(stagedRoot);
1131
- }
1132
- async function restoreUnselectedState(snapshotRoot, stagedRoot, workflowIds) {
1133
- for (const workflowId of workflowIds) {
1134
- const source = join(snapshotRoot, "state", workflowId);
1135
- if (await nodeExists(source))
1136
- await copyNode(source, join(stagedRoot, ".speculo", workflowId), true);
1137
- }
1138
- }
1139
- async function writeInstallManifest(stagedRoot, targetVersion, workflowIds) {
1140
- await writeFile(join(stagedRoot, INSTALL_RELATIVE), JSON.stringify({
1141
- schema_version: 1,
1142
- package_version: targetVersion,
1143
- workflows: [...new Set(workflowIds)].sort(),
1144
- }, null, 2) + "\n", "utf8");
1145
- }
1146
- async function writePendingMarker(stagedRoot, source, target, blockers) {
1147
- await writeFile(join(stagedRoot, MIGRATION_RELATIVE), JSON.stringify({
1148
- schema_version: 1,
1149
- status: "pending",
1150
- source_version: source,
1151
- target_version: target,
1152
- backup_root: "speculo/.speculo/back",
1153
- created_at: new Date().toISOString(),
1154
- blockers,
1155
- }, null, 2) + "\n", "utf8");
1156
- }
1157
- export async function assertNoPendingMigration(previousRoot) {
1158
- const marker = join(previousRoot, MIGRATION_RELATIVE);
1159
- if (!(await pathExists(marker)))
1160
- return;
1161
- throw new Error("Speculo runtime migration is pending. Run the migrate-runtime-state command before speculo init.");
1162
- }
1163
- export async function migrateRuntimeState(options) {
1164
- const targetVersion = await readTargetVersion(options.packageRoot);
1165
- const snapshotRoot = join(options.stagedRoot, SNAPSHOT_DIR);
1166
- await copySnapshot(options.previousRoot, snapshotRoot);
1167
- const snapshotState = join(snapshotRoot, "state");
1168
- const snapshotStateIsSymlink = await pathExists(snapshotState) && (await lstat(snapshotState)).isSymbolicLink();
1169
- const versionResult = snapshotStateIsSymlink
1170
- ? { version: null, blockers: [] }
1171
- : await sourceVersion(snapshotState);
1172
- const blockers = [...versionResult.blockers, ...await inspectSnapshot(snapshotRoot, versionResult.version)];
1173
- await writeBackup(snapshotRoot, options.stagedRoot, versionResult.version, targetVersion);
1174
- if (blockers.length === 0) {
1175
- await restoreCompatibleState(snapshotRoot, options.stagedRoot);
1176
- }
1177
- else {
1178
- await restoreUnselectedState(snapshotRoot, options.stagedRoot, options.unselectedWorkflowIds);
1179
- await writePendingMarker(options.stagedRoot, versionResult.version, targetVersion, blockers);
1180
- }
1181
- await writeInstallManifest(options.stagedRoot, targetVersion, [...options.selectedWorkflowIds, ...options.unselectedWorkflowIds]);
1182
- await rm(snapshotRoot, { recursive: true, force: true });
1183
- return {
1184
- status: blockers.length === 0 ? "migrated" : "pending",
1185
- sourceVersion: versionResult.version,
1186
- targetVersion,
1187
- backupPath: "speculo/.speculo/back",
1188
- blockers,
1189
- };
1190
- }
1191
- export async function initializeRuntimeManifest(packageRoot, stagedRoot, workflowIds) {
1192
- const targetVersion = await readTargetVersion(packageRoot);
1193
- await writeInstallManifest(stagedRoot, targetVersion, workflowIds);
1194
- return {
1195
- status: "not-required",
1196
- sourceVersion: null,
1197
- targetVersion,
1198
- backupPath: null,
1199
- blockers: [],
1200
- };
1201
- }
1202
- //# sourceMappingURL=migrations.js.map