@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
@@ -43,25 +43,25 @@ node scripts/migrate-runtime-state.mjs inspect --project-root <project-root>
43
43
 
44
44
  ### 3. 生成确定性计划
45
45
 
46
- 生成 schema v1 JSON plan。`backup_manifest_sha256` 锁定 inspect 现场;`source_decisions` 必须逐项覆盖 manifest 中的每个文件且不得包含 `unresolved`;`actions` 仅使用脚本支持的动作:
46
+ 生成 schema v2 JSON plan。`backup_manifest_sha256` 锁定 inspect 现场;`source_decisions` 必须逐项覆盖 manifest 中的每个文件且不得包含 `unresolved`;每个 action 必须以 `source_decision` 显式引用唯一来源决策,且动作、decision disposition 与目标一一对应:
47
47
 
48
48
  ```json
49
49
  {
50
- "schema_version": 1,
50
+ "schema_version": 2,
51
51
  "backup_manifest_sha256": "<inspect 返回的 sha256>",
52
52
  "source_decisions": [
53
53
  {"path": "config.json", "disposition": "merge-json", "target": "config.json"},
54
+ {"path": "state/specdev/context.md", "disposition": "restore", "target": ".speculo/specdev/context.md"},
54
55
  {"path": "state/workspace.json", "disposition": "keep-current", "target": ".speculo/workspace.json"}
55
56
  ],
56
57
  "actions": [
57
- {"kind": "copy", "from": "state/specdev/context", "to": ".speculo/specdev/context", "expected_target": "absent"},
58
- {"kind": "replace-json", "to": ".speculo/specdev/status.json", "value": {}, "expected_target": "file:<sha256>"},
59
- {"kind": "keep-current", "to": "config.json", "expected_target": "file:<sha256>"}
58
+ {"kind": "copy", "source_decision": "state/specdev/context.md", "from": "state/specdev/context.md", "to": ".speculo/specdev/context.md", "expected_target": "absent"},
59
+ {"kind": "replace-json", "source_decision": "config.json", "to": "config.json", "value": {}, "expected_target": "file:<sha256>"}
60
60
  ]
61
61
  }
62
62
  ```
63
63
 
64
- `from` 必须相对 backup root;`to` 只能是 `config.json` 或 `.speculo/` 内非受保护路径。每个 action 都使用 `fingerprint` 输出锁定 `expected_target`;目标相互重叠时拆成不重叠动作。Plan 写入调用方声明的临时位置,报告保存等价 Markdown 表格;正式 state 不保存第二份 plan。
64
+ `from` 必须与 `source_decision` 相同且相对 backup root;`copy ↔ restore`、`replace-json ↔ merge-json|replace-json`、`keep-current ↔ keep-current`、`remove-current ↔ remove-current`。只有 `keep-current` 决策可省略 action。`to` 只能是 `config.json` 或 `.speculo/` 内非受保护路径。每个 action 都使用 `fingerprint` 输出锁定 `expected_target`;目标相互重叠时拆成不重叠动作。Plan 写入调用方声明的临时位置,报告保存等价 Markdown 表格;正式 state 不保存第二份 plan。
65
65
 
66
66
  **完成标准**:计划可由脚本解析,路径均在授权边界内,受管理静态资产和 `back/` 不会成为目标。
67
67
 
@@ -26,6 +26,11 @@
26
26
  - `active` 从仍位于 `changes/` 且 change 状态为 active/blocked/completed 的真实目录核对;不得创建虚假 change。
27
27
  - `archived` 从 `archive/YYYY-MM/<change>/` 核对,active/archived 不得重叠。
28
28
  - `config.json` 保留语言、Git、执行、验证与规划偏好,并补入当前 schema 的必需默认值。
29
+ - SpecDev config v3/v4 升级为 v5 时,`execution.max_parallel` 只作为旧并发偏好的输入,保留为正整数的 `execution.max_implementation_agents`;补齐 `max_integration_attempts` 与 planning 原型变体配置。删除旧的自动提交和条件 worktree 开关,不把它们解释为授权。
30
+ - change-status v3 只有在 `worktrees` 缺失或为空时才可确定性升级为 v4;存在旧 worktree 记录时,必须逐 Ticket 重建 implementation owner、source commit、candidate/result、E2E disposition 和父分支包含关系。v4 runtime 不得自动升级为 v5:旧状态无法可靠推断 execution authorization 或 Lead leadership,必须保持 pending 并显式对账。
31
+ - active Goal Plan 不是 v6 时必须重新规划 Lead、workspace 与 integration gate;不能只改 frontmatter 版本号。
32
+ - 已声明为 v4 的 SpecDev config,以及已声明为 v5 的 global status、change-status 和 Goal Plan,必须完整满足当前 schema:必需字段、类型、枚举、嵌套对象和 `additionalProperties` 边界都通过后才能自动保留。不得为不完整状态静默补默认值,也不得只扫描少量标记字段。
33
+ - worktree `removed` 只接受为 `integrated` 后的清理终态;source checkpoint、passed candidate/result、验证、E2E disposition 和 Evidence 必须完整保留。
29
34
  - `.config/`、`adr/`、`context/`、`research/`、`changes/`、`archive/` 和声明的 sidecar 都是持久项。
30
35
  - Command Markdown 报告和具有当前 owner 的 `state.json` 均迁移;未知 command state 保持 unresolved。
31
36
 
@@ -49,10 +54,11 @@
49
54
 
50
55
  ## 验证
51
56
 
52
- - backup manifest 的每个 file hash、size symlink 条目与现场一致;存在 symlink 时阻塞。
53
- - plan 的 `backup_manifest_sha256` 与现场一致,`source_decisions` 恰好覆盖 manifest 全部条目,且每个 action 的 `expected_target` 与 active 现场一致。
57
+ - backup manifest 的每个 file hash、size,以及每个 symlink 的类型和 link target 都必须与现场一致;link 本身保留为 pending 事实,不能被自动恢复到 active runtime。
58
+ - plan 的 `backup_manifest_sha256` 与现场一致,`source_decisions` 恰好覆盖 manifest 全部条目;每个 action 必须显式 `source_decision`,且只可实现该 decision 的同目标、同 disposition 动作。除 `keep-current` 外,每个 decision 必须恰有一个 action;每个 action 的 `expected_target` 与 active 现场一致。
59
+ - action 后、删除 staged marker 前、原子替换后都递归拒绝 active runtime 中的 symlink;任何失败均恢复原 active 安装。
54
60
  - 所有迁移后 JSON 可解析。
55
61
  - workspace、install manifest、项目配置和已安装 workflow 全局状态满足当前版本。
56
- - SpecDev active/archive 索引与目录一致;person 状态满足 schema v1。
62
+ - SpecDev active/archive 索引与目录一致;SpecDev config 满足 v5 合同,global status 满足 v5 合同,change-status 满足 v6 合同,Goal Plan 满足 v6 合同;person 状态满足 schema v1。
57
63
  - pending marker 只在全部验证通过后从 staged 安装删除。
58
64
  - 执行后 backup manifest 与内容 hash 不变。
@@ -8,6 +8,7 @@ import {
8
8
  mkdtemp,
9
9
  readFile,
10
10
  readdir,
11
+ readlink,
11
12
  rename,
12
13
  rm,
13
14
  writeFile,
@@ -15,6 +16,9 @@ import {
15
16
  import { dirname, join, relative, resolve, sep } from "node:path";
16
17
 
17
18
  const STAGE_PREFIX = ".speculo-runtime-migrate-stage-";
19
+ const CONFIG_SCHEMA_VERSION = 5;
20
+ const GOAL_PLAN_SCHEMA_VERSION = 6;
21
+ const CHANGE_STATUS_SCHEMA_VERSION = 6;
18
22
  const ROLLBACK_NAME = ".speculo-runtime-migrate-rollback";
19
23
  const VALID_ACTIONS = new Set(["copy", "replace-json", "keep-current", "remove-current"]);
20
24
  const VALID_DECISIONS = new Set(["restore", "merge-json", "replace-json", "keep-current", "remove-current"]);
@@ -26,7 +30,7 @@ function usage() {
26
30
  " node migrate-runtime-state.mjs fingerprint --project-root <path> --target <relative-path>",
27
31
  " node migrate-runtime-state.mjs apply --project-root <path> --plan <plan.json> --confirmed",
28
32
  "",
29
- "inspect and fingerprint are read-only. apply requires an explicit confirmed schema-v1 plan.",
33
+ "inspect and fingerprint are read-only. apply requires an explicit confirmed schema-v2 plan.",
30
34
  "",
31
35
  ].join("\n"));
32
36
  return 2;
@@ -101,7 +105,7 @@ async function walk(root, current = root, options = {}) {
101
105
  values.push({ path: item, type: "directory" });
102
106
  values.push(...await walk(root, path, options));
103
107
  } else if (entry.isSymbolicLink()) {
104
- values.push({ path: item, type: "symlink" });
108
+ values.push({ path: item, type: "symlink", target: await readlink(path) });
105
109
  } else if (entry.isFile()) {
106
110
  const stat = await lstat(path);
107
111
  values.push({ path: item, type: "file", bytes: stat.size, sha256: await sha256(path) });
@@ -164,6 +168,9 @@ async function validateBackup(ctx, checkMigrationWorkspace = true) {
164
168
  if (entry.type === "file" && (typeof entry.sha256 !== "string" || typeof entry.bytes !== "number")) {
165
169
  throw new Error("file manifest entry has no hash or size: " + item);
166
170
  }
171
+ if (entry.type === "symlink" && typeof entry.target !== "string") {
172
+ throw new Error("symlink manifest entry has no target: " + item);
173
+ }
167
174
  expected.set(item, entry);
168
175
  } catch (error) {
169
176
  issues.push(String(error));
@@ -177,8 +184,10 @@ async function validateBackup(ctx, checkMigrationWorkspace = true) {
177
184
  issues.push("undeclared backup entry: " + entry.path);
178
185
  continue;
179
186
  }
180
- if (entry.type === "symlink" || declared.type === "symlink") {
181
- issues.push("backup symlink requires manual recovery outside this command: " + entry.path);
187
+ if (entry.type !== declared.type) {
188
+ issues.push("backup entry type mismatch: " + entry.path);
189
+ } else if (entry.type === "symlink") {
190
+ if (entry.target !== declared.target) issues.push("backup symlink target mismatch: " + entry.path);
182
191
  } else if (entry.sha256 !== declared.sha256 || entry.bytes !== declared.bytes) {
183
192
  issues.push("backup hash or size mismatch: " + entry.path);
184
193
  }
@@ -244,51 +253,74 @@ function pathsOverlap(left, right) {
244
253
  }
245
254
 
246
255
  async function validatePlan(ctx, plan) {
247
- if (plan.schema_version !== 1 || !Array.isArray(plan.source_decisions) || !Array.isArray(plan.actions)) {
248
- throw new Error("Plan must use schema_version 1 and contain source_decisions and actions");
256
+ if (plan.schema_version !== 2 || !Array.isArray(plan.source_decisions) || !Array.isArray(plan.actions)) {
257
+ throw new Error("Plan must use schema_version 2 and contain source_decisions and actions");
249
258
  }
250
259
  if (plan.backup_manifest_sha256 !== await sha256(ctx.manifestPath)) throw new Error("Plan backup manifest fingerprint does not match");
251
260
  const install = await readJson(join(ctx.stateRoot, "install.json"));
252
261
  const workflows = Array.isArray(install.workflows) ? install.workflows.filter((item) => typeof item === "string") : [];
253
262
  const expectedSources = new Set(ctx.manifest.files.map((entry) => entry.path));
254
- const seenSources = new Set();
263
+ const decisions = new Map();
255
264
  for (const [index, decision] of plan.source_decisions.entries()) {
256
265
  if (!decision || typeof decision !== "object" || !VALID_DECISIONS.has(decision.disposition)) {
257
266
  throw new Error(`source_decisions[${index}] has an invalid disposition`);
258
267
  }
259
268
  const source = safeRelative(decision.path, `source_decisions[${index}] path`);
260
269
  if (!expectedSources.has(source)) throw new Error(`source_decisions[${index}] is not in the backup manifest: ${source}`);
261
- if (seenSources.has(source)) throw new Error(`source_decisions[${index}] repeats ${source}`);
270
+ if (decisions.has(source)) throw new Error(`source_decisions[${index}] repeats ${source}`);
262
271
  if (typeof decision.target !== "string" || !allowedDecisionTarget(decision.target, decision.disposition, workflows)) {
263
272
  throw new Error(`source_decisions[${index}] target is outside runtime ownership: ${decision.target}`);
264
273
  }
265
- seenSources.add(source);
274
+ decisions.set(source, decision);
266
275
  }
267
276
  for (const source of expectedSources) {
268
- if (!seenSources.has(source)) throw new Error("Plan has no decision for backup entry: " + source);
277
+ if (!decisions.has(source)) throw new Error("Plan has no decision for backup entry: " + source);
269
278
  }
279
+
280
+ const actionSources = new Set();
270
281
  const seenTargets = new Set();
271
282
  for (const [index, action] of plan.actions.entries()) {
272
283
  if (!action || typeof action !== "object" || !VALID_ACTIONS.has(action.kind)) throw new Error(`actions[${index}] has an invalid kind`);
273
- if (!allowedTarget(action.to, workflows)) throw new Error(`actions[${index}] target is outside runtime ownership: ${action.to}`);
284
+ if (typeof action.source_decision !== "string") throw new Error(`actions[${index}] must explicitly name source_decision`);
285
+ const actionSource = safeRelative(action.source_decision, `actions[${index}] source_decision`);
286
+ const decision = decisions.get(actionSource);
287
+ if (!decision) throw new Error(`actions[${index}] source_decision is not in source_decisions: ${actionSource}`);
288
+ if (actionSources.has(actionSource)) throw new Error(`actions[${index}] duplicates source action: ${actionSource}`);
289
+ actionSources.add(actionSource);
290
+ if (typeof action.to !== "string" || !allowedTarget(action.to, workflows)) {
291
+ throw new Error(`actions[${index}] target is outside runtime ownership: ${action.to}`);
292
+ }
293
+ if (decision.target !== action.to) throw new Error(`actions[${index}] target must match source decision target`);
274
294
  for (const target of seenTargets) {
275
295
  if (pathsOverlap(target, action.to)) throw new Error(`actions[${index}] overlaps target ${target}`);
276
296
  }
277
297
  seenTargets.add(action.to);
278
298
  if (action.kind === "copy") {
279
- const sourcePath = safeRelative(action.from, `actions[${index}] source`);
280
- if (sourcePath !== "config.json" && !sourcePath.startsWith("state/")) throw new Error(`actions[${index}] source is outside backup data: ${action.from}`);
281
- const source = inside(ctx.backupRoot, action.from);
282
- if (!(await exists(source))) throw new Error(`actions[${index}] source does not exist: ${action.from}`);
283
- }
284
- if (action.kind === "replace-json") {
299
+ if (decision.disposition !== "restore") throw new Error(`actions[${index}] copy must implement a restore decision`);
300
+ if (action.from !== actionSource) throw new Error(`actions[${index}] copy source must match source_decision`);
301
+ const source = inside(ctx.backupRoot, actionSource);
302
+ if (!(await exists(source))) throw new Error(`actions[${index}] source does not exist: ${actionSource}`);
303
+ } else if (action.kind === "replace-json") {
304
+ if (!new Set(["merge-json", "replace-json"]).has(decision.disposition)) {
305
+ throw new Error(`actions[${index}] replace-json must implement a merge-json or replace-json decision`);
306
+ }
285
307
  if (!action.to.endsWith(".json") || action.value === undefined) throw new Error(`actions[${index}] replace-json needs a JSON target and value`);
286
308
  JSON.stringify(action.value);
309
+ } else if (action.kind === "keep-current") {
310
+ if (decision.disposition !== "keep-current") throw new Error(`actions[${index}] keep-current must implement a keep-current decision`);
311
+ } else if (decision.disposition !== "remove-current") {
312
+ throw new Error(`actions[${index}] remove-current must implement a remove-current decision`);
287
313
  }
288
314
  if (typeof action.expected_target !== "string") throw new Error(`actions[${index}] must contain expected_target`);
289
315
  const currentFingerprint = await fingerprint(inside(ctx.speculoRoot, action.to));
290
316
  if (currentFingerprint !== action.expected_target) throw new Error(`actions[${index}] target drifted: ${action.to}`);
291
317
  }
318
+
319
+ for (const [source, decision] of decisions) {
320
+ if (decision.disposition !== "keep-current" && !actionSources.has(source)) {
321
+ throw new Error(`source_decisions entry requires an action: ${source}`);
322
+ }
323
+ }
292
324
  return workflows;
293
325
  }
294
326
 
@@ -305,13 +337,340 @@ async function validateJsonTree(root) {
305
337
  return failures;
306
338
  }
307
339
 
340
+ function isObject(value) {
341
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
342
+ }
343
+
344
+ function hasExactKeys(value, expected) {
345
+ const actual = Object.keys(value).sort();
346
+ const wanted = [...expected].sort();
347
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
348
+ }
349
+
350
+ function nonEmptyString(value) {
351
+ return typeof value === "string" && value.length > 0;
352
+ }
353
+
354
+ function stringOrNull(value) {
355
+ return value === null || typeof value === "string";
356
+ }
357
+
358
+ function stringArray(value) {
359
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
360
+ }
361
+
362
+ const CHANGE_NAME_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
363
+ const ARCHIVE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/archive\/[^<]+<\/Path>$/;
364
+ const EVIDENCE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/changes\/[^<]+\/evidence\/T-[0-9]{2,}\.md<\/Path>$/;
365
+
366
+ function validIntegrationV4(integration, worktreeStatus, sourceCheckpoint, change, ticketId) {
367
+ const required = [
368
+ "status", "parent_before_sha", "source_sha", "candidate_sha", "candidate_branch",
369
+ "candidate_workspace_ref", "result_sha", "method", "conflict_paths", "verification",
370
+ "e2e", "evidence", "attempts",
371
+ ];
372
+ if (!isObject(integration) || !hasExactKeys(integration, required)) return false;
373
+ if (!new Set(["pending", "candidate", "passed", "failed", "stale"]).has(integration.status)) return false;
374
+ if (!new Set([null, "fast-forward", "merge-commit"]).has(integration.method)) return false;
375
+ if (!new Set(["pending", "passed", "failed"]).has(integration.verification)) return false;
376
+ for (const key of ["parent_before_sha", "source_sha", "candidate_sha", "candidate_branch", "result_sha"]) {
377
+ if (!stringOrNull(integration[key])) return false;
378
+ }
379
+ if (
380
+ integration.candidate_workspace_ref !== null &&
381
+ (typeof integration.candidate_workspace_ref !== "string" ||
382
+ !/^specdev-worktree\/\.integration\/T-[0-9]{2,}$/.test(integration.candidate_workspace_ref))
383
+ ) return false;
384
+ if (!stringArray(integration.conflict_paths)) return false;
385
+ if (!Number.isInteger(integration.attempts) || integration.attempts < 0) return false;
386
+ if (
387
+ typeof integration.evidence !== "string" ||
388
+ !EVIDENCE_PATH_PATTERN.test(integration.evidence) ||
389
+ integration.evidence !== `<Path>{roots.state}/specdev/changes/${change}/evidence/${ticketId}.md</Path>`
390
+ ) return false;
391
+
392
+ const e2e = integration.e2e;
393
+ if (!isObject(e2e) || !hasExactKeys(e2e, ["required", "status", "evidence"])) return false;
394
+ if (typeof e2e.required !== "boolean" || !new Set(["not-required", "pending", "passed", "failed"]).has(e2e.status)) return false;
395
+ if (!stringOrNull(e2e.evidence)) return false;
396
+ if (e2e.required === false && e2e.status !== "not-required") return false;
397
+ if (e2e.required === true && e2e.status === "not-required") return false;
398
+ if (e2e.required === true && e2e.status === "passed" && !nonEmptyString(e2e.evidence)) return false;
399
+
400
+ if (new Set(["integrating", "integrated", "removed"]).has(worktreeStatus)) {
401
+ if (
402
+ !nonEmptyString(integration.parent_before_sha) ||
403
+ !nonEmptyString(integration.source_sha) ||
404
+ integration.source_sha !== sourceCheckpoint ||
405
+ !nonEmptyString(integration.candidate_sha) ||
406
+ integration.candidate_branch !== `speculo/integration/${change}/${ticketId}` ||
407
+ integration.candidate_workspace_ref !== `specdev-worktree/.integration/${ticketId}` ||
408
+ !new Set(["fast-forward", "merge-commit"]).has(integration.method) ||
409
+ !Number.isInteger(integration.attempts) ||
410
+ integration.attempts < 1
411
+ ) return false;
412
+ }
413
+ if (worktreeStatus === "integrating" && integration.status !== "candidate") return false;
414
+ if (new Set(["integrated", "removed"]).has(worktreeStatus)) {
415
+ if (
416
+ integration.status !== "passed" ||
417
+ integration.verification !== "passed" ||
418
+ !nonEmptyString(integration.result_sha) ||
419
+ integration.result_sha !== integration.candidate_sha ||
420
+ !new Set(["not-required", "passed"]).has(e2e.status)
421
+ ) return false;
422
+ if (integration.method === "fast-forward" && (integration.candidate_sha !== sourceCheckpoint || integration.conflict_paths.length > 0)) return false;
423
+ if (integration.method === "merge-commit" && (integration.candidate_sha === sourceCheckpoint || integration.candidate_sha === integration.parent_before_sha)) return false;
424
+ }
425
+ return true;
426
+ }
427
+
428
+ function validChangeStatusV4(status, expectedChange, expectedStatus) {
429
+ const required = [
430
+ "schema_version", "artifact", "change", "change_status", "current_work", "created_at",
431
+ "updated_at", "completed_at", "archived", "archive_path", "blockers", "deviations", "worktrees",
432
+ ];
433
+ if (
434
+ !isObject(status) ||
435
+ !hasExactKeys(status, required) ||
436
+ status.schema_version !== 4 ||
437
+ status.artifact !== "change-status" ||
438
+ status.change !== expectedChange ||
439
+ !CHANGE_NAME_PATTERN.test(String(status.change)) ||
440
+ !expectedStatus.has(status.change_status) ||
441
+ !(status.current_work === null || typeof status.current_work === "string") ||
442
+ !nonEmptyString(status.created_at) ||
443
+ !nonEmptyString(status.updated_at) ||
444
+ !(status.completed_at === null || nonEmptyString(status.completed_at)) ||
445
+ typeof status.archived !== "boolean" ||
446
+ !(status.archive_path === null || (typeof status.archive_path === "string" && ARCHIVE_PATH_PATTERN.test(status.archive_path))) ||
447
+ !stringArray(status.blockers) ||
448
+ !stringArray(status.deviations) ||
449
+ !Array.isArray(status.worktrees)
450
+ ) return false;
451
+ if (status.change_status === "archived") {
452
+ if (status.archived !== true || typeof status.archive_path !== "string" || !ARCHIVE_PATH_PATTERN.test(status.archive_path)) return false;
453
+ } else if (status.archived !== false) {
454
+ return false;
455
+ }
456
+
457
+ const seenTickets = new Set();
458
+ return status.worktrees.every((worktree) => {
459
+ const requiredWorktree = [
460
+ "ticket_id", "owner", "implementation_owner", "integration_owner", "provider", "base_sha",
461
+ "parent_branch", "branch", "workspace_ref", "source_checkpoint", "integration", "status", "updated_at",
462
+ ];
463
+ if (!isObject(worktree) || !hasExactKeys(worktree, requiredWorktree) || worktree.provider !== "git") return false;
464
+ if (typeof worktree.ticket_id !== "string" || !/^T-[0-9]{2,}$/.test(worktree.ticket_id)) return false;
465
+ if (seenTickets.has(worktree.ticket_id)) return false;
466
+ seenTickets.add(worktree.ticket_id);
467
+ for (const key of ["owner", "implementation_owner", "integration_owner", "base_sha", "parent_branch", "branch", "updated_at"]) {
468
+ if (!nonEmptyString(worktree[key])) return false;
469
+ }
470
+ if (worktree.parent_branch === worktree.branch) return false;
471
+ if (worktree.workspace_ref !== `specdev-worktree/${worktree.ticket_id}`) return false;
472
+ if (!new Set(["planned", "active", "review", "integrating", "integrated", "removed", "blocked"]).has(worktree.status)) return false;
473
+ const sourceRequired = new Set(["review", "integrating", "integrated", "removed"]).has(worktree.status);
474
+ if (sourceRequired ? !nonEmptyString(worktree.source_checkpoint) : !stringOrNull(worktree.source_checkpoint)) return false;
475
+ return validIntegrationV4(worktree.integration, worktree.status, worktree.source_checkpoint, expectedChange, worktree.ticket_id);
476
+ });
477
+ }
478
+
479
+ function validateChangeStatusV6(status, expectedChange, expectedStatus) {
480
+ const failures = [];
481
+ if (!isObject(status) || status.schema_version !== CHANGE_STATUS_SCHEMA_VERSION || status.artifact !== "change-status" || status.change !== expectedChange || !expectedStatus.has(status.change_status)) {
482
+ return ["invalid or incomplete change-status v6 contract: " + expectedChange];
483
+ }
484
+ if (!Array.isArray(status.worktrees)) return ["change-status v6 worktrees must be an array: " + expectedChange];
485
+ const previous = { ...status, schema_version: 5 };
486
+ if (!isObject(previous.execution_authorization) || !isObject(previous.leadership) || !Array.isArray(previous.works_run) || !Array.isArray(previous.claimed_investigations)) {
487
+ failures.push("change-status v6 is missing execution authority or leadership state: " + expectedChange);
488
+ }
489
+ const worktreeKeys = [
490
+ "ticket_id", "owner", "implementation_owner", "integration_owner", "provider", "base_sha",
491
+ "parent_branch", "branch", "workspace_ref", "source_checkpoint", "integration", "status", "updated_at",
492
+ ];
493
+ const integrationKeys = [
494
+ "status", "parent_ref", "parent_before_sha", "source_sha", "candidate_sha", "candidate_tree_sha",
495
+ "candidate_branch", "candidate_workspace_ref", "result_sha", "method", "conflict_paths", "verification",
496
+ "full_suite", "e2e", "evidence", "attempts", "promotion_status",
497
+ ];
498
+ for (const worktree of status.worktrees) {
499
+ if (!isObject(worktree)) { failures.push("change-status v6 contains an invalid worktree"); continue; }
500
+ if (!hasExactKeys(worktree, worktreeKeys) || worktree.provider !== "git" || !/^T-[0-9]{2,}$/.test(String(worktree.ticket_id))) {
501
+ failures.push("change-status v6 contains an incomplete worktree: " + String(worktree.ticket_id));
502
+ continue;
503
+ }
504
+ const current = worktree.workspace_ref === "current";
505
+ if (current && worktree.parent_branch !== worktree.branch) failures.push(`${worktree.ticket_id}: current branch must equal parent_branch`);
506
+ if (!current && worktree.parent_branch === worktree.branch) failures.push(`${worktree.ticket_id}: required branch must differ from parent_branch`);
507
+ const integration = isObject(worktree.integration) ? worktree.integration : {};
508
+ if (!hasExactKeys(integration, integrationKeys) || !Number.isInteger(integration.attempts) || integration.attempts < 0 || !Array.isArray(integration.conflict_paths)) {
509
+ failures.push(`${worktree.ticket_id}: integration contract is incomplete`);
510
+ continue;
511
+ }
512
+ for (const key of ["full_suite", "e2e"]) {
513
+ const suite = integration[key];
514
+ if (!isObject(suite) || !hasExactKeys(suite, ["required", "status", "reason", "evidence"]) || typeof suite.required !== "boolean") {
515
+ failures.push(`${worktree.ticket_id}: ${key} contract is incomplete`);
516
+ }
517
+ }
518
+ if (current && ["candidate_sha", "candidate_tree_sha", "candidate_branch", "candidate_workspace_ref"].some((key) => integration[key] !== null)) failures.push(`${worktree.ticket_id}: current workspace cannot contain candidate fields`);
519
+ if (current && integration.method !== null && integration.method !== "direct-parent") failures.push(`${worktree.ticket_id}: current workspace requires direct-parent`);
520
+ if (!current && integration.method === "direct-parent") failures.push(`${worktree.ticket_id}: required workspace cannot use direct-parent`);
521
+ }
522
+ return failures;
523
+ }
524
+
525
+ function validateChangeStatusV4(status, expectedChange, expectedStatus) {
526
+ return validChangeStatusV4(status, expectedChange, expectedStatus)
527
+ ? []
528
+ : ["invalid or incomplete change-status v4 contract: " + expectedChange];
529
+ }
530
+
531
+ function parseGoalPlanScalar(raw) {
532
+ const value = raw.trim();
533
+ if (value === "true") return true;
534
+ if (value === "false") return false;
535
+ if (/^-?\d+$/.test(value)) return Number(value);
536
+ if (value.startsWith("[") && value.endsWith("]")) {
537
+ const inner = value.slice(1, -1).trim();
538
+ return inner ? inner.split(",").map((item) => parseGoalPlanScalar(item)) : [];
539
+ }
540
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
541
+ return value;
542
+ }
543
+
544
+ function parseGoalPlanFrontmatter(text) {
545
+ const lines = text.split(/\r?\n/);
546
+ if (lines[0]?.trim() !== "---") return null;
547
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
548
+ if (end < 0) return null;
549
+ const meta = {};
550
+ let currentListKey = null;
551
+ for (const line of lines.slice(1, end)) {
552
+ const trimmed = line.trim();
553
+ if (!trimmed || trimmed.startsWith("#")) continue;
554
+ if (currentListKey && /^\s+-\s+/.test(line)) {
555
+ meta[currentListKey].push(parseGoalPlanScalar(line.replace(/^\s+-\s+/, "")));
556
+ continue;
557
+ }
558
+ currentListKey = null;
559
+ const colon = line.indexOf(":");
560
+ if (colon < 1) return null;
561
+ const key = line.slice(0, colon).trim();
562
+ if (key in meta) return null;
563
+ const raw = line.slice(colon + 1).trim();
564
+ if (!raw) {
565
+ meta[key] = [];
566
+ currentListKey = key;
567
+ } else {
568
+ meta[key] = parseGoalPlanScalar(raw);
569
+ }
570
+ }
571
+ return meta;
572
+ }
573
+
574
+ function validGoalPlanV4(meta, change) {
575
+ const required = [
576
+ "schema_version", "artifact", "change", "status", "modes", "orchestration", "lead",
577
+ "implementation_agent_limit", "ticket_workspace_policy", "integration_gate", "ready_for_execution",
578
+ ];
579
+ if (!isObject(meta) || !hasExactKeys(meta, required)) return false;
580
+ if (
581
+ meta.schema_version !== 4 ||
582
+ meta.artifact !== "goal-plan" ||
583
+ meta.change !== change ||
584
+ !new Set(["draft", "ready", "in_progress", "completed", "blocked"]).has(meta.status) ||
585
+ meta.orchestration !== "lead-directed" ||
586
+ !nonEmptyString(meta.lead) ||
587
+ !Number.isInteger(meta.implementation_agent_limit) ||
588
+ meta.implementation_agent_limit < 1 ||
589
+ !new Set(["current", "required"]).has(meta.ticket_workspace_policy) ||
590
+ !new Set(["direct-parent", "candidate-merge"]).has(meta.integration_gate) ||
591
+ (meta.ticket_workspace_policy === "current" && meta.integration_gate !== "direct-parent") ||
592
+ (meta.ticket_workspace_policy === "required" && meta.integration_gate !== "candidate-merge") ||
593
+ typeof meta.ready_for_execution !== "boolean" ||
594
+ !Array.isArray(meta.modes)
595
+ ) return false;
596
+ return meta.modes.every((mode) => new Set(["migration", "high-assurance", "reference-conformance", "release-coordination"]).has(mode)) &&
597
+ new Set(meta.modes).size === meta.modes.length;
598
+ }
599
+
600
+ function validGoalPlanV6(meta, change) {
601
+ const required = [
602
+ "schema_version", "artifact", "change", "status", "modes", "orchestration", "lead",
603
+ "implementation_agent_limit", "integration_attempt_limit", "ticket_workspace_policy", "integration_gate", "ready_for_execution",
604
+ ];
605
+ if (!isObject(meta) || !hasExactKeys(meta, required) || meta.schema_version !== GOAL_PLAN_SCHEMA_VERSION) return false;
606
+ const { integration_attempt_limit: integrationAttemptLimit, ...previous } = meta;
607
+ return validGoalPlanV4({ ...previous, schema_version: 4 }, change) &&
608
+ Number.isInteger(integrationAttemptLimit) && integrationAttemptLimit >= 1;
609
+ }
610
+
611
+ async function validateGoalPlanV4(changeRoot, change) {
612
+ const path = join(changeRoot, "goal-plan.md");
613
+ if (!(await exists(path))) return [];
614
+ const text = await readFile(path, "utf8");
615
+ return validGoalPlanV4(parseGoalPlanFrontmatter(text), change)
616
+ ? []
617
+ : ["Goal Plan is not the complete fixed Lead/candidate-integration v4 contract: " + change];
618
+ }
619
+
620
+ async function validateGoalPlanV6(changeRoot, change) {
621
+ const path = join(changeRoot, "goal-plan.md");
622
+ if (!(await exists(path))) return [];
623
+ return validGoalPlanV6(parseGoalPlanFrontmatter(await readFile(path, "utf8")), change)
624
+ ? []
625
+ : ["Goal Plan is not the complete Lead/workspace v6 contract: " + change];
626
+ }
627
+
628
+ function validSpecdevConfigV4(config) {
629
+ const rootKeys = ["schema_version", "interaction_language", "artifact_language", "git", "execution", "verification", "planning"];
630
+ if (
631
+ !isObject(config) ||
632
+ !hasExactKeys(config, rootKeys) ||
633
+ config.schema_version !== 4 ||
634
+ !nonEmptyString(config.interaction_language) ||
635
+ !nonEmptyString(config.artifact_language) ||
636
+ !isObject(config.git) ||
637
+ !isObject(config.execution) ||
638
+ !isObject(config.verification) ||
639
+ !isObject(config.planning)
640
+ ) return false;
641
+ if (!hasExactKeys(config.git, ["default_branch"]) || !(config.git.default_branch === null || typeof config.git.default_branch === "string")) return false;
642
+ if (!hasExactKeys(config.execution, ["max_implementation_agents", "deep_ticket_human_approval", "shared_path_owner"])) return false;
643
+ if (
644
+ !Number.isInteger(config.execution.max_implementation_agents) ||
645
+ config.execution.max_implementation_agents < 1 ||
646
+ typeof config.execution.deep_ticket_human_approval !== "boolean" ||
647
+ !nonEmptyString(config.execution.shared_path_owner)
648
+ ) return false;
649
+ for (const key of ["test", "typecheck", "lint", "build"]) {
650
+ if (!(key in config.verification) || !stringOrNull(config.verification[key])) return false;
651
+ }
652
+ return new Set(["lite", "standard", "deep"]).has(config.planning.default_depth) &&
653
+ typeof config.planning.require_ready_gate === "boolean" &&
654
+ typeof config.planning.require_evidence === "boolean";
655
+ }
656
+
657
+ function validSpecdevConfigV5(config) {
658
+ const rootKeys = ["schema_version", "interaction_language", "artifact_language", "git", "execution", "verification", "planning"];
659
+ if (!isObject(config) || !hasExactKeys(config, rootKeys) || config.schema_version !== CONFIG_SCHEMA_VERSION || !isObject(config.git) || !isObject(config.execution) || !isObject(config.verification) || !isObject(config.planning)) return false;
660
+ if (!hasExactKeys(config.git, ["default_branch"]) || !(config.git.default_branch === null || typeof config.git.default_branch === "string")) return false;
661
+ if (!hasExactKeys(config.execution, ["max_implementation_agents", "max_integration_attempts", "deep_ticket_human_approval", "shared_path_owner"])) return false;
662
+ if (!Number.isInteger(config.execution.max_implementation_agents) || config.execution.max_implementation_agents < 1 || !Number.isInteger(config.execution.max_integration_attempts) || config.execution.max_integration_attempts < 1 || typeof config.execution.deep_ticket_human_approval !== "boolean" || !nonEmptyString(config.execution.shared_path_owner)) return false;
663
+ for (const key of ["test", "typecheck", "lint", "build"]) if (!(key in config.verification) || !stringOrNull(config.verification[key])) return false;
664
+ return new Set(["lite", "standard", "deep"]).has(config.planning.default_depth) && typeof config.planning.require_ready_gate === "boolean" && typeof config.planning.require_evidence === "boolean" && Number.isInteger(config.planning.ui_prototype_default_variants) && config.planning.ui_prototype_default_variants >= 1 && Number.isInteger(config.planning.ui_prototype_max_variants) && config.planning.ui_prototype_max_variants >= config.planning.ui_prototype_default_variants;
665
+ }
666
+
308
667
  async function validateSpecdev(speculoRoot) {
309
668
  const statusPath = join(speculoRoot, ".speculo", "specdev", "status.json");
310
669
  if (!(await exists(statusPath))) return [];
311
670
  const failures = [];
312
671
  const status = await readJson(statusPath);
313
- if (status.schema_version !== 4 || status.workflow !== "specdev" || !Array.isArray(status.active) || !Array.isArray(status.archived)) {
314
- return [".speculo/specdev/status.json is not SpecDev global status v4"];
672
+ if (status.schema_version !== 5 || status.workflow !== "specdev" || !Array.isArray(status.active) || !Array.isArray(status.archived)) {
673
+ return [".speculo/specdev/status.json is not SpecDev global status v5"];
315
674
  }
316
675
  const active = new Set();
317
676
  for (const entry of status.active) {
@@ -326,12 +685,14 @@ async function validateSpecdev(speculoRoot) {
326
685
  failures.push("missing active change state: " + entry.change);
327
686
  } else {
328
687
  const changeStatus = await readJson(path);
329
- if (
330
- changeStatus.schema_version !== 3 ||
331
- changeStatus.artifact !== "change-status" ||
332
- changeStatus.change !== entry.change ||
333
- !new Set(["active", "blocked", "completed"]).has(changeStatus.change_status)
334
- ) failures.push("invalid active change state: " + entry.change);
688
+ if (changeStatus.schema_version === CHANGE_STATUS_SCHEMA_VERSION) {
689
+ failures.push(...validateChangeStatusV6(changeStatus, entry.change, new Set(["active", "blocked", "completed"])));
690
+ } else {
691
+ failures.push(...validateChangeStatusV4(changeStatus, entry.change, new Set(["active", "blocked", "completed"])));
692
+ }
693
+ failures.push(...(await exists(join(dirname(path), "goal-plan.md")) && (await readFile(join(dirname(path), "goal-plan.md"), "utf8")).startsWith("---\nschema_version: 6")
694
+ ? await validateGoalPlanV6(dirname(path), entry.change)
695
+ : await validateGoalPlanV4(dirname(path), entry.change)));
335
696
  }
336
697
  }
337
698
  const archived = new Set();
@@ -348,12 +709,9 @@ async function validateSpecdev(speculoRoot) {
348
709
  failures.push("missing archived change state: " + name);
349
710
  } else {
350
711
  const archivedStatus = await readJson(path);
351
- if (
352
- archivedStatus.schema_version !== 3 ||
353
- archivedStatus.artifact !== "change-status" ||
354
- archivedStatus.change !== name ||
355
- archivedStatus.change_status !== "archived"
356
- ) failures.push("invalid archived change state: " + name);
712
+ failures.push(...(archivedStatus.schema_version === CHANGE_STATUS_SCHEMA_VERSION
713
+ ? validateChangeStatusV6(archivedStatus, name, new Set(["archived"]))
714
+ : validateChangeStatusV4(archivedStatus, name, new Set(["archived"]))));
357
715
  }
358
716
  }
359
717
  const changesRoot = join(speculoRoot, ".speculo", "specdev", "changes");
@@ -379,7 +737,7 @@ async function validateSpecdev(speculoRoot) {
379
737
  const configPath = join(speculoRoot, ".speculo", "specdev", "config.json");
380
738
  if (await exists(configPath)) {
381
739
  const config = await readJson(configPath);
382
- if (config.schema_version !== 3) failures.push(".speculo/specdev/config.json is not schema v3");
740
+ if (!validSpecdevConfigV5(config)) failures.push(".speculo/specdev/config.json is not the complete schema-v5 execution contract");
383
741
  }
384
742
  return failures;
385
743
  }
@@ -438,6 +796,12 @@ async function validateActive(speculoRoot, allowPending = false) {
438
796
  if (failures.length) throw new Error("Migrated runtime validation failed:\n- " + failures.join("\n- "));
439
797
  }
440
798
 
799
+ async function assertNoSymlinks(root) {
800
+ for (const entry of await walk(root)) {
801
+ if (entry.type === "symlink") throw new Error("Runtime contains a symbolic link: " + entry.path);
802
+ }
803
+ }
804
+
441
805
  async function applyAction(ctx, stagedSpeculo, action) {
442
806
  if (action.kind === "keep-current") return;
443
807
  const destination = inside(stagedSpeculo, action.to);
@@ -451,7 +815,11 @@ async function applyAction(ctx, stagedSpeculo, action) {
451
815
  const source = inside(ctx.backupRoot, action.from);
452
816
  const stat = await lstat(source);
453
817
  await rm(destination, { recursive: true, force: true });
454
- await cp(source, destination, { recursive: stat.isDirectory(), force: true });
818
+ await cp(source, destination, {
819
+ recursive: stat.isDirectory(),
820
+ force: true,
821
+ verbatimSymlinks: true,
822
+ });
455
823
  return;
456
824
  }
457
825
  await writeFile(destination, JSON.stringify(action.value, null, 2) + "\n", "utf8");
@@ -474,12 +842,15 @@ async function apply(projectRoot, planPath, confirmed) {
474
842
  try {
475
843
  await cp(ctx.speculoRoot, stagedSpeculo, { recursive: true, force: true });
476
844
  for (const action of plan.actions) await applyAction(ctx, stagedSpeculo, action);
845
+ await assertNoSymlinks(stagedSpeculo);
477
846
  await validateActive(stagedSpeculo, true);
478
847
  await rm(join(stagedSpeculo, ".speculo", "migration.json"), { force: true });
848
+ await assertNoSymlinks(stagedSpeculo);
479
849
  await rename(ctx.speculoRoot, rollbackRoot);
480
850
  oldMoved = true;
481
851
  await rename(stagedSpeculo, ctx.speculoRoot);
482
852
  newInstalled = true;
853
+ await assertNoSymlinks(ctx.speculoRoot);
483
854
  await validateActive(ctx.speculoRoot);
484
855
  const installedCtx = await contextWithCompletedMigration(ctx.projectRoot);
485
856
  const postIssues = await validateBackup(installedCtx, false);