@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
@@ -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 时,`execution.max_parallel` 只作为旧并发偏好的输入,收敛到 `execution.max_implementation_agents: 1..3`;删除旧的自动提交和条件 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 不是 v5 时必须重新规划固定 Lead、Ticket worktree 和 candidate 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 满足 v4 合同,global status、change-status 与 Goal Plan 满足 v5 合同;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,
@@ -26,7 +27,7 @@ function usage() {
26
27
  " node migrate-runtime-state.mjs fingerprint --project-root <path> --target <relative-path>",
27
28
  " node migrate-runtime-state.mjs apply --project-root <path> --plan <plan.json> --confirmed",
28
29
  "",
29
- "inspect and fingerprint are read-only. apply requires an explicit confirmed schema-v1 plan.",
30
+ "inspect and fingerprint are read-only. apply requires an explicit confirmed schema-v2 plan.",
30
31
  "",
31
32
  ].join("\n"));
32
33
  return 2;
@@ -101,7 +102,7 @@ async function walk(root, current = root, options = {}) {
101
102
  values.push({ path: item, type: "directory" });
102
103
  values.push(...await walk(root, path, options));
103
104
  } else if (entry.isSymbolicLink()) {
104
- values.push({ path: item, type: "symlink" });
105
+ values.push({ path: item, type: "symlink", target: await readlink(path) });
105
106
  } else if (entry.isFile()) {
106
107
  const stat = await lstat(path);
107
108
  values.push({ path: item, type: "file", bytes: stat.size, sha256: await sha256(path) });
@@ -164,6 +165,9 @@ async function validateBackup(ctx, checkMigrationWorkspace = true) {
164
165
  if (entry.type === "file" && (typeof entry.sha256 !== "string" || typeof entry.bytes !== "number")) {
165
166
  throw new Error("file manifest entry has no hash or size: " + item);
166
167
  }
168
+ if (entry.type === "symlink" && typeof entry.target !== "string") {
169
+ throw new Error("symlink manifest entry has no target: " + item);
170
+ }
167
171
  expected.set(item, entry);
168
172
  } catch (error) {
169
173
  issues.push(String(error));
@@ -177,8 +181,10 @@ async function validateBackup(ctx, checkMigrationWorkspace = true) {
177
181
  issues.push("undeclared backup entry: " + entry.path);
178
182
  continue;
179
183
  }
180
- if (entry.type === "symlink" || declared.type === "symlink") {
181
- issues.push("backup symlink requires manual recovery outside this command: " + entry.path);
184
+ if (entry.type !== declared.type) {
185
+ issues.push("backup entry type mismatch: " + entry.path);
186
+ } else if (entry.type === "symlink") {
187
+ if (entry.target !== declared.target) issues.push("backup symlink target mismatch: " + entry.path);
182
188
  } else if (entry.sha256 !== declared.sha256 || entry.bytes !== declared.bytes) {
183
189
  issues.push("backup hash or size mismatch: " + entry.path);
184
190
  }
@@ -244,51 +250,74 @@ function pathsOverlap(left, right) {
244
250
  }
245
251
 
246
252
  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");
253
+ if (plan.schema_version !== 2 || !Array.isArray(plan.source_decisions) || !Array.isArray(plan.actions)) {
254
+ throw new Error("Plan must use schema_version 2 and contain source_decisions and actions");
249
255
  }
250
256
  if (plan.backup_manifest_sha256 !== await sha256(ctx.manifestPath)) throw new Error("Plan backup manifest fingerprint does not match");
251
257
  const install = await readJson(join(ctx.stateRoot, "install.json"));
252
258
  const workflows = Array.isArray(install.workflows) ? install.workflows.filter((item) => typeof item === "string") : [];
253
259
  const expectedSources = new Set(ctx.manifest.files.map((entry) => entry.path));
254
- const seenSources = new Set();
260
+ const decisions = new Map();
255
261
  for (const [index, decision] of plan.source_decisions.entries()) {
256
262
  if (!decision || typeof decision !== "object" || !VALID_DECISIONS.has(decision.disposition)) {
257
263
  throw new Error(`source_decisions[${index}] has an invalid disposition`);
258
264
  }
259
265
  const source = safeRelative(decision.path, `source_decisions[${index}] path`);
260
266
  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}`);
267
+ if (decisions.has(source)) throw new Error(`source_decisions[${index}] repeats ${source}`);
262
268
  if (typeof decision.target !== "string" || !allowedDecisionTarget(decision.target, decision.disposition, workflows)) {
263
269
  throw new Error(`source_decisions[${index}] target is outside runtime ownership: ${decision.target}`);
264
270
  }
265
- seenSources.add(source);
271
+ decisions.set(source, decision);
266
272
  }
267
273
  for (const source of expectedSources) {
268
- if (!seenSources.has(source)) throw new Error("Plan has no decision for backup entry: " + source);
274
+ if (!decisions.has(source)) throw new Error("Plan has no decision for backup entry: " + source);
269
275
  }
276
+
277
+ const actionSources = new Set();
270
278
  const seenTargets = new Set();
271
279
  for (const [index, action] of plan.actions.entries()) {
272
280
  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}`);
281
+ if (typeof action.source_decision !== "string") throw new Error(`actions[${index}] must explicitly name source_decision`);
282
+ const actionSource = safeRelative(action.source_decision, `actions[${index}] source_decision`);
283
+ const decision = decisions.get(actionSource);
284
+ if (!decision) throw new Error(`actions[${index}] source_decision is not in source_decisions: ${actionSource}`);
285
+ if (actionSources.has(actionSource)) throw new Error(`actions[${index}] duplicates source action: ${actionSource}`);
286
+ actionSources.add(actionSource);
287
+ if (typeof action.to !== "string" || !allowedTarget(action.to, workflows)) {
288
+ throw new Error(`actions[${index}] target is outside runtime ownership: ${action.to}`);
289
+ }
290
+ if (decision.target !== action.to) throw new Error(`actions[${index}] target must match source decision target`);
274
291
  for (const target of seenTargets) {
275
292
  if (pathsOverlap(target, action.to)) throw new Error(`actions[${index}] overlaps target ${target}`);
276
293
  }
277
294
  seenTargets.add(action.to);
278
295
  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") {
296
+ if (decision.disposition !== "restore") throw new Error(`actions[${index}] copy must implement a restore decision`);
297
+ if (action.from !== actionSource) throw new Error(`actions[${index}] copy source must match source_decision`);
298
+ const source = inside(ctx.backupRoot, actionSource);
299
+ if (!(await exists(source))) throw new Error(`actions[${index}] source does not exist: ${actionSource}`);
300
+ } else if (action.kind === "replace-json") {
301
+ if (!new Set(["merge-json", "replace-json"]).has(decision.disposition)) {
302
+ throw new Error(`actions[${index}] replace-json must implement a merge-json or replace-json decision`);
303
+ }
285
304
  if (!action.to.endsWith(".json") || action.value === undefined) throw new Error(`actions[${index}] replace-json needs a JSON target and value`);
286
305
  JSON.stringify(action.value);
306
+ } else if (action.kind === "keep-current") {
307
+ if (decision.disposition !== "keep-current") throw new Error(`actions[${index}] keep-current must implement a keep-current decision`);
308
+ } else if (decision.disposition !== "remove-current") {
309
+ throw new Error(`actions[${index}] remove-current must implement a remove-current decision`);
287
310
  }
288
311
  if (typeof action.expected_target !== "string") throw new Error(`actions[${index}] must contain expected_target`);
289
312
  const currentFingerprint = await fingerprint(inside(ctx.speculoRoot, action.to));
290
313
  if (currentFingerprint !== action.expected_target) throw new Error(`actions[${index}] target drifted: ${action.to}`);
291
314
  }
315
+
316
+ for (const [source, decision] of decisions) {
317
+ if (decision.disposition !== "keep-current" && !actionSources.has(source)) {
318
+ throw new Error(`source_decisions entry requires an action: ${source}`);
319
+ }
320
+ }
292
321
  return workflows;
293
322
  }
294
323
 
@@ -305,13 +334,265 @@ async function validateJsonTree(root) {
305
334
  return failures;
306
335
  }
307
336
 
337
+ function isObject(value) {
338
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
339
+ }
340
+
341
+ function hasExactKeys(value, expected) {
342
+ const actual = Object.keys(value).sort();
343
+ const wanted = [...expected].sort();
344
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
345
+ }
346
+
347
+ function nonEmptyString(value) {
348
+ return typeof value === "string" && value.length > 0;
349
+ }
350
+
351
+ function stringOrNull(value) {
352
+ return value === null || typeof value === "string";
353
+ }
354
+
355
+ function stringArray(value) {
356
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
357
+ }
358
+
359
+ const CHANGE_NAME_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
360
+ const ARCHIVE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/archive\/[^<]+<\/Path>$/;
361
+ const EVIDENCE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/changes\/[^<]+\/evidence\/T-[0-9]{2,}\.md<\/Path>$/;
362
+
363
+ function validIntegrationV4(integration, worktreeStatus, sourceCheckpoint, change, ticketId) {
364
+ const required = [
365
+ "status", "parent_before_sha", "source_sha", "candidate_sha", "candidate_branch",
366
+ "candidate_workspace_ref", "result_sha", "method", "conflict_paths", "verification",
367
+ "e2e", "evidence", "attempts",
368
+ ];
369
+ if (!isObject(integration) || !hasExactKeys(integration, required)) return false;
370
+ if (!new Set(["pending", "candidate", "passed", "failed", "stale"]).has(integration.status)) return false;
371
+ if (!new Set([null, "fast-forward", "merge-commit"]).has(integration.method)) return false;
372
+ if (!new Set(["pending", "passed", "failed"]).has(integration.verification)) return false;
373
+ for (const key of ["parent_before_sha", "source_sha", "candidate_sha", "candidate_branch", "result_sha"]) {
374
+ if (!stringOrNull(integration[key])) return false;
375
+ }
376
+ if (
377
+ integration.candidate_workspace_ref !== null &&
378
+ (typeof integration.candidate_workspace_ref !== "string" ||
379
+ !/^specdev-worktree\/\.integration\/T-[0-9]{2,}$/.test(integration.candidate_workspace_ref))
380
+ ) return false;
381
+ if (!stringArray(integration.conflict_paths)) return false;
382
+ if (!Number.isInteger(integration.attempts) || integration.attempts < 0) return false;
383
+ if (
384
+ typeof integration.evidence !== "string" ||
385
+ !EVIDENCE_PATH_PATTERN.test(integration.evidence) ||
386
+ integration.evidence !== `<Path>{roots.state}/specdev/changes/${change}/evidence/${ticketId}.md</Path>`
387
+ ) return false;
388
+
389
+ const e2e = integration.e2e;
390
+ if (!isObject(e2e) || !hasExactKeys(e2e, ["required", "status", "evidence"])) return false;
391
+ if (typeof e2e.required !== "boolean" || !new Set(["not-required", "pending", "passed", "failed"]).has(e2e.status)) return false;
392
+ if (!stringOrNull(e2e.evidence)) return false;
393
+ if (e2e.required === false && e2e.status !== "not-required") return false;
394
+ if (e2e.required === true && e2e.status === "not-required") return false;
395
+ if (e2e.required === true && e2e.status === "passed" && !nonEmptyString(e2e.evidence)) return false;
396
+
397
+ if (new Set(["integrating", "integrated", "removed"]).has(worktreeStatus)) {
398
+ if (
399
+ !nonEmptyString(integration.parent_before_sha) ||
400
+ !nonEmptyString(integration.source_sha) ||
401
+ integration.source_sha !== sourceCheckpoint ||
402
+ !nonEmptyString(integration.candidate_sha) ||
403
+ integration.candidate_branch !== `speculo/integration/${change}/${ticketId}` ||
404
+ integration.candidate_workspace_ref !== `specdev-worktree/.integration/${ticketId}` ||
405
+ !new Set(["fast-forward", "merge-commit"]).has(integration.method) ||
406
+ !Number.isInteger(integration.attempts) ||
407
+ integration.attempts < 1
408
+ ) return false;
409
+ }
410
+ if (worktreeStatus === "integrating" && integration.status !== "candidate") return false;
411
+ if (new Set(["integrated", "removed"]).has(worktreeStatus)) {
412
+ if (
413
+ integration.status !== "passed" ||
414
+ integration.verification !== "passed" ||
415
+ !nonEmptyString(integration.result_sha) ||
416
+ integration.result_sha !== integration.candidate_sha ||
417
+ !new Set(["not-required", "passed"]).has(e2e.status)
418
+ ) return false;
419
+ if (integration.method === "fast-forward" && (integration.candidate_sha !== sourceCheckpoint || integration.conflict_paths.length > 0)) return false;
420
+ if (integration.method === "merge-commit" && (integration.candidate_sha === sourceCheckpoint || integration.candidate_sha === integration.parent_before_sha)) return false;
421
+ }
422
+ return true;
423
+ }
424
+
425
+ function validChangeStatusV4(status, expectedChange, expectedStatus) {
426
+ const required = [
427
+ "schema_version", "artifact", "change", "change_status", "current_work", "created_at",
428
+ "updated_at", "completed_at", "archived", "archive_path", "blockers", "deviations", "worktrees",
429
+ ];
430
+ if (
431
+ !isObject(status) ||
432
+ !hasExactKeys(status, required) ||
433
+ status.schema_version !== 4 ||
434
+ status.artifact !== "change-status" ||
435
+ status.change !== expectedChange ||
436
+ !CHANGE_NAME_PATTERN.test(String(status.change)) ||
437
+ !expectedStatus.has(status.change_status) ||
438
+ !(status.current_work === null || typeof status.current_work === "string") ||
439
+ !nonEmptyString(status.created_at) ||
440
+ !nonEmptyString(status.updated_at) ||
441
+ !(status.completed_at === null || nonEmptyString(status.completed_at)) ||
442
+ typeof status.archived !== "boolean" ||
443
+ !(status.archive_path === null || (typeof status.archive_path === "string" && ARCHIVE_PATH_PATTERN.test(status.archive_path))) ||
444
+ !stringArray(status.blockers) ||
445
+ !stringArray(status.deviations) ||
446
+ !Array.isArray(status.worktrees)
447
+ ) return false;
448
+ if (status.change_status === "archived") {
449
+ if (status.archived !== true || typeof status.archive_path !== "string" || !ARCHIVE_PATH_PATTERN.test(status.archive_path)) return false;
450
+ } else if (status.archived !== false) {
451
+ return false;
452
+ }
453
+
454
+ const seenTickets = new Set();
455
+ return status.worktrees.every((worktree) => {
456
+ const requiredWorktree = [
457
+ "ticket_id", "owner", "implementation_owner", "integration_owner", "provider", "base_sha",
458
+ "parent_branch", "branch", "workspace_ref", "source_checkpoint", "integration", "status", "updated_at",
459
+ ];
460
+ if (!isObject(worktree) || !hasExactKeys(worktree, requiredWorktree) || worktree.provider !== "git") return false;
461
+ if (typeof worktree.ticket_id !== "string" || !/^T-[0-9]{2,}$/.test(worktree.ticket_id)) return false;
462
+ if (seenTickets.has(worktree.ticket_id)) return false;
463
+ seenTickets.add(worktree.ticket_id);
464
+ for (const key of ["owner", "implementation_owner", "integration_owner", "base_sha", "parent_branch", "branch", "updated_at"]) {
465
+ if (!nonEmptyString(worktree[key])) return false;
466
+ }
467
+ if (worktree.parent_branch === worktree.branch) return false;
468
+ if (worktree.workspace_ref !== `specdev-worktree/${worktree.ticket_id}`) return false;
469
+ if (!new Set(["planned", "active", "review", "integrating", "integrated", "removed", "blocked"]).has(worktree.status)) return false;
470
+ const sourceRequired = new Set(["review", "integrating", "integrated", "removed"]).has(worktree.status);
471
+ if (sourceRequired ? !nonEmptyString(worktree.source_checkpoint) : !stringOrNull(worktree.source_checkpoint)) return false;
472
+ return validIntegrationV4(worktree.integration, worktree.status, worktree.source_checkpoint, expectedChange, worktree.ticket_id);
473
+ });
474
+ }
475
+
476
+ function validateChangeStatusV4(status, expectedChange, expectedStatus) {
477
+ return validChangeStatusV4(status, expectedChange, expectedStatus)
478
+ ? []
479
+ : ["invalid or incomplete change-status v4 contract: " + expectedChange];
480
+ }
481
+
482
+ function parseGoalPlanScalar(raw) {
483
+ const value = raw.trim();
484
+ if (value === "true") return true;
485
+ if (value === "false") return false;
486
+ if (/^-?\d+$/.test(value)) return Number(value);
487
+ if (value.startsWith("[") && value.endsWith("]")) {
488
+ const inner = value.slice(1, -1).trim();
489
+ return inner ? inner.split(",").map((item) => parseGoalPlanScalar(item)) : [];
490
+ }
491
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
492
+ return value;
493
+ }
494
+
495
+ function parseGoalPlanFrontmatter(text) {
496
+ const lines = text.split(/\r?\n/);
497
+ if (lines[0]?.trim() !== "---") return null;
498
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
499
+ if (end < 0) return null;
500
+ const meta = {};
501
+ let currentListKey = null;
502
+ for (const line of lines.slice(1, end)) {
503
+ const trimmed = line.trim();
504
+ if (!trimmed || trimmed.startsWith("#")) continue;
505
+ if (currentListKey && /^\s+-\s+/.test(line)) {
506
+ meta[currentListKey].push(parseGoalPlanScalar(line.replace(/^\s+-\s+/, "")));
507
+ continue;
508
+ }
509
+ currentListKey = null;
510
+ const colon = line.indexOf(":");
511
+ if (colon < 1) return null;
512
+ const key = line.slice(0, colon).trim();
513
+ if (key in meta) return null;
514
+ const raw = line.slice(colon + 1).trim();
515
+ if (!raw) {
516
+ meta[key] = [];
517
+ currentListKey = key;
518
+ } else {
519
+ meta[key] = parseGoalPlanScalar(raw);
520
+ }
521
+ }
522
+ return meta;
523
+ }
524
+
525
+ function validGoalPlanV4(meta, change) {
526
+ const required = [
527
+ "schema_version", "artifact", "change", "status", "modes", "orchestration", "lead",
528
+ "implementation_agent_limit", "ticket_workspace_policy", "integration_gate", "ready_for_execution",
529
+ ];
530
+ if (!isObject(meta) || !hasExactKeys(meta, required)) return false;
531
+ if (
532
+ meta.schema_version !== 4 ||
533
+ meta.artifact !== "goal-plan" ||
534
+ meta.change !== change ||
535
+ !new Set(["draft", "ready", "in_progress", "completed", "blocked"]).has(meta.status) ||
536
+ meta.orchestration !== "lead-directed" ||
537
+ !nonEmptyString(meta.lead) ||
538
+ !Number.isInteger(meta.implementation_agent_limit) ||
539
+ meta.implementation_agent_limit < 1 ||
540
+ meta.implementation_agent_limit > 3 ||
541
+ meta.ticket_workspace_policy !== "required" ||
542
+ meta.integration_gate !== "candidate-merge" ||
543
+ typeof meta.ready_for_execution !== "boolean" ||
544
+ !Array.isArray(meta.modes)
545
+ ) return false;
546
+ return meta.modes.every((mode) => new Set(["migration", "high-assurance", "reference-conformance", "release-coordination"]).has(mode)) &&
547
+ new Set(meta.modes).size === meta.modes.length;
548
+ }
549
+
550
+ async function validateGoalPlanV4(changeRoot, change) {
551
+ const path = join(changeRoot, "goal-plan.md");
552
+ if (!(await exists(path))) return [];
553
+ const text = await readFile(path, "utf8");
554
+ return validGoalPlanV4(parseGoalPlanFrontmatter(text), change)
555
+ ? []
556
+ : ["Goal Plan is not the complete fixed Lead/candidate-integration v4 contract: " + change];
557
+ }
558
+
559
+ function validSpecdevConfigV4(config) {
560
+ const rootKeys = ["schema_version", "interaction_language", "artifact_language", "git", "execution", "verification", "planning"];
561
+ if (
562
+ !isObject(config) ||
563
+ !hasExactKeys(config, rootKeys) ||
564
+ config.schema_version !== 4 ||
565
+ !nonEmptyString(config.interaction_language) ||
566
+ !nonEmptyString(config.artifact_language) ||
567
+ !isObject(config.git) ||
568
+ !isObject(config.execution) ||
569
+ !isObject(config.verification) ||
570
+ !isObject(config.planning)
571
+ ) return false;
572
+ if (!hasExactKeys(config.git, ["default_branch"]) || !(config.git.default_branch === null || typeof config.git.default_branch === "string")) return false;
573
+ if (!hasExactKeys(config.execution, ["max_implementation_agents", "deep_ticket_human_approval", "shared_path_owner"])) return false;
574
+ if (
575
+ !Number.isInteger(config.execution.max_implementation_agents) ||
576
+ config.execution.max_implementation_agents < 1 ||
577
+ config.execution.max_implementation_agents > 3 ||
578
+ typeof config.execution.deep_ticket_human_approval !== "boolean" ||
579
+ !nonEmptyString(config.execution.shared_path_owner)
580
+ ) return false;
581
+ for (const key of ["test", "typecheck", "lint", "build"]) {
582
+ if (!(key in config.verification) || !stringOrNull(config.verification[key])) return false;
583
+ }
584
+ return new Set(["lite", "standard", "deep"]).has(config.planning.default_depth) &&
585
+ typeof config.planning.require_ready_gate === "boolean" &&
586
+ typeof config.planning.require_evidence === "boolean";
587
+ }
588
+
308
589
  async function validateSpecdev(speculoRoot) {
309
590
  const statusPath = join(speculoRoot, ".speculo", "specdev", "status.json");
310
591
  if (!(await exists(statusPath))) return [];
311
592
  const failures = [];
312
593
  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"];
594
+ if (status.schema_version !== 5 || status.workflow !== "specdev" || !Array.isArray(status.active) || !Array.isArray(status.archived)) {
595
+ return [".speculo/specdev/status.json is not SpecDev global status v5"];
315
596
  }
316
597
  const active = new Set();
317
598
  for (const entry of status.active) {
@@ -326,12 +607,12 @@ async function validateSpecdev(speculoRoot) {
326
607
  failures.push("missing active change state: " + entry.change);
327
608
  } else {
328
609
  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);
610
+ failures.push(...validateChangeStatusV4(
611
+ changeStatus,
612
+ entry.change,
613
+ new Set(["active", "blocked", "completed"]),
614
+ ));
615
+ failures.push(...await validateGoalPlanV4(dirname(path), entry.change));
335
616
  }
336
617
  }
337
618
  const archived = new Set();
@@ -348,12 +629,7 @@ async function validateSpecdev(speculoRoot) {
348
629
  failures.push("missing archived change state: " + name);
349
630
  } else {
350
631
  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);
632
+ failures.push(...validateChangeStatusV4(archivedStatus, name, new Set(["archived"])));
357
633
  }
358
634
  }
359
635
  const changesRoot = join(speculoRoot, ".speculo", "specdev", "changes");
@@ -379,7 +655,7 @@ async function validateSpecdev(speculoRoot) {
379
655
  const configPath = join(speculoRoot, ".speculo", "specdev", "config.json");
380
656
  if (await exists(configPath)) {
381
657
  const config = await readJson(configPath);
382
- if (config.schema_version !== 3) failures.push(".speculo/specdev/config.json is not schema v3");
658
+ if (!validSpecdevConfigV4(config)) failures.push(".speculo/specdev/config.json is not the complete schema-v4 execution contract");
383
659
  }
384
660
  return failures;
385
661
  }
@@ -438,6 +714,12 @@ async function validateActive(speculoRoot, allowPending = false) {
438
714
  if (failures.length) throw new Error("Migrated runtime validation failed:\n- " + failures.join("\n- "));
439
715
  }
440
716
 
717
+ async function assertNoSymlinks(root) {
718
+ for (const entry of await walk(root)) {
719
+ if (entry.type === "symlink") throw new Error("Runtime contains a symbolic link: " + entry.path);
720
+ }
721
+ }
722
+
441
723
  async function applyAction(ctx, stagedSpeculo, action) {
442
724
  if (action.kind === "keep-current") return;
443
725
  const destination = inside(stagedSpeculo, action.to);
@@ -451,7 +733,11 @@ async function applyAction(ctx, stagedSpeculo, action) {
451
733
  const source = inside(ctx.backupRoot, action.from);
452
734
  const stat = await lstat(source);
453
735
  await rm(destination, { recursive: true, force: true });
454
- await cp(source, destination, { recursive: stat.isDirectory(), force: true });
736
+ await cp(source, destination, {
737
+ recursive: stat.isDirectory(),
738
+ force: true,
739
+ verbatimSymlinks: true,
740
+ });
455
741
  return;
456
742
  }
457
743
  await writeFile(destination, JSON.stringify(action.value, null, 2) + "\n", "utf8");
@@ -474,12 +760,15 @@ async function apply(projectRoot, planPath, confirmed) {
474
760
  try {
475
761
  await cp(ctx.speculoRoot, stagedSpeculo, { recursive: true, force: true });
476
762
  for (const action of plan.actions) await applyAction(ctx, stagedSpeculo, action);
763
+ await assertNoSymlinks(stagedSpeculo);
477
764
  await validateActive(stagedSpeculo, true);
478
765
  await rm(join(stagedSpeculo, ".speculo", "migration.json"), { force: true });
766
+ await assertNoSymlinks(stagedSpeculo);
479
767
  await rename(ctx.speculoRoot, rollbackRoot);
480
768
  oldMoved = true;
481
769
  await rename(stagedSpeculo, ctx.speculoRoot);
482
770
  newInstalled = true;
771
+ await assertNoSymlinks(ctx.speculoRoot);
483
772
  await validateActive(ctx.speculoRoot);
484
773
  const installedCtx = await contextWithCompletedMigration(ctx.projectRoot);
485
774
  const postIssues = await validateBackup(installedCtx, false);