@namewta/speculo 0.7.2 → 0.7.3

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 (53) hide show
  1. package/dist/src/migrations.js +604 -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 +171 -455
  5. package/template/canonical/canonical-specdev-goal-plan.md +686 -1117
  6. package/template/canonical/canonical-specdev-grill-with-docs.md +172 -456
  7. package/template/canonical/canonical-specdev-spec.md +199 -493
  8. package/template/canonical/canonical-specdev-tickets.md +378 -597
  9. package/template/canonical/canonical-specdev-wayfinder.md +170 -454
  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 +322 -33
  13. package/template/workflows/specdev/I-implement/I-implement.md +97 -143
  14. package/template/workflows/specdev/I-implement/evidence-template.md +60 -48
  15. package/template/workflows/specdev/I-implement/execution-preflight.md +29 -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 +3 -5
  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 +40 -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 +42 -76
  28. package/template/workflows/specdev/T-tickets/T-tickets.md +6 -3
  29. package/template/workflows/specdev/T-tickets/ticket-readiness.md +5 -3
  30. package/template/workflows/specdev/T-tickets/ticket-template.md +8 -1
  31. package/template/workflows/specdev/T-tickets/tickets-map-template.md +5 -4
  32. package/template/workflows/specdev/_state/status.json +1 -1
  33. package/template/workflows/specdev/common/README.md +2 -2
  34. package/template/workflows/specdev/common/rules/change-completion.md +17 -20
  35. package/template/workflows/specdev/common/rules/deviation-control.md +1 -1
  36. package/template/workflows/specdev/common/rules/evidence-and-verification.md +27 -37
  37. package/template/workflows/specdev/common/rules/path-ownership.md +21 -23
  38. package/template/workflows/specdev/common/rules/readiness-and-depth.md +1 -1
  39. package/template/workflows/specdev/common/schemas/change-status.schema.json +136 -373
  40. package/template/workflows/specdev/common/schemas/config.schema.json +9 -11
  41. package/template/workflows/specdev/common/schemas/goal-plan.schema.json +24 -16
  42. package/template/workflows/specdev/common/schemas/status.schema.json +7 -63
  43. package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +42 -21
  44. package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +36 -21
  45. package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +46 -18
  46. package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +34 -31
  47. package/template/workflows/specdev/common/skills/subagent-delivery/references/external-web-subagent.md +10 -23
  48. package/template/workflows/specdev/common/skills/subagent-delivery/references/native-subagent.md +15 -25
  49. package/template/workflows/specdev/common/tools/validate-specdev.mjs +386 -209
  50. package/template/workflows/specdev/I-implement/delegated-evidence-template.md +0 -12
  51. package/template/workflows/specdev/P-goal-plan/delegated-execution-template.md +0 -35
  52. package/template/workflows/specdev/P-goal-plan/delegated-execution.md +0 -59
  53. package/template/workflows/specdev/P-goal-plan/workspace-execution-template.md +0 -24
@@ -19,6 +19,15 @@ function toPosix(path) {
19
19
  async function readJson(path) {
20
20
  return JSON.parse(await readFile(path, "utf8"));
21
21
  }
22
+ async function nodeExists(path) {
23
+ try {
24
+ await lstat(path);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
22
31
  async function readTargetVersion(packageRoot) {
23
32
  const manifest = await readJson(join(packageRoot, "package.json"));
24
33
  if (typeof manifest.version !== "string" || !/^\d+\.\d+\.\d+(?:[-+].+)?$/.test(manifest.version)) {
@@ -58,14 +67,15 @@ async function sourceVersion(snapshotState) {
58
67
  async function copySnapshot(previousRoot, snapshotRoot) {
59
68
  await mkdir(snapshotRoot, { recursive: true });
60
69
  const configPath = join(previousRoot, "config.json");
61
- if (await pathExists(configPath))
62
- await cp(configPath, join(snapshotRoot, "config.json"), { force: true });
70
+ if (await nodeExists(configPath))
71
+ await cp(configPath, join(snapshotRoot, "config.json"), { force: true, verbatimSymlinks: true });
63
72
  const previousState = join(previousRoot, ".speculo");
64
- if (!(await pathExists(previousState)))
73
+ if (!(await nodeExists(previousState)))
65
74
  return;
66
75
  await cp(previousState, join(snapshotRoot, "state"), {
67
76
  recursive: true,
68
77
  force: true,
78
+ verbatimSymlinks: true,
69
79
  filter: (source) => {
70
80
  const item = toPosix(relative(previousState, source));
71
81
  return item !== "back" && !item.startsWith("back/") && item !== "migration.json";
@@ -105,10 +115,10 @@ async function writeBackup(snapshotRoot, stagedRoot, source, target) {
105
115
  await mkdir(backupRoot, { recursive: true });
106
116
  const snapshotConfig = join(snapshotRoot, "config.json");
107
117
  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 });
118
+ if (await nodeExists(snapshotConfig))
119
+ await cp(snapshotConfig, join(backupRoot, "config.json"), { force: true, verbatimSymlinks: true });
120
+ if (await nodeExists(snapshotState))
121
+ await cp(snapshotState, join(backupRoot, "state"), { recursive: true, force: true, verbatimSymlinks: true });
112
122
  const files = await collectBackupEntries(backupRoot);
113
123
  await writeFile(join(backupRoot, "manifest.json"), JSON.stringify({
114
124
  schema_version: 1,
@@ -148,6 +158,392 @@ async function inspectJsonTree(root) {
148
158
  function expectArray(value) {
149
159
  return Array.isArray(value);
150
160
  }
161
+ function isJsonObject(value) {
162
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
163
+ }
164
+ function hasExactKeys(value, expected) {
165
+ const actual = Object.keys(value).sort();
166
+ const wanted = [...expected].sort();
167
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
168
+ }
169
+ function isNonEmptyString(value) {
170
+ return typeof value === "string" && value.length > 0;
171
+ }
172
+ function isStringOrNull(value) {
173
+ return value === null || typeof value === "string";
174
+ }
175
+ function isStringArray(value) {
176
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
177
+ }
178
+ const CHANGE_NAME_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
179
+ const ARCHIVE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/archive\/[^<]+<\/Path>$/;
180
+ const EVIDENCE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/changes\/[^<]+\/evidence\/T-[0-9]{2,}\.md<\/Path>$/;
181
+ function isChangeName(value) {
182
+ return typeof value === "string" && CHANGE_NAME_PATTERN.test(value);
183
+ }
184
+ function hasCompleteV4Integration(integration, worktreeStatus, sourceCheckpoint, change, ticketId) {
185
+ const required = [
186
+ "status", "parent_before_sha", "source_sha", "candidate_sha", "candidate_branch",
187
+ "candidate_workspace_ref", "result_sha", "method", "conflict_paths", "verification",
188
+ "e2e", "evidence", "attempts",
189
+ ];
190
+ if (!hasExactKeys(integration, required))
191
+ return false;
192
+ if (!new Set(["pending", "candidate", "passed", "failed", "stale"]).has(String(integration.status)))
193
+ return false;
194
+ if (![null, "fast-forward", "merge-commit"].includes(integration.method))
195
+ return false;
196
+ if (!new Set(["pending", "passed", "failed"]).has(String(integration.verification)))
197
+ return false;
198
+ for (const key of ["parent_before_sha", "source_sha", "candidate_sha", "candidate_branch", "result_sha"]) {
199
+ if (!isStringOrNull(integration[key]))
200
+ return false;
201
+ }
202
+ if (integration.candidate_workspace_ref !== null &&
203
+ (typeof integration.candidate_workspace_ref !== "string" ||
204
+ !/^specdev-worktree\/\.integration\/T-[0-9]{2,}$/.test(integration.candidate_workspace_ref)))
205
+ return false;
206
+ if (!isStringArray(integration.conflict_paths))
207
+ return false;
208
+ if (!Number.isInteger(integration.attempts) || Number(integration.attempts) < 0)
209
+ return false;
210
+ if (typeof integration.evidence !== "string" ||
211
+ !EVIDENCE_PATH_PATTERN.test(integration.evidence) ||
212
+ integration.evidence !== `<Path>{roots.state}/specdev/changes/${change}/evidence/${ticketId}.md</Path>`)
213
+ return false;
214
+ const e2e = integration.e2e;
215
+ if (!isJsonObject(e2e) || !hasExactKeys(e2e, ["required", "status", "evidence"]))
216
+ return false;
217
+ if (typeof e2e.required !== "boolean" || !new Set(["not-required", "pending", "passed", "failed"]).has(String(e2e.status)))
218
+ return false;
219
+ if (!isStringOrNull(e2e.evidence))
220
+ return false;
221
+ if (e2e.required === false && e2e.status !== "not-required")
222
+ return false;
223
+ if (e2e.required === true && e2e.status === "not-required")
224
+ return false;
225
+ if (e2e.required === true && e2e.status === "passed" && !isNonEmptyString(e2e.evidence))
226
+ return false;
227
+ const lifecycleNeedsCandidate = new Set(["integrating", "integrated", "removed"]).has(String(worktreeStatus));
228
+ if (lifecycleNeedsCandidate) {
229
+ if (!isNonEmptyString(integration.parent_before_sha) ||
230
+ !isNonEmptyString(integration.source_sha) ||
231
+ integration.source_sha !== sourceCheckpoint ||
232
+ !isNonEmptyString(integration.candidate_sha) ||
233
+ integration.candidate_branch !== `speculo/integration/${change}/${ticketId}` ||
234
+ integration.candidate_workspace_ref !== `specdev-worktree/.integration/${ticketId}` ||
235
+ !new Set(["fast-forward", "merge-commit"]).has(String(integration.method)) ||
236
+ !Number.isInteger(integration.attempts) ||
237
+ Number(integration.attempts) < 1)
238
+ return false;
239
+ }
240
+ if (worktreeStatus === "integrating" && integration.status !== "candidate")
241
+ return false;
242
+ if (new Set(["integrated", "removed"]).has(String(worktreeStatus))) {
243
+ if (integration.status !== "passed" ||
244
+ integration.verification !== "passed" ||
245
+ !isNonEmptyString(integration.result_sha) ||
246
+ integration.result_sha !== integration.candidate_sha ||
247
+ !new Set(["not-required", "passed"]).has(String(e2e.status)))
248
+ return false;
249
+ if (integration.method === "fast-forward" &&
250
+ (integration.candidate_sha !== sourceCheckpoint || integration.conflict_paths.length > 0))
251
+ return false;
252
+ if (integration.method === "merge-commit" &&
253
+ (integration.candidate_sha === sourceCheckpoint || integration.candidate_sha === integration.parent_before_sha))
254
+ return false;
255
+ }
256
+ return true;
257
+ }
258
+ function hasLegacyWorktreeState(status) {
259
+ return status.schema_version === 3 &&
260
+ status.worktrees !== undefined &&
261
+ (!Array.isArray(status.worktrees) || status.worktrees.length > 0);
262
+ }
263
+ function hasUnsupportedV3ChangeStatusFields(status) {
264
+ const allowed = new Set([
265
+ "schema_version", "artifact", "change", "change_status", "current_work",
266
+ "created_at", "updated_at", "completed_at", "archived", "archive_path",
267
+ "blockers", "deviations", "worktrees",
268
+ ]);
269
+ return status.schema_version === 3 && Object.keys(status).some((key) => !allowed.has(key));
270
+ }
271
+ function hasUnsupportedV3SpecdevConfigFields(config) {
272
+ if (config.schema_version !== 3)
273
+ return false;
274
+ const rootAllowed = new Set([
275
+ "schema_version", "interaction_language", "artifact_language", "git",
276
+ "execution", "verification", "planning",
277
+ ]);
278
+ if (Object.keys(config).some((key) => !rootAllowed.has(key)))
279
+ return true;
280
+ const git = isJsonObject(config.git) ? config.git : {};
281
+ const execution = isJsonObject(config.execution) ? config.execution : {};
282
+ return Object.keys(git).some((key) => !new Set(["auto_commit", "default_branch", "worktree_for_parallel"]).has(key)) ||
283
+ Object.keys(execution).some((key) => !new Set(["max_parallel", "deep_ticket_human_approval", "shared_path_owner"]).has(key));
284
+ }
285
+ function hasCompleteV4ChangeStatus(status) {
286
+ const required = [
287
+ "schema_version", "artifact", "change", "change_status", "current_work", "created_at",
288
+ "updated_at", "completed_at", "archived", "archive_path", "blockers", "deviations", "worktrees",
289
+ ];
290
+ if (status.schema_version !== 4 ||
291
+ status.artifact !== "change-status" ||
292
+ !hasExactKeys(status, required) ||
293
+ typeof status.change !== "string" ||
294
+ !CHANGE_NAME_PATTERN.test(status.change) ||
295
+ !new Set(["active", "blocked", "completed", "archived"]).has(String(status.change_status)) ||
296
+ !(status.current_work === null || typeof status.current_work === "string") ||
297
+ !isNonEmptyString(status.created_at) ||
298
+ !isNonEmptyString(status.updated_at) ||
299
+ !(status.completed_at === null || isNonEmptyString(status.completed_at)) ||
300
+ typeof status.archived !== "boolean" ||
301
+ !(status.archive_path === null || (typeof status.archive_path === "string" && ARCHIVE_PATH_PATTERN.test(status.archive_path))) ||
302
+ !isStringArray(status.blockers) ||
303
+ !isStringArray(status.deviations) ||
304
+ !Array.isArray(status.worktrees))
305
+ return false;
306
+ if (status.change_status === "archived") {
307
+ if (status.archived !== true || typeof status.archive_path !== "string" || !ARCHIVE_PATH_PATTERN.test(status.archive_path))
308
+ return false;
309
+ }
310
+ else if (status.archived !== false) {
311
+ return false;
312
+ }
313
+ const seenTickets = new Set();
314
+ return status.worktrees.every((entry) => {
315
+ const requiredWorktree = [
316
+ "ticket_id", "owner", "implementation_owner", "integration_owner", "provider", "base_sha",
317
+ "parent_branch", "branch", "workspace_ref", "source_checkpoint", "integration", "status", "updated_at",
318
+ ];
319
+ if (!isJsonObject(entry) || !hasExactKeys(entry, requiredWorktree) || entry.provider !== "git")
320
+ return false;
321
+ if (typeof entry.ticket_id !== "string" || !/^T-[0-9]{2,}$/.test(entry.ticket_id))
322
+ return false;
323
+ if (seenTickets.has(entry.ticket_id))
324
+ return false;
325
+ seenTickets.add(entry.ticket_id);
326
+ for (const key of ["owner", "implementation_owner", "integration_owner", "base_sha", "parent_branch", "branch", "updated_at"]) {
327
+ if (!isNonEmptyString(entry[key]))
328
+ return false;
329
+ }
330
+ if (entry.parent_branch === entry.branch)
331
+ return false;
332
+ if (entry.workspace_ref !== `specdev-worktree/${entry.ticket_id}`)
333
+ return false;
334
+ if (!new Set(["planned", "active", "review", "integrating", "integrated", "removed", "blocked"]).has(String(entry.status)))
335
+ return false;
336
+ const sourceRequired = new Set(["review", "integrating", "integrated", "removed"]).has(String(entry.status));
337
+ if (sourceRequired ? !isNonEmptyString(entry.source_checkpoint) : !isStringOrNull(entry.source_checkpoint))
338
+ return false;
339
+ const integration = entry.integration;
340
+ return isJsonObject(integration) && hasCompleteV4Integration(integration, entry.status, entry.source_checkpoint, String(status.change), entry.ticket_id);
341
+ });
342
+ }
343
+ function hasCompleteV4SpecdevConfig(config) {
344
+ const rootKeys = ["schema_version", "interaction_language", "artifact_language", "git", "execution", "verification", "planning"];
345
+ if (config.schema_version !== 4 ||
346
+ !hasExactKeys(config, rootKeys) ||
347
+ !isNonEmptyString(config.interaction_language) ||
348
+ !isNonEmptyString(config.artifact_language) ||
349
+ !isJsonObject(config.git) ||
350
+ !isJsonObject(config.execution) ||
351
+ !isJsonObject(config.verification) ||
352
+ !isJsonObject(config.planning)) {
353
+ return false;
354
+ }
355
+ if (!hasExactKeys(config.git, ["default_branch"]) || !(config.git.default_branch === null || typeof config.git.default_branch === "string"))
356
+ return false;
357
+ if (!hasExactKeys(config.execution, ["max_implementation_agents", "deep_ticket_human_approval", "shared_path_owner"]))
358
+ return false;
359
+ const limit = config.execution.max_implementation_agents;
360
+ if (!Number.isInteger(limit) || Number(limit) < 1 || Number(limit) > 3 ||
361
+ typeof config.execution.deep_ticket_human_approval !== "boolean" ||
362
+ !isNonEmptyString(config.execution.shared_path_owner))
363
+ return false;
364
+ for (const key of ["test", "typecheck", "lint", "build"]) {
365
+ if (!(key in config.verification) || !isStringOrNull(config.verification[key]))
366
+ return false;
367
+ }
368
+ return new Set(["lite", "standard", "deep"]).has(String(config.planning.default_depth)) &&
369
+ typeof config.planning.require_ready_gate === "boolean" &&
370
+ typeof config.planning.require_evidence === "boolean";
371
+ }
372
+ function parseGoalPlanFrontmatter(text) {
373
+ const lines = text.split(/\r?\n/);
374
+ if (lines[0]?.trim() !== "---")
375
+ return null;
376
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
377
+ if (end < 0)
378
+ return null;
379
+ const meta = {};
380
+ let currentListKey = null;
381
+ for (const line of lines.slice(1, end)) {
382
+ const trimmed = line.trim();
383
+ if (!trimmed || trimmed.startsWith("#"))
384
+ continue;
385
+ if (currentListKey && /^\s+-\s+/.test(line)) {
386
+ meta[currentListKey].push(parseGoalPlanScalar(line.replace(/^\s+-\s+/, "")));
387
+ continue;
388
+ }
389
+ currentListKey = null;
390
+ const colon = line.indexOf(":");
391
+ if (colon < 1)
392
+ return null;
393
+ const key = line.slice(0, colon).trim();
394
+ if (key in meta)
395
+ return null;
396
+ const raw = line.slice(colon + 1).trim();
397
+ if (!raw) {
398
+ meta[key] = [];
399
+ currentListKey = key;
400
+ }
401
+ else {
402
+ meta[key] = parseGoalPlanScalar(raw);
403
+ }
404
+ }
405
+ return meta;
406
+ }
407
+ function parseGoalPlanScalar(raw) {
408
+ const value = raw.trim();
409
+ if (value === "true")
410
+ return true;
411
+ if (value === "false")
412
+ return false;
413
+ if (/^-?\d+$/.test(value))
414
+ return Number(value);
415
+ if (value.startsWith("[") && value.endsWith("]")) {
416
+ const inner = value.slice(1, -1).trim();
417
+ return inner ? inner.split(",").map((item) => parseGoalPlanScalar(item)) : [];
418
+ }
419
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
420
+ return value.slice(1, -1);
421
+ }
422
+ return value;
423
+ }
424
+ function hasCompleteV5GoalPlan(meta, change) {
425
+ if (meta.schema_version !== 5)
426
+ return false;
427
+ const v4Shape = { ...meta, schema_version: 4 };
428
+ if (!hasCompleteV4GoalPlan(v4Shape, change))
429
+ return false;
430
+ const status = String(meta.status);
431
+ return (new Set(["ready", "in_progress"]).has(status) && meta.ready_for_execution === true) ||
432
+ (new Set(["draft", "blocked", "completed"]).has(status) && meta.ready_for_execution === false);
433
+ }
434
+ function normalizeGoalPlanV5(text) {
435
+ const meta = parseGoalPlanFrontmatter(text);
436
+ if (meta === null || !hasCompleteV4GoalPlan(meta, String(meta.change)))
437
+ return text;
438
+ return text.replace(/^schema_version:\s*4\s*$/m, "schema_version: 5");
439
+ }
440
+ function v5Authorization(scope) {
441
+ return { status: "not-authorized", source: null, granted_at: null, scope };
442
+ }
443
+ function normalizeChangeStatusV5(previous, globalEntry) {
444
+ if (previous.schema_version === 5)
445
+ return previous;
446
+ const change = String(previous.change);
447
+ const worktrees = Array.isArray(previous.worktrees) ? previous.worktrees.map((entry) => {
448
+ if (!isJsonObject(entry))
449
+ return entry;
450
+ const ticketId = String(entry.ticket_id);
451
+ const integration = isJsonObject(entry.integration) ? entry.integration : {};
452
+ const sourceCheckpoint = isStringOrNull(entry.source_checkpoint) ? entry.source_checkpoint : null;
453
+ return {
454
+ ...entry,
455
+ workspace_ref: `specdev-worktree/${change}/${ticketId}`,
456
+ integration: {
457
+ ...integration,
458
+ parent_ref: typeof entry.parent_branch === "string" ? entry.parent_branch : null,
459
+ candidate_tree_sha: integration.candidate_sha ?? null,
460
+ candidate_workspace_ref: integration.candidate_workspace_ref === null ? null : `specdev-worktree/.integration/${change}/${ticketId}`,
461
+ full_suite: { required: true, status: "pending", reason: null, evidence: null },
462
+ promotion_status: integration.status === "passed" ? "applied" : "pending",
463
+ source_sha: integration.source_sha ?? sourceCheckpoint,
464
+ },
465
+ };
466
+ }) : [];
467
+ const status = String(previous.change_status);
468
+ return {
469
+ ...previous,
470
+ schema_version: 5,
471
+ current_work: typeof previous.current_work === "string" ? previous.current_work : globalEntry?.current_work ?? null,
472
+ works_run: Array.isArray(globalEntry?.works_run) && globalEntry.works_run.every((item) => typeof item === "string") ? globalEntry.works_run : [],
473
+ claimed_investigations: Array.isArray(globalEntry?.claimed_investigations) ? globalEntry.claimed_investigations.map((claim) => isJsonObject(claim) ? {
474
+ id: String(claim.id ?? ""), owner: String(claim.owner ?? ""), session: typeof claim.session === "string" ? claim.session : null, claimed_at: String(claim.claimed_at ?? ""),
475
+ } : claim) : [],
476
+ execution_authorization: {
477
+ implementation_commit: v5Authorization("Ticket source commits"),
478
+ local_candidate_integration: v5Authorization("Lead-owned local parent candidate integration and parent update"),
479
+ source_cleanup: v5Authorization("Source worktree and branch cleanup"),
480
+ },
481
+ leadership: { current: "unassigned", epoch: 1, assigned_at: String(previous.updated_at), history: [] },
482
+ archived: status === "archived",
483
+ archive_path: status === "archived" ? `<Path>{roots.state}/specdev/archive/${change.slice(0, 7)}/${change}</Path>` : null,
484
+ worktrees,
485
+ };
486
+ }
487
+ function hasCompleteV5ChangeStatus(status) {
488
+ const required = [
489
+ "schema_version", "artifact", "change", "change_status", "current_work", "works_run", "claimed_investigations",
490
+ "execution_authorization", "leadership", "created_at", "updated_at", "completed_at", "archived", "archive_path", "blockers", "deviations", "worktrees",
491
+ ];
492
+ if (status.schema_version !== 5 || !hasExactKeys(status, required) || !isChangeName(status.change) ||
493
+ !isStringArray(status.works_run) || new Set(status.works_run).size !== status.works_run.length ||
494
+ !Array.isArray(status.claimed_investigations) || !isJsonObject(status.execution_authorization) || !isJsonObject(status.leadership))
495
+ return false;
496
+ const authorization = status.execution_authorization;
497
+ for (const key of ["implementation_commit", "local_candidate_integration", "source_cleanup"]) {
498
+ const entry = authorization[key];
499
+ if (!isJsonObject(entry) || !hasExactKeys(entry, ["status", "source", "granted_at", "scope"]) ||
500
+ !new Set(["authorized", "not-authorized", "revoked"]).has(String(entry.status)) || !isNonEmptyString(entry.scope) ||
501
+ !isStringOrNull(entry.source) || !isStringOrNull(entry.granted_at))
502
+ return false;
503
+ }
504
+ const leadership = status.leadership;
505
+ return isNonEmptyString(leadership.current) && Number.isInteger(leadership.epoch) && Number(leadership.epoch) >= 1 &&
506
+ isNonEmptyString(leadership.assigned_at) && Array.isArray(leadership.history);
507
+ }
508
+ function hasCompleteV4GoalPlan(meta, change) {
509
+ const required = [
510
+ "schema_version", "artifact", "change", "status", "modes", "orchestration", "lead",
511
+ "implementation_agent_limit", "ticket_workspace_policy", "integration_gate", "ready_for_execution",
512
+ ];
513
+ if (!hasExactKeys(meta, required))
514
+ return false;
515
+ if (meta.schema_version !== 4 ||
516
+ meta.artifact !== "goal-plan" ||
517
+ meta.change !== change ||
518
+ !new Set(["draft", "ready", "in_progress", "completed", "blocked"]).has(String(meta.status)) ||
519
+ meta.orchestration !== "lead-directed" ||
520
+ !isNonEmptyString(meta.lead) ||
521
+ !Number.isInteger(meta.implementation_agent_limit) ||
522
+ Number(meta.implementation_agent_limit) < 1 ||
523
+ Number(meta.implementation_agent_limit) > 3 ||
524
+ meta.ticket_workspace_policy !== "required" ||
525
+ meta.integration_gate !== "candidate-merge" ||
526
+ typeof meta.ready_for_execution !== "boolean" ||
527
+ !Array.isArray(meta.modes))
528
+ return false;
529
+ const modes = meta.modes;
530
+ return modes.every((mode) => new Set(["migration", "high-assurance", "reference-conformance", "release-coordination"]).has(String(mode))) &&
531
+ new Set(modes.map(String)).size === modes.length;
532
+ }
533
+ async function inspectGoalPlanVersion(stateRoot, change, blockers) {
534
+ const path = join(stateRoot, "specdev", "changes", change, "goal-plan.md");
535
+ if (!(await pathExists(path)))
536
+ return;
537
+ const frontmatter = parseGoalPlanFrontmatter(await readFile(path, "utf8"));
538
+ const currentContract = frontmatter !== null && hasCompleteV5GoalPlan(frontmatter, change);
539
+ if (!currentContract) {
540
+ blockers.push({
541
+ code: "unsupported-goal-plan-contract",
542
+ path: `.speculo/specdev/changes/${change}/goal-plan.md`,
543
+ message: "Goal Plan must contain the complete fixed Lead and candidate-integration v5 frontmatter contract",
544
+ });
545
+ }
546
+ }
151
547
  async function inspectSpecdevState(stateRoot) {
152
548
  const blockers = [];
153
549
  const statusPath = join(stateRoot, "specdev", "status.json");
@@ -160,8 +556,8 @@ async function inspectSpecdevState(stateRoot) {
160
556
  catch {
161
557
  return blockers;
162
558
  }
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" });
559
+ if (!isJsonObject(status) || !new Set([4, 5]).has(Number(status.schema_version)) || status.workflow !== "specdev" || !expectArray(status.active) || !expectArray(status.archived)) {
560
+ blockers.push({ code: "unsupported-specdev-status", path: ".speculo/specdev/status.json", message: "automatic migration supports SpecDev global status schema v4/v5 only" });
165
561
  return blockers;
166
562
  }
167
563
  const activeNames = new Set();
@@ -171,6 +567,10 @@ async function inspectSpecdevState(stateRoot) {
171
567
  continue;
172
568
  }
173
569
  const name = item.change;
570
+ if (!isChangeName(name)) {
571
+ blockers.push({ code: "invalid-change-name", path: ".speculo/specdev/status.json", message: "active entries must use canonical change names" });
572
+ continue;
573
+ }
174
574
  if (activeNames.has(name)) {
175
575
  blockers.push({ code: "duplicate-active-change", path: ".speculo/specdev/status.json", message: `${name} appears more than once in active` });
176
576
  }
@@ -182,12 +582,41 @@ async function inspectSpecdevState(stateRoot) {
182
582
  else {
183
583
  try {
184
584
  const changeStatus = await readJson(changeStatusPath);
185
- if (changeStatus.schema_version !== 3 ||
585
+ if (!new Set([3, 4, 5]).has(Number(changeStatus.schema_version)) ||
186
586
  changeStatus.artifact !== "change-status" ||
187
587
  changeStatus.change !== name ||
188
588
  !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" });
589
+ blockers.push({ code: "unsupported-change-status", path: `.speculo/specdev/changes/${name}/.status.json`, message: "active change state must use schema v3/v4 and match its index entry" });
590
+ }
591
+ else if (hasLegacyWorktreeState(changeStatus)) {
592
+ blockers.push({
593
+ code: "ambiguous-ticket-worktree-contract",
594
+ path: `.speculo/specdev/changes/${name}/.status.json`,
595
+ message: "legacy worktree state cannot determine required implementation owner, source commit, candidate gate, or E2E disposition",
596
+ });
190
597
  }
598
+ else if (hasUnsupportedV3ChangeStatusFields(changeStatus)) {
599
+ blockers.push({
600
+ code: "unmapped-change-status-fields",
601
+ path: `.speculo/specdev/changes/${name}/.status.json`,
602
+ message: "legacy change-status contains additional fields that cannot be dropped during automatic migration",
603
+ });
604
+ }
605
+ else if (changeStatus.schema_version === 4) {
606
+ blockers.push({
607
+ code: "unmapped-change-runtime-authority",
608
+ path: `.speculo/specdev/changes/${name}/.status.json`,
609
+ message: "v4 change-status cannot infer execution authorization or Lead leadership; reconcile explicitly before v5 migration",
610
+ });
611
+ }
612
+ else if (changeStatus.schema_version === 5 && !hasCompleteV5ChangeStatus(changeStatus)) {
613
+ blockers.push({
614
+ code: "invalid-change-status-v5",
615
+ path: `.speculo/specdev/changes/${name}/.status.json`,
616
+ message: "change-status v5 is missing required runtime authority fields",
617
+ });
618
+ }
619
+ await inspectGoalPlanVersion(stateRoot, name, blockers);
191
620
  }
192
621
  catch {
193
622
  // The JSON tree check reports the parse failure with the precise path.
@@ -200,6 +629,10 @@ async function inspectSpecdevState(stateRoot) {
200
629
  blockers.push({ code: "invalid-archived-entry", path: ".speculo/specdev/status.json", message: "archived entries must be change names" });
201
630
  continue;
202
631
  }
632
+ if (!isChangeName(item)) {
633
+ blockers.push({ code: "invalid-change-name", path: ".speculo/specdev/status.json", message: "archived entries must use canonical change names" });
634
+ continue;
635
+ }
203
636
  if (archivedNames.has(item))
204
637
  blockers.push({ code: "duplicate-archived-change", path: ".speculo/specdev/status.json", message: `${item} appears more than once in archived` });
205
638
  archivedNames.add(item);
@@ -213,11 +646,32 @@ async function inspectSpecdevState(stateRoot) {
213
646
  else {
214
647
  try {
215
648
  const archivedStatus = await readJson(archivedStatusPath);
216
- if (archivedStatus.schema_version !== 3 ||
649
+ if (!new Set([3, 4]).has(Number(archivedStatus.schema_version)) ||
217
650
  archivedStatus.artifact !== "change-status" ||
218
651
  archivedStatus.change !== item ||
219
652
  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" });
653
+ 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" });
654
+ }
655
+ else if (hasLegacyWorktreeState(archivedStatus)) {
656
+ blockers.push({
657
+ code: "ambiguous-archived-worktree-contract",
658
+ path: `.speculo/specdev/archive/${month}/${item}/.status.json`,
659
+ message: "legacy archived worktree state requires an explicit v4 reconciliation decision",
660
+ });
661
+ }
662
+ else if (hasUnsupportedV3ChangeStatusFields(archivedStatus)) {
663
+ blockers.push({
664
+ code: "unmapped-archived-status-fields",
665
+ path: `.speculo/specdev/archive/${month}/${item}/.status.json`,
666
+ message: "legacy archived status contains additional fields that require an explicit migration decision",
667
+ });
668
+ }
669
+ else if (archivedStatus.schema_version === 4 && !hasCompleteV4ChangeStatus(archivedStatus)) {
670
+ blockers.push({
671
+ code: "invalid-archived-status-v4",
672
+ path: `.speculo/specdev/archive/${month}/${item}/.status.json`,
673
+ message: "archived change-status v4 is incomplete",
674
+ });
221
675
  }
222
676
  }
223
677
  catch {
@@ -262,8 +716,15 @@ async function inspectSpecdevState(stateRoot) {
262
716
  if (await pathExists(configPath)) {
263
717
  try {
264
718
  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" });
719
+ if (!new Set([3, 4]).has(Number(config.schema_version))) {
720
+ blockers.push({ code: "unsupported-specdev-config", path: ".speculo/specdev/config.json", message: "automatic migration supports SpecDev config schema v3/v4 only" });
721
+ }
722
+ else if (hasUnsupportedV3SpecdevConfigFields(config)) {
723
+ 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" });
724
+ }
725
+ else if (config.schema_version === 4 && !hasCompleteV4SpecdevConfig(config)) {
726
+ blockers.push({ code: "invalid-specdev-config-v4", path: ".speculo/specdev/config.json", message: "SpecDev config v4 is incomplete or contains legacy execution fields" });
727
+ }
267
728
  }
268
729
  catch {
269
730
  // The JSON tree check reports the parse failure with the precise path.
@@ -377,31 +838,151 @@ function mergeDefaults(defaults, previous) {
377
838
  }
378
839
  return previous;
379
840
  }
841
+ function normalizeSpecdevConfigV4(previous, defaults) {
842
+ if (previous.schema_version === 4)
843
+ return previous;
844
+ const previousGit = isJsonObject(previous.git) ? previous.git : {};
845
+ const previousExecution = isJsonObject(previous.execution) ? previous.execution : {};
846
+ const defaultGit = isJsonObject(defaults.git) ? defaults.git : {};
847
+ const defaultExecution = isJsonObject(defaults.execution) ? defaults.execution : {};
848
+ const legacyLimit = Number(previousExecution.max_parallel);
849
+ const maxImplementationAgents = Number.isInteger(legacyLimit)
850
+ ? Math.max(1, Math.min(3, legacyLimit))
851
+ : Number(defaultExecution.max_implementation_agents ?? 3);
852
+ return {
853
+ schema_version: 4,
854
+ interaction_language: typeof previous.interaction_language === "string"
855
+ ? previous.interaction_language
856
+ : defaults.interaction_language,
857
+ artifact_language: typeof previous.artifact_language === "string"
858
+ ? previous.artifact_language
859
+ : defaults.artifact_language,
860
+ git: {
861
+ default_branch: typeof previousGit.default_branch === "string" || previousGit.default_branch === null
862
+ ? previousGit.default_branch
863
+ : defaultGit.default_branch ?? null,
864
+ },
865
+ execution: {
866
+ max_implementation_agents: maxImplementationAgents,
867
+ deep_ticket_human_approval: typeof previousExecution.deep_ticket_human_approval === "boolean"
868
+ ? previousExecution.deep_ticket_human_approval
869
+ : defaultExecution.deep_ticket_human_approval,
870
+ shared_path_owner: typeof previousExecution.shared_path_owner === "string"
871
+ ? previousExecution.shared_path_owner
872
+ : defaultExecution.shared_path_owner,
873
+ },
874
+ verification: isJsonObject(previous.verification)
875
+ ? mergeDefaults(defaults.verification, previous.verification)
876
+ : defaults.verification,
877
+ planning: isJsonObject(previous.planning)
878
+ ? mergeDefaults(defaults.planning, previous.planning)
879
+ : defaults.planning,
880
+ };
881
+ }
882
+ function normalizeChangeStatusV4(previous) {
883
+ if (previous.schema_version === 4)
884
+ return previous;
885
+ const change = String(previous.change);
886
+ const status = String(previous.change_status);
887
+ const timestamp = typeof previous.updated_at === "string" && previous.updated_at
888
+ ? previous.updated_at
889
+ : typeof previous.created_at === "string" && previous.created_at
890
+ ? previous.created_at
891
+ : new Date().toISOString();
892
+ const archived = status === "archived";
893
+ return {
894
+ schema_version: 4,
895
+ artifact: "change-status",
896
+ change,
897
+ change_status: status,
898
+ current_work: typeof previous.current_work === "string" ? previous.current_work : null,
899
+ created_at: typeof previous.created_at === "string" && previous.created_at ? previous.created_at : timestamp,
900
+ updated_at: timestamp,
901
+ completed_at: typeof previous.completed_at === "string" ? previous.completed_at : null,
902
+ archived,
903
+ archive_path: archived
904
+ ? typeof previous.archive_path === "string" && previous.archive_path
905
+ ? previous.archive_path
906
+ : `<Path>{roots.state}/specdev/archive/${change.slice(0, 7)}/${change}</Path>`
907
+ : null,
908
+ blockers: Array.isArray(previous.blockers) ? previous.blockers : [],
909
+ deviations: Array.isArray(previous.deviations) ? previous.deviations : [],
910
+ worktrees: [],
911
+ };
912
+ }
913
+ async function upgradeSpecdevRuntimeV5(stagedRoot) {
914
+ const stateRoot = join(stagedRoot, ".speculo", "specdev");
915
+ if (!(await pathExists(stateRoot)))
916
+ return;
917
+ const configPath = join(stateRoot, "config.json");
918
+ if (await pathExists(configPath)) {
919
+ const previous = await readJson(configPath);
920
+ if (previous.schema_version === 3) {
921
+ const defaults = await readJson(join(stagedRoot, "workflows", "specdev", "I-init-setup", "config-template.json"));
922
+ await writeFile(configPath, JSON.stringify(normalizeSpecdevConfigV4(previous, defaults), null, 2) + "\n", "utf8");
923
+ }
924
+ }
925
+ const statusPath = join(stateRoot, "status.json");
926
+ if (!(await pathExists(statusPath)))
927
+ return;
928
+ const status = await readJson(statusPath);
929
+ const activeEntries = expectArray(status.active) ? status.active.filter(isJsonObject) : [];
930
+ const changeStatusPaths = [];
931
+ for (const entry of activeEntries) {
932
+ if (isChangeName(entry.change))
933
+ changeStatusPaths.push({ path: join(stateRoot, "changes", entry.change, ".status.json"), entry });
934
+ }
935
+ for (const entry of expectArray(status.archived) ? status.archived : []) {
936
+ if (isChangeName(entry))
937
+ changeStatusPaths.push({ path: join(stateRoot, "archive", entry.slice(0, 7), entry, ".status.json"), entry: null });
938
+ }
939
+ for (const { path, entry } of changeStatusPaths) {
940
+ if (!(await pathExists(path)))
941
+ continue;
942
+ const previous = await readJson(path);
943
+ if (previous.schema_version === 3) {
944
+ await writeFile(path, JSON.stringify(normalizeChangeStatusV4(previous), null, 2) + "\n", "utf8");
945
+ continue;
946
+ }
947
+ if (previous.schema_version === 4) {
948
+ await writeFile(path, JSON.stringify(normalizeChangeStatusV5(previous, entry), null, 2) + "\n", "utf8");
949
+ }
950
+ }
951
+ if (status.schema_version === 4) {
952
+ await writeFile(statusPath, JSON.stringify({
953
+ schema_version: 5,
954
+ workflow: "specdev",
955
+ active: activeEntries.map((entry) => ({ change: entry.change })),
956
+ archived: expectArray(status.archived) ? status.archived : [],
957
+ }, null, 2) + "\n", "utf8");
958
+ }
959
+ }
960
+ async function copyNode(source, target, recursive = false) {
961
+ await cp(source, target, { recursive, force: true, verbatimSymlinks: true });
962
+ }
380
963
  async function restoreCompatibleState(snapshotRoot, stagedRoot) {
381
964
  const snapshotState = join(snapshotRoot, "state");
382
965
  const stagedState = join(stagedRoot, ".speculo");
383
- if (await pathExists(snapshotState)) {
966
+ if (await nodeExists(snapshotState)) {
384
967
  for (const entry of await readdir(snapshotState, { withFileTypes: true })) {
385
968
  if (MANAGED_STATE_ENTRIES.has(entry.name))
386
969
  continue;
387
- await cp(join(snapshotState, entry.name), join(stagedState, entry.name), {
388
- recursive: entry.isDirectory(),
389
- force: true,
390
- });
970
+ await copyNode(join(snapshotState, entry.name), join(stagedState, entry.name), entry.isDirectory());
391
971
  }
392
972
  }
393
973
  const previousConfig = join(snapshotRoot, "config.json");
394
- if (await pathExists(previousConfig)) {
974
+ if (await nodeExists(previousConfig)) {
395
975
  const defaults = await readJson(join(stagedRoot, "config.json"));
396
976
  const previous = await readJson(previousConfig);
397
977
  await writeFile(join(stagedRoot, "config.json"), JSON.stringify(mergeDefaults(defaults, previous), null, 2) + "\n", "utf8");
398
978
  }
979
+ await upgradeSpecdevRuntimeV5(stagedRoot);
399
980
  }
400
981
  async function restoreUnselectedState(snapshotRoot, stagedRoot, workflowIds) {
401
982
  for (const workflowId of workflowIds) {
402
983
  const source = join(snapshotRoot, "state", workflowId);
403
- if (await pathExists(source))
404
- await cp(source, join(stagedRoot, ".speculo", workflowId), { recursive: true, force: true });
984
+ if (await nodeExists(source))
985
+ await copyNode(source, join(stagedRoot, ".speculo", workflowId), true);
405
986
  }
406
987
  }
407
988
  async function writeInstallManifest(stagedRoot, targetVersion, workflowIds) {