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