@namewta/speculo 0.7.2 → 0.7.4

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 (55) hide show
  1. package/dist/src/migrations.js +755 -23
  2. package/dist/src/migrations.js.map +1 -1
  3. package/package.json +1 -1
  4. package/template/canonical/canonical-specdev-engineering-cognitive-mentor.md +200 -448
  5. package/template/canonical/canonical-specdev-goal-plan.md +744 -1108
  6. package/template/canonical/canonical-specdev-grill-with-docs.md +201 -449
  7. package/template/canonical/canonical-specdev-spec.md +232 -486
  8. package/template/canonical/canonical-specdev-tickets.md +411 -590
  9. package/template/canonical/canonical-specdev-wayfinder.md +199 -447
  10. package/template/skills/migrate-runtime-state/SKILL.md +6 -6
  11. package/template/skills/migrate-runtime-state/references/migration-contract.md +9 -3
  12. package/template/skills/migrate-runtime-state/scripts/migrate-runtime-state.mjs +404 -33
  13. package/template/workflows/specdev/I-implement/I-implement.md +98 -143
  14. package/template/workflows/specdev/I-implement/evidence-template.md +66 -48
  15. package/template/workflows/specdev/I-implement/execution-preflight.md +31 -21
  16. package/template/workflows/specdev/I-implement/merge-conflict-protocol.md +12 -12
  17. package/template/workflows/specdev/I-init-setup/I-init-setup.md +4 -5
  18. package/template/workflows/specdev/I-init-setup/change-status-template.json +14 -1
  19. package/template/workflows/specdev/I-init-setup/config-template.json +7 -6
  20. package/template/workflows/specdev/I-init-setup/status-template.json +1 -1
  21. package/template/workflows/specdev/INDEX.md +11 -8
  22. package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +76 -102
  23. package/template/workflows/specdev/P-goal-plan/completion-control.md +26 -44
  24. package/template/workflows/specdev/P-goal-plan/goal-plan-template.md +46 -37
  25. package/template/workflows/specdev/P-goal-plan/lead-orchestration.md +34 -0
  26. package/template/workflows/specdev/P-goal-plan/orchestration-protocol.md +31 -46
  27. package/template/workflows/specdev/P-goal-plan/planning-modes.md +67 -89
  28. package/template/workflows/specdev/P-prototype/ui-prototype.md +1 -1
  29. package/template/workflows/specdev/T-tickets/T-tickets.md +6 -3
  30. package/template/workflows/specdev/T-tickets/ticket-readiness.md +5 -3
  31. package/template/workflows/specdev/T-tickets/ticket-template.md +8 -1
  32. package/template/workflows/specdev/T-tickets/tickets-map-template.md +5 -4
  33. package/template/workflows/specdev/_state/status.json +1 -1
  34. package/template/workflows/specdev/common/README.md +2 -2
  35. package/template/workflows/specdev/common/rules/change-completion.md +17 -20
  36. package/template/workflows/specdev/common/rules/deviation-control.md +1 -1
  37. package/template/workflows/specdev/common/rules/evidence-and-verification.md +31 -37
  38. package/template/workflows/specdev/common/rules/path-ownership.md +21 -23
  39. package/template/workflows/specdev/common/rules/readiness-and-depth.md +1 -1
  40. package/template/workflows/specdev/common/schemas/change-status.schema.json +153 -363
  41. package/template/workflows/specdev/common/schemas/config.schema.json +17 -13
  42. package/template/workflows/specdev/common/schemas/goal-plan.schema.json +33 -16
  43. package/template/workflows/specdev/common/schemas/status.schema.json +7 -63
  44. package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +42 -21
  45. package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +36 -21
  46. package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +46 -18
  47. package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +34 -31
  48. package/template/workflows/specdev/common/skills/subagent-delivery/references/external-web-subagent.md +10 -23
  49. package/template/workflows/specdev/common/skills/subagent-delivery/references/native-subagent.md +15 -25
  50. package/template/workflows/specdev/common/tools/README.md +2 -1
  51. package/template/workflows/specdev/common/tools/validate-specdev.mjs +591 -219
  52. package/template/workflows/specdev/I-implement/delegated-evidence-template.md +0 -12
  53. package/template/workflows/specdev/P-goal-plan/delegated-execution-template.md +0 -35
  54. package/template/workflows/specdev/P-goal-plan/delegated-execution.md +0 -59
  55. package/template/workflows/specdev/P-goal-plan/workspace-execution-template.md +0 -24
@@ -6,6 +6,9 @@ const BACKUP_RELATIVE = ".speculo/back";
6
6
  const INSTALL_RELATIVE = ".speculo/install.json";
7
7
  const MIGRATION_RELATIVE = ".speculo/migration.json";
8
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;
9
12
  const MANAGED_STATE_ENTRIES = new Set([
10
13
  "README.md",
11
14
  "workspace.json",
@@ -19,6 +22,15 @@ function toPosix(path) {
19
22
  async function readJson(path) {
20
23
  return JSON.parse(await readFile(path, "utf8"));
21
24
  }
25
+ async function nodeExists(path) {
26
+ try {
27
+ await lstat(path);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
22
34
  async function readTargetVersion(packageRoot) {
23
35
  const manifest = await readJson(join(packageRoot, "package.json"));
24
36
  if (typeof manifest.version !== "string" || !/^\d+\.\d+\.\d+(?:[-+].+)?$/.test(manifest.version)) {
@@ -58,14 +70,15 @@ async function sourceVersion(snapshotState) {
58
70
  async function copySnapshot(previousRoot, snapshotRoot) {
59
71
  await mkdir(snapshotRoot, { recursive: true });
60
72
  const configPath = join(previousRoot, "config.json");
61
- if (await pathExists(configPath))
62
- await cp(configPath, join(snapshotRoot, "config.json"), { force: true });
73
+ if (await nodeExists(configPath))
74
+ await cp(configPath, join(snapshotRoot, "config.json"), { force: true, verbatimSymlinks: true });
63
75
  const previousState = join(previousRoot, ".speculo");
64
- if (!(await pathExists(previousState)))
76
+ if (!(await nodeExists(previousState)))
65
77
  return;
66
78
  await cp(previousState, join(snapshotRoot, "state"), {
67
79
  recursive: true,
68
80
  force: true,
81
+ verbatimSymlinks: true,
69
82
  filter: (source) => {
70
83
  const item = toPosix(relative(previousState, source));
71
84
  return item !== "back" && !item.startsWith("back/") && item !== "migration.json";
@@ -105,10 +118,10 @@ async function writeBackup(snapshotRoot, stagedRoot, source, target) {
105
118
  await mkdir(backupRoot, { recursive: true });
106
119
  const snapshotConfig = join(snapshotRoot, "config.json");
107
120
  const snapshotState = join(snapshotRoot, "state");
108
- if (await pathExists(snapshotConfig))
109
- await cp(snapshotConfig, join(backupRoot, "config.json"), { force: true });
110
- if (await pathExists(snapshotState))
111
- await cp(snapshotState, join(backupRoot, "state"), { recursive: true, force: true });
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 });
112
125
  const files = await collectBackupEntries(backupRoot);
113
126
  await writeFile(join(backupRoot, "manifest.json"), JSON.stringify({
114
127
  schema_version: 1,
@@ -148,6 +161,506 @@ async function inspectJsonTree(root) {
148
161
  function expectArray(value) {
149
162
  return Array.isArray(value);
150
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
+ }
151
664
  async function inspectSpecdevState(stateRoot) {
152
665
  const blockers = [];
153
666
  const statusPath = join(stateRoot, "specdev", "status.json");
@@ -160,8 +673,8 @@ async function inspectSpecdevState(stateRoot) {
160
673
  catch {
161
674
  return blockers;
162
675
  }
163
- if (status.schema_version !== 4 || status.workflow !== "specdev" || !expectArray(status.active) || !expectArray(status.archived)) {
164
- blockers.push({ code: "unsupported-specdev-status", path: ".speculo/specdev/status.json", message: "automatic migration supports SpecDev global status schema v4 only" });
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" });
165
678
  return blockers;
166
679
  }
167
680
  const activeNames = new Set();
@@ -171,6 +684,10 @@ async function inspectSpecdevState(stateRoot) {
171
684
  continue;
172
685
  }
173
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
+ }
174
691
  if (activeNames.has(name)) {
175
692
  blockers.push({ code: "duplicate-active-change", path: ".speculo/specdev/status.json", message: `${name} appears more than once in active` });
176
693
  }
@@ -182,12 +699,44 @@ async function inspectSpecdevState(stateRoot) {
182
699
  else {
183
700
  try {
184
701
  const changeStatus = await readJson(changeStatusPath);
185
- if (changeStatus.schema_version !== 3 ||
702
+ if (!new Set([3, 4, 5, 6]).has(Number(changeStatus.schema_version)) ||
186
703
  changeStatus.artifact !== "change-status" ||
187
704
  changeStatus.change !== name ||
188
705
  !new Set(["active", "blocked", "completed"]).has(String(changeStatus.change_status))) {
189
- blockers.push({ code: "unsupported-change-status", path: `.speculo/specdev/changes/${name}/.status.json`, message: "active change state must use schema v3 and match its index entry" });
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
+ });
190
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);
191
740
  }
192
741
  catch {
193
742
  // The JSON tree check reports the parse failure with the precise path.
@@ -200,6 +749,10 @@ async function inspectSpecdevState(stateRoot) {
200
749
  blockers.push({ code: "invalid-archived-entry", path: ".speculo/specdev/status.json", message: "archived entries must be change names" });
201
750
  continue;
202
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
+ }
203
756
  if (archivedNames.has(item))
204
757
  blockers.push({ code: "duplicate-archived-change", path: ".speculo/specdev/status.json", message: `${item} appears more than once in archived` });
205
758
  archivedNames.add(item);
@@ -213,11 +766,38 @@ async function inspectSpecdevState(stateRoot) {
213
766
  else {
214
767
  try {
215
768
  const archivedStatus = await readJson(archivedStatusPath);
216
- if (archivedStatus.schema_version !== 3 ||
769
+ if (!new Set([3, 4, 5, 6]).has(Number(archivedStatus.schema_version)) ||
217
770
  archivedStatus.artifact !== "change-status" ||
218
771
  archivedStatus.change !== item ||
219
772
  archivedStatus.change_status !== "archived") {
220
- blockers.push({ code: "unsupported-archived-status", path: `.speculo/specdev/archive/${month}/${item}/.status.json`, message: "archived change state must use schema v3 and match its index entry" });
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" });
221
801
  }
222
802
  }
223
803
  catch {
@@ -262,8 +842,18 @@ async function inspectSpecdevState(stateRoot) {
262
842
  if (await pathExists(configPath)) {
263
843
  try {
264
844
  const config = await readJson(configPath);
265
- if (config.schema_version !== 3)
266
- blockers.push({ code: "unsupported-specdev-config", path: ".speculo/specdev/config.json", message: "automatic migration supports SpecDev config schema v3 only" });
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
+ }
267
857
  }
268
858
  catch {
269
859
  // The JSON tree check reports the parse failure with the precise path.
@@ -377,31 +967,173 @@ function mergeDefaults(defaults, previous) {
377
967
  }
378
968
  return previous;
379
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
+ }
380
1114
  async function restoreCompatibleState(snapshotRoot, stagedRoot) {
381
1115
  const snapshotState = join(snapshotRoot, "state");
382
1116
  const stagedState = join(stagedRoot, ".speculo");
383
- if (await pathExists(snapshotState)) {
1117
+ if (await nodeExists(snapshotState)) {
384
1118
  for (const entry of await readdir(snapshotState, { withFileTypes: true })) {
385
1119
  if (MANAGED_STATE_ENTRIES.has(entry.name))
386
1120
  continue;
387
- await cp(join(snapshotState, entry.name), join(stagedState, entry.name), {
388
- recursive: entry.isDirectory(),
389
- force: true,
390
- });
1121
+ await copyNode(join(snapshotState, entry.name), join(stagedState, entry.name), entry.isDirectory());
391
1122
  }
392
1123
  }
393
1124
  const previousConfig = join(snapshotRoot, "config.json");
394
- if (await pathExists(previousConfig)) {
1125
+ if (await nodeExists(previousConfig)) {
395
1126
  const defaults = await readJson(join(stagedRoot, "config.json"));
396
1127
  const previous = await readJson(previousConfig);
397
1128
  await writeFile(join(stagedRoot, "config.json"), JSON.stringify(mergeDefaults(defaults, previous), null, 2) + "\n", "utf8");
398
1129
  }
1130
+ await upgradeSpecdevRuntimeV5(stagedRoot);
399
1131
  }
400
1132
  async function restoreUnselectedState(snapshotRoot, stagedRoot, workflowIds) {
401
1133
  for (const workflowId of workflowIds) {
402
1134
  const source = join(snapshotRoot, "state", workflowId);
403
- if (await pathExists(source))
404
- await cp(source, join(stagedRoot, ".speculo", workflowId), { recursive: true, force: true });
1135
+ if (await nodeExists(source))
1136
+ await copyNode(source, join(stagedRoot, ".speculo", workflowId), true);
405
1137
  }
406
1138
  }
407
1139
  async function writeInstallManifest(stagedRoot, targetVersion, workflowIds) {