@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.
- package/dist/src/migrations.js +604 -23
- package/dist/src/migrations.js.map +1 -1
- package/package.json +1 -1
- package/template/canonical/canonical-specdev-engineering-cognitive-mentor.md +171 -455
- package/template/canonical/canonical-specdev-goal-plan.md +686 -1117
- package/template/canonical/canonical-specdev-grill-with-docs.md +172 -456
- package/template/canonical/canonical-specdev-spec.md +199 -493
- package/template/canonical/canonical-specdev-tickets.md +378 -597
- package/template/canonical/canonical-specdev-wayfinder.md +170 -454
- 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 +322 -33
- package/template/workflows/specdev/I-implement/I-implement.md +97 -143
- package/template/workflows/specdev/I-implement/evidence-template.md +60 -48
- package/template/workflows/specdev/I-implement/execution-preflight.md +29 -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 +3 -5
- 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 +40 -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 +42 -76
- 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 +27 -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 +136 -373
- package/template/workflows/specdev/common/schemas/config.schema.json +9 -11
- package/template/workflows/specdev/common/schemas/goal-plan.schema.json +24 -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/validate-specdev.mjs +386 -209
- 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 时,`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
|
|
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 满足 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-
|
|
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
|
|
181
|
-
issues.push("backup
|
|
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 !==
|
|
248
|
-
throw new Error("Plan must use schema_version
|
|
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
|
|
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 (
|
|
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
|
-
|
|
271
|
+
decisions.set(source, decision);
|
|
266
272
|
}
|
|
267
273
|
for (const source of expectedSources) {
|
|
268
|
-
if (!
|
|
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 (
|
|
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
|
-
|
|
280
|
-
if (
|
|
281
|
-
const source = inside(ctx.backupRoot,
|
|
282
|
-
if (!(await exists(source))) throw new Error(`actions[${index}] source does not exist: ${
|
|
283
|
-
}
|
|
284
|
-
|
|
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 !==
|
|
314
|
-
return [".speculo/specdev/status.json is not SpecDev global status
|
|
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
|
-
|
|
330
|
-
changeStatus
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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);
|