@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.
- package/dist/src/migrations.js +755 -23
- package/dist/src/migrations.js.map +1 -1
- package/package.json +1 -1
- package/template/canonical/canonical-specdev-engineering-cognitive-mentor.md +200 -448
- package/template/canonical/canonical-specdev-goal-plan.md +744 -1108
- package/template/canonical/canonical-specdev-grill-with-docs.md +201 -449
- package/template/canonical/canonical-specdev-spec.md +232 -486
- package/template/canonical/canonical-specdev-tickets.md +411 -590
- package/template/canonical/canonical-specdev-wayfinder.md +199 -447
- package/template/skills/migrate-runtime-state/SKILL.md +6 -6
- package/template/skills/migrate-runtime-state/references/migration-contract.md +9 -3
- package/template/skills/migrate-runtime-state/scripts/migrate-runtime-state.mjs +404 -33
- package/template/workflows/specdev/I-implement/I-implement.md +98 -143
- package/template/workflows/specdev/I-implement/evidence-template.md +66 -48
- package/template/workflows/specdev/I-implement/execution-preflight.md +31 -21
- package/template/workflows/specdev/I-implement/merge-conflict-protocol.md +12 -12
- package/template/workflows/specdev/I-init-setup/I-init-setup.md +4 -5
- package/template/workflows/specdev/I-init-setup/change-status-template.json +14 -1
- package/template/workflows/specdev/I-init-setup/config-template.json +7 -6
- package/template/workflows/specdev/I-init-setup/status-template.json +1 -1
- package/template/workflows/specdev/INDEX.md +11 -8
- package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +76 -102
- package/template/workflows/specdev/P-goal-plan/completion-control.md +26 -44
- package/template/workflows/specdev/P-goal-plan/goal-plan-template.md +46 -37
- package/template/workflows/specdev/P-goal-plan/lead-orchestration.md +34 -0
- package/template/workflows/specdev/P-goal-plan/orchestration-protocol.md +31 -46
- package/template/workflows/specdev/P-goal-plan/planning-modes.md +67 -89
- package/template/workflows/specdev/P-prototype/ui-prototype.md +1 -1
- package/template/workflows/specdev/T-tickets/T-tickets.md +6 -3
- package/template/workflows/specdev/T-tickets/ticket-readiness.md +5 -3
- package/template/workflows/specdev/T-tickets/ticket-template.md +8 -1
- package/template/workflows/specdev/T-tickets/tickets-map-template.md +5 -4
- package/template/workflows/specdev/_state/status.json +1 -1
- package/template/workflows/specdev/common/README.md +2 -2
- package/template/workflows/specdev/common/rules/change-completion.md +17 -20
- package/template/workflows/specdev/common/rules/deviation-control.md +1 -1
- package/template/workflows/specdev/common/rules/evidence-and-verification.md +31 -37
- package/template/workflows/specdev/common/rules/path-ownership.md +21 -23
- package/template/workflows/specdev/common/rules/readiness-and-depth.md +1 -1
- package/template/workflows/specdev/common/schemas/change-status.schema.json +153 -363
- package/template/workflows/specdev/common/schemas/config.schema.json +17 -13
- package/template/workflows/specdev/common/schemas/goal-plan.schema.json +33 -16
- package/template/workflows/specdev/common/schemas/status.schema.json +7 -63
- package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +42 -21
- package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +36 -21
- package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +46 -18
- package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +34 -31
- package/template/workflows/specdev/common/skills/subagent-delivery/references/external-web-subagent.md +10 -23
- package/template/workflows/specdev/common/skills/subagent-delivery/references/native-subagent.md +15 -25
- package/template/workflows/specdev/common/tools/README.md +2 -1
- package/template/workflows/specdev/common/tools/validate-specdev.mjs +591 -219
- package/template/workflows/specdev/I-implement/delegated-evidence-template.md +0 -12
- package/template/workflows/specdev/P-goal-plan/delegated-execution-template.md +0 -35
- package/template/workflows/specdev/P-goal-plan/delegated-execution.md +0 -59
- 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
|
|
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":
|
|
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": ".
|
|
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`
|
|
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
|
|
53
|
-
- plan 的 `backup_manifest_sha256` 与现场一致,`source_decisions` 恰好覆盖 manifest
|
|
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-
|
|
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
|
|
181
|
-
issues.push("backup
|
|
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 !==
|
|
248
|
-
throw new Error("Plan must use schema_version
|
|
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
|
|
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 (
|
|
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
|
-
|
|
274
|
+
decisions.set(source, decision);
|
|
266
275
|
}
|
|
267
276
|
for (const source of expectedSources) {
|
|
268
|
-
if (!
|
|
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 (
|
|
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
|
-
|
|
280
|
-
if (
|
|
281
|
-
const source = inside(ctx.backupRoot,
|
|
282
|
-
if (!(await exists(source))) throw new Error(`actions[${index}] source does not exist: ${
|
|
283
|
-
}
|
|
284
|
-
|
|
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 !==
|
|
314
|
-
return [".speculo/specdev/status.json is not SpecDev global status
|
|
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.
|
|
331
|
-
|
|
332
|
-
changeStatus
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
352
|
-
archivedStatus
|
|
353
|
-
archivedStatus
|
|
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
|
|
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, {
|
|
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);
|