@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
package/dist/src/migrations.js
CHANGED
|
@@ -6,6 +6,9 @@ const BACKUP_RELATIVE = ".speculo/back";
|
|
|
6
6
|
const INSTALL_RELATIVE = ".speculo/install.json";
|
|
7
7
|
const MIGRATION_RELATIVE = ".speculo/migration.json";
|
|
8
8
|
const SNAPSHOT_DIR = ".migration-snapshot";
|
|
9
|
+
const CONFIG_SCHEMA_VERSION = 5;
|
|
10
|
+
const GOAL_PLAN_SCHEMA_VERSION = 6;
|
|
11
|
+
const CHANGE_STATUS_SCHEMA_VERSION = 6;
|
|
9
12
|
const MANAGED_STATE_ENTRIES = new Set([
|
|
10
13
|
"README.md",
|
|
11
14
|
"workspace.json",
|
|
@@ -19,6 +22,15 @@ function toPosix(path) {
|
|
|
19
22
|
async function readJson(path) {
|
|
20
23
|
return JSON.parse(await readFile(path, "utf8"));
|
|
21
24
|
}
|
|
25
|
+
async function nodeExists(path) {
|
|
26
|
+
try {
|
|
27
|
+
await lstat(path);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
22
34
|
async function readTargetVersion(packageRoot) {
|
|
23
35
|
const manifest = await readJson(join(packageRoot, "package.json"));
|
|
24
36
|
if (typeof manifest.version !== "string" || !/^\d+\.\d+\.\d+(?:[-+].+)?$/.test(manifest.version)) {
|
|
@@ -58,14 +70,15 @@ async function sourceVersion(snapshotState) {
|
|
|
58
70
|
async function copySnapshot(previousRoot, snapshotRoot) {
|
|
59
71
|
await mkdir(snapshotRoot, { recursive: true });
|
|
60
72
|
const configPath = join(previousRoot, "config.json");
|
|
61
|
-
if (await
|
|
62
|
-
await cp(configPath, join(snapshotRoot, "config.json"), { force: true });
|
|
73
|
+
if (await nodeExists(configPath))
|
|
74
|
+
await cp(configPath, join(snapshotRoot, "config.json"), { force: true, verbatimSymlinks: true });
|
|
63
75
|
const previousState = join(previousRoot, ".speculo");
|
|
64
|
-
if (!(await
|
|
76
|
+
if (!(await nodeExists(previousState)))
|
|
65
77
|
return;
|
|
66
78
|
await cp(previousState, join(snapshotRoot, "state"), {
|
|
67
79
|
recursive: true,
|
|
68
80
|
force: true,
|
|
81
|
+
verbatimSymlinks: true,
|
|
69
82
|
filter: (source) => {
|
|
70
83
|
const item = toPosix(relative(previousState, source));
|
|
71
84
|
return item !== "back" && !item.startsWith("back/") && item !== "migration.json";
|
|
@@ -105,10 +118,10 @@ async function writeBackup(snapshotRoot, stagedRoot, source, target) {
|
|
|
105
118
|
await mkdir(backupRoot, { recursive: true });
|
|
106
119
|
const snapshotConfig = join(snapshotRoot, "config.json");
|
|
107
120
|
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 });
|
|
121
|
+
if (await nodeExists(snapshotConfig))
|
|
122
|
+
await cp(snapshotConfig, join(backupRoot, "config.json"), { force: true, verbatimSymlinks: true });
|
|
123
|
+
if (await nodeExists(snapshotState))
|
|
124
|
+
await cp(snapshotState, join(backupRoot, "state"), { recursive: true, force: true, verbatimSymlinks: true });
|
|
112
125
|
const files = await collectBackupEntries(backupRoot);
|
|
113
126
|
await writeFile(join(backupRoot, "manifest.json"), JSON.stringify({
|
|
114
127
|
schema_version: 1,
|
|
@@ -148,6 +161,506 @@ async function inspectJsonTree(root) {
|
|
|
148
161
|
function expectArray(value) {
|
|
149
162
|
return Array.isArray(value);
|
|
150
163
|
}
|
|
164
|
+
function isJsonObject(value) {
|
|
165
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
166
|
+
}
|
|
167
|
+
function hasExactKeys(value, expected) {
|
|
168
|
+
const actual = Object.keys(value).sort();
|
|
169
|
+
const wanted = [...expected].sort();
|
|
170
|
+
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
171
|
+
}
|
|
172
|
+
function isNonEmptyString(value) {
|
|
173
|
+
return typeof value === "string" && value.length > 0;
|
|
174
|
+
}
|
|
175
|
+
function isStringOrNull(value) {
|
|
176
|
+
return value === null || typeof value === "string";
|
|
177
|
+
}
|
|
178
|
+
function isStringArray(value) {
|
|
179
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
180
|
+
}
|
|
181
|
+
const CHANGE_NAME_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
182
|
+
const ARCHIVE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/archive\/[^<]+<\/Path>$/;
|
|
183
|
+
const EVIDENCE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/changes\/[^<]+\/evidence\/T-[0-9]{2,}\.md<\/Path>$/;
|
|
184
|
+
function isChangeName(value) {
|
|
185
|
+
return typeof value === "string" && CHANGE_NAME_PATTERN.test(value);
|
|
186
|
+
}
|
|
187
|
+
function hasCompleteV4Integration(integration, worktreeStatus, sourceCheckpoint, change, ticketId) {
|
|
188
|
+
const required = [
|
|
189
|
+
"status", "parent_before_sha", "source_sha", "candidate_sha", "candidate_branch",
|
|
190
|
+
"candidate_workspace_ref", "result_sha", "method", "conflict_paths", "verification",
|
|
191
|
+
"e2e", "evidence", "attempts",
|
|
192
|
+
];
|
|
193
|
+
if (!hasExactKeys(integration, required))
|
|
194
|
+
return false;
|
|
195
|
+
if (!new Set(["pending", "candidate", "passed", "failed", "stale"]).has(String(integration.status)))
|
|
196
|
+
return false;
|
|
197
|
+
if (![null, "direct-parent", "fast-forward", "merge-commit"].includes(integration.method))
|
|
198
|
+
return false;
|
|
199
|
+
if (!new Set(["pending", "passed", "failed"]).has(String(integration.verification)))
|
|
200
|
+
return false;
|
|
201
|
+
for (const key of ["parent_before_sha", "source_sha", "candidate_sha", "candidate_branch", "result_sha"]) {
|
|
202
|
+
if (!isStringOrNull(integration[key]))
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
if (integration.candidate_workspace_ref !== null &&
|
|
206
|
+
(typeof integration.candidate_workspace_ref !== "string" ||
|
|
207
|
+
!/^specdev-worktree\/\.integration\/T-[0-9]{2,}$/.test(integration.candidate_workspace_ref)))
|
|
208
|
+
return false;
|
|
209
|
+
if (!isStringArray(integration.conflict_paths))
|
|
210
|
+
return false;
|
|
211
|
+
if (!Number.isInteger(integration.attempts) || Number(integration.attempts) < 0)
|
|
212
|
+
return false;
|
|
213
|
+
if (typeof integration.evidence !== "string" ||
|
|
214
|
+
!EVIDENCE_PATH_PATTERN.test(integration.evidence) ||
|
|
215
|
+
integration.evidence !== `<Path>{roots.state}/specdev/changes/${change}/evidence/${ticketId}.md</Path>`)
|
|
216
|
+
return false;
|
|
217
|
+
const e2e = integration.e2e;
|
|
218
|
+
if (!isJsonObject(e2e) || !hasExactKeys(e2e, ["required", "status", "evidence"]))
|
|
219
|
+
return false;
|
|
220
|
+
if (typeof e2e.required !== "boolean" || !new Set(["not-required", "pending", "passed", "failed"]).has(String(e2e.status)))
|
|
221
|
+
return false;
|
|
222
|
+
if (!isStringOrNull(e2e.evidence))
|
|
223
|
+
return false;
|
|
224
|
+
if (e2e.required === false && e2e.status !== "not-required")
|
|
225
|
+
return false;
|
|
226
|
+
if (e2e.required === true && e2e.status === "not-required")
|
|
227
|
+
return false;
|
|
228
|
+
if (e2e.required === true && e2e.status === "passed" && !isNonEmptyString(e2e.evidence))
|
|
229
|
+
return false;
|
|
230
|
+
const currentWorkspace = integration.method === "direct-parent";
|
|
231
|
+
const lifecycleNeedsCandidate = new Set(["integrating", "integrated", "removed"]).has(String(worktreeStatus));
|
|
232
|
+
if (lifecycleNeedsCandidate) {
|
|
233
|
+
if (!isNonEmptyString(integration.parent_before_sha) ||
|
|
234
|
+
!isNonEmptyString(integration.source_sha) ||
|
|
235
|
+
integration.source_sha !== sourceCheckpoint ||
|
|
236
|
+
(currentWorkspace
|
|
237
|
+
? integration.candidate_sha !== null || integration.candidate_branch !== null || integration.candidate_workspace_ref !== null || integration.method !== "direct-parent"
|
|
238
|
+
: !isNonEmptyString(integration.candidate_sha) ||
|
|
239
|
+
integration.candidate_branch !== `speculo/integration/${change}/${ticketId}` ||
|
|
240
|
+
integration.candidate_workspace_ref !== `specdev-worktree/.integration/${ticketId}` ||
|
|
241
|
+
!new Set(["fast-forward", "merge-commit"]).has(String(integration.method))) ||
|
|
242
|
+
!Number.isInteger(integration.attempts) ||
|
|
243
|
+
Number(integration.attempts) < 1)
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
if (worktreeStatus === "integrating" && integration.status !== "candidate")
|
|
247
|
+
return false;
|
|
248
|
+
if (new Set(["integrated", "removed"]).has(String(worktreeStatus))) {
|
|
249
|
+
if (integration.status !== "passed" ||
|
|
250
|
+
integration.verification !== "passed" ||
|
|
251
|
+
!isNonEmptyString(integration.result_sha) ||
|
|
252
|
+
integration.result_sha !== (currentWorkspace ? integration.source_sha : integration.candidate_sha) ||
|
|
253
|
+
!new Set(["not-required", "passed"]).has(String(e2e.status)))
|
|
254
|
+
return false;
|
|
255
|
+
if (integration.method === "fast-forward" &&
|
|
256
|
+
(integration.candidate_sha !== sourceCheckpoint || integration.conflict_paths.length > 0))
|
|
257
|
+
return false;
|
|
258
|
+
if (integration.method === "merge-commit" &&
|
|
259
|
+
(integration.candidate_sha === sourceCheckpoint || integration.candidate_sha === integration.parent_before_sha))
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
function hasLegacyWorktreeState(status) {
|
|
265
|
+
return status.schema_version === 3 &&
|
|
266
|
+
status.worktrees !== undefined &&
|
|
267
|
+
(!Array.isArray(status.worktrees) || status.worktrees.length > 0);
|
|
268
|
+
}
|
|
269
|
+
function hasUnsupportedV3ChangeStatusFields(status) {
|
|
270
|
+
const allowed = new Set([
|
|
271
|
+
"schema_version", "artifact", "change", "change_status", "current_work",
|
|
272
|
+
"created_at", "updated_at", "completed_at", "archived", "archive_path",
|
|
273
|
+
"blockers", "deviations", "worktrees",
|
|
274
|
+
]);
|
|
275
|
+
return status.schema_version === 3 && Object.keys(status).some((key) => !allowed.has(key));
|
|
276
|
+
}
|
|
277
|
+
function hasUnsupportedV3SpecdevConfigFields(config) {
|
|
278
|
+
if (config.schema_version !== 3)
|
|
279
|
+
return false;
|
|
280
|
+
const rootAllowed = new Set([
|
|
281
|
+
"schema_version", "interaction_language", "artifact_language", "git",
|
|
282
|
+
"execution", "verification", "planning",
|
|
283
|
+
]);
|
|
284
|
+
if (Object.keys(config).some((key) => !rootAllowed.has(key)))
|
|
285
|
+
return true;
|
|
286
|
+
const git = isJsonObject(config.git) ? config.git : {};
|
|
287
|
+
const execution = isJsonObject(config.execution) ? config.execution : {};
|
|
288
|
+
return Object.keys(git).some((key) => !new Set(["auto_commit", "default_branch", "worktree_for_parallel"]).has(key)) ||
|
|
289
|
+
Object.keys(execution).some((key) => !new Set(["max_parallel", "deep_ticket_human_approval", "shared_path_owner"]).has(key));
|
|
290
|
+
}
|
|
291
|
+
function hasCompleteV4ChangeStatus(status) {
|
|
292
|
+
const required = [
|
|
293
|
+
"schema_version", "artifact", "change", "change_status", "current_work", "created_at",
|
|
294
|
+
"updated_at", "completed_at", "archived", "archive_path", "blockers", "deviations", "worktrees",
|
|
295
|
+
];
|
|
296
|
+
if (status.schema_version !== 4 ||
|
|
297
|
+
status.artifact !== "change-status" ||
|
|
298
|
+
!hasExactKeys(status, required) ||
|
|
299
|
+
typeof status.change !== "string" ||
|
|
300
|
+
!CHANGE_NAME_PATTERN.test(status.change) ||
|
|
301
|
+
!new Set(["active", "blocked", "completed", "archived"]).has(String(status.change_status)) ||
|
|
302
|
+
!(status.current_work === null || typeof status.current_work === "string") ||
|
|
303
|
+
!isNonEmptyString(status.created_at) ||
|
|
304
|
+
!isNonEmptyString(status.updated_at) ||
|
|
305
|
+
!(status.completed_at === null || isNonEmptyString(status.completed_at)) ||
|
|
306
|
+
typeof status.archived !== "boolean" ||
|
|
307
|
+
!(status.archive_path === null || (typeof status.archive_path === "string" && ARCHIVE_PATH_PATTERN.test(status.archive_path))) ||
|
|
308
|
+
!isStringArray(status.blockers) ||
|
|
309
|
+
!isStringArray(status.deviations) ||
|
|
310
|
+
!Array.isArray(status.worktrees))
|
|
311
|
+
return false;
|
|
312
|
+
if (status.change_status === "archived") {
|
|
313
|
+
if (status.archived !== true || typeof status.archive_path !== "string" || !ARCHIVE_PATH_PATTERN.test(status.archive_path))
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
else if (status.archived !== false) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
const seenTickets = new Set();
|
|
320
|
+
return status.worktrees.every((entry) => {
|
|
321
|
+
const requiredWorktree = [
|
|
322
|
+
"ticket_id", "owner", "implementation_owner", "integration_owner", "provider", "base_sha",
|
|
323
|
+
"parent_branch", "branch", "workspace_ref", "source_checkpoint", "integration", "status", "updated_at",
|
|
324
|
+
];
|
|
325
|
+
if (!isJsonObject(entry) || !hasExactKeys(entry, requiredWorktree) || entry.provider !== "git")
|
|
326
|
+
return false;
|
|
327
|
+
if (typeof entry.ticket_id !== "string" || !/^T-[0-9]{2,}$/.test(entry.ticket_id))
|
|
328
|
+
return false;
|
|
329
|
+
if (seenTickets.has(entry.ticket_id))
|
|
330
|
+
return false;
|
|
331
|
+
seenTickets.add(entry.ticket_id);
|
|
332
|
+
for (const key of ["owner", "implementation_owner", "integration_owner", "base_sha", "parent_branch", "branch", "updated_at"]) {
|
|
333
|
+
if (!isNonEmptyString(entry[key]))
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
const integration = isJsonObject(entry.integration) ? entry.integration : {};
|
|
337
|
+
const currentWorkspace = entry.workspace_ref === "current" || integration.method === "direct-parent";
|
|
338
|
+
if (currentWorkspace ? entry.parent_branch !== entry.branch : entry.parent_branch === entry.branch)
|
|
339
|
+
return false;
|
|
340
|
+
if (!currentWorkspace && entry.workspace_ref !== `specdev-worktree/${entry.ticket_id}`)
|
|
341
|
+
return false;
|
|
342
|
+
if (!new Set(["planned", "active", "review", "integrating", "integrated", "removed", "blocked"]).has(String(entry.status)))
|
|
343
|
+
return false;
|
|
344
|
+
const sourceRequired = new Set(["review", "integrating", "integrated", "removed"]).has(String(entry.status));
|
|
345
|
+
if (sourceRequired ? !isNonEmptyString(entry.source_checkpoint) : !isStringOrNull(entry.source_checkpoint))
|
|
346
|
+
return false;
|
|
347
|
+
return isJsonObject(integration) && hasCompleteV4Integration(integration, entry.status, entry.source_checkpoint, String(status.change), entry.ticket_id);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
function hasCompleteV4SpecdevConfig(config) {
|
|
351
|
+
const rootKeys = ["schema_version", "interaction_language", "artifact_language", "git", "execution", "verification", "planning"];
|
|
352
|
+
if (config.schema_version !== 4 ||
|
|
353
|
+
!hasExactKeys(config, rootKeys) ||
|
|
354
|
+
!isNonEmptyString(config.interaction_language) ||
|
|
355
|
+
!isNonEmptyString(config.artifact_language) ||
|
|
356
|
+
!isJsonObject(config.git) ||
|
|
357
|
+
!isJsonObject(config.execution) ||
|
|
358
|
+
!isJsonObject(config.verification) ||
|
|
359
|
+
!isJsonObject(config.planning)) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
if (!hasExactKeys(config.git, ["default_branch"]) || !(config.git.default_branch === null || typeof config.git.default_branch === "string"))
|
|
363
|
+
return false;
|
|
364
|
+
if (!hasExactKeys(config.execution, ["max_implementation_agents", "deep_ticket_human_approval", "shared_path_owner"]))
|
|
365
|
+
return false;
|
|
366
|
+
const limit = config.execution.max_implementation_agents;
|
|
367
|
+
if (!Number.isInteger(limit) || Number(limit) < 1 ||
|
|
368
|
+
typeof config.execution.deep_ticket_human_approval !== "boolean" ||
|
|
369
|
+
!isNonEmptyString(config.execution.shared_path_owner))
|
|
370
|
+
return false;
|
|
371
|
+
for (const key of ["test", "typecheck", "lint", "build"]) {
|
|
372
|
+
if (!(key in config.verification) || !isStringOrNull(config.verification[key]))
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
return new Set(["lite", "standard", "deep"]).has(String(config.planning.default_depth)) &&
|
|
376
|
+
typeof config.planning.require_ready_gate === "boolean" &&
|
|
377
|
+
typeof config.planning.require_evidence === "boolean";
|
|
378
|
+
}
|
|
379
|
+
function hasCompleteV5SpecdevConfig(config) {
|
|
380
|
+
const rootKeys = ["schema_version", "interaction_language", "artifact_language", "git", "execution", "verification", "planning"];
|
|
381
|
+
if (config.schema_version !== CONFIG_SCHEMA_VERSION || !hasExactKeys(config, rootKeys) ||
|
|
382
|
+
!isNonEmptyString(config.interaction_language) || !isNonEmptyString(config.artifact_language) ||
|
|
383
|
+
!isJsonObject(config.git) || !isJsonObject(config.execution) || !isJsonObject(config.verification) || !isJsonObject(config.planning))
|
|
384
|
+
return false;
|
|
385
|
+
if (!hasExactKeys(config.git, ["default_branch"]) || !(config.git.default_branch === null || typeof config.git.default_branch === "string"))
|
|
386
|
+
return false;
|
|
387
|
+
if (!hasExactKeys(config.execution, ["max_implementation_agents", "max_integration_attempts", "deep_ticket_human_approval", "shared_path_owner"]))
|
|
388
|
+
return false;
|
|
389
|
+
if (!Number.isInteger(config.execution.max_implementation_agents) || Number(config.execution.max_implementation_agents) < 1 ||
|
|
390
|
+
!Number.isInteger(config.execution.max_integration_attempts) || Number(config.execution.max_integration_attempts) < 1 ||
|
|
391
|
+
typeof config.execution.deep_ticket_human_approval !== "boolean" || !isNonEmptyString(config.execution.shared_path_owner))
|
|
392
|
+
return false;
|
|
393
|
+
for (const key of ["test", "typecheck", "lint", "build"]) {
|
|
394
|
+
if (!(key in config.verification) || !isStringOrNull(config.verification[key]))
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
return new Set(["lite", "standard", "deep"]).has(String(config.planning.default_depth)) &&
|
|
398
|
+
typeof config.planning.require_ready_gate === "boolean" &&
|
|
399
|
+
typeof config.planning.require_evidence === "boolean" &&
|
|
400
|
+
Number.isInteger(config.planning.ui_prototype_default_variants) && Number(config.planning.ui_prototype_default_variants) >= 1 &&
|
|
401
|
+
Number.isInteger(config.planning.ui_prototype_max_variants) && Number(config.planning.ui_prototype_max_variants) >= Number(config.planning.ui_prototype_default_variants);
|
|
402
|
+
}
|
|
403
|
+
function parseGoalPlanFrontmatter(text) {
|
|
404
|
+
const lines = text.split(/\r?\n/);
|
|
405
|
+
if (lines[0]?.trim() !== "---")
|
|
406
|
+
return null;
|
|
407
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
408
|
+
if (end < 0)
|
|
409
|
+
return null;
|
|
410
|
+
const meta = {};
|
|
411
|
+
let currentListKey = null;
|
|
412
|
+
for (const line of lines.slice(1, end)) {
|
|
413
|
+
const trimmed = line.trim();
|
|
414
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
415
|
+
continue;
|
|
416
|
+
if (currentListKey && /^\s+-\s+/.test(line)) {
|
|
417
|
+
meta[currentListKey].push(parseGoalPlanScalar(line.replace(/^\s+-\s+/, "")));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
currentListKey = null;
|
|
421
|
+
const colon = line.indexOf(":");
|
|
422
|
+
if (colon < 1)
|
|
423
|
+
return null;
|
|
424
|
+
const key = line.slice(0, colon).trim();
|
|
425
|
+
if (key in meta)
|
|
426
|
+
return null;
|
|
427
|
+
const raw = line.slice(colon + 1).trim();
|
|
428
|
+
if (!raw) {
|
|
429
|
+
meta[key] = [];
|
|
430
|
+
currentListKey = key;
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
meta[key] = parseGoalPlanScalar(raw);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return meta;
|
|
437
|
+
}
|
|
438
|
+
function parseGoalPlanScalar(raw) {
|
|
439
|
+
const value = raw.trim();
|
|
440
|
+
if (value === "true")
|
|
441
|
+
return true;
|
|
442
|
+
if (value === "false")
|
|
443
|
+
return false;
|
|
444
|
+
if (/^-?\d+$/.test(value))
|
|
445
|
+
return Number(value);
|
|
446
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
447
|
+
const inner = value.slice(1, -1).trim();
|
|
448
|
+
return inner ? inner.split(",").map((item) => parseGoalPlanScalar(item)) : [];
|
|
449
|
+
}
|
|
450
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
451
|
+
return value.slice(1, -1);
|
|
452
|
+
}
|
|
453
|
+
return value;
|
|
454
|
+
}
|
|
455
|
+
function hasCompleteV5GoalPlan(meta, change) {
|
|
456
|
+
if (meta.schema_version !== 5)
|
|
457
|
+
return false;
|
|
458
|
+
const v4Shape = { ...meta, schema_version: 4 };
|
|
459
|
+
if (!hasCompleteV4GoalPlan(v4Shape, change))
|
|
460
|
+
return false;
|
|
461
|
+
const status = String(meta.status);
|
|
462
|
+
return (new Set(["ready", "in_progress"]).has(status) && meta.ready_for_execution === true) ||
|
|
463
|
+
(new Set(["draft", "blocked", "completed"]).has(status) && meta.ready_for_execution === false);
|
|
464
|
+
}
|
|
465
|
+
function hasCompleteV6GoalPlan(meta, change) {
|
|
466
|
+
const required = [
|
|
467
|
+
"schema_version", "artifact", "change", "status", "modes", "orchestration", "lead",
|
|
468
|
+
"implementation_agent_limit", "integration_attempt_limit", "ticket_workspace_policy", "integration_gate", "ready_for_execution",
|
|
469
|
+
];
|
|
470
|
+
if (!hasExactKeys(meta, required) || meta.schema_version !== GOAL_PLAN_SCHEMA_VERSION)
|
|
471
|
+
return false;
|
|
472
|
+
const { integration_attempt_limit: integrationAttemptLimit, ...previous } = meta;
|
|
473
|
+
return hasCompleteV5GoalPlan({ ...previous, schema_version: 5 }, change) &&
|
|
474
|
+
Number.isInteger(integrationAttemptLimit) && Number(integrationAttemptLimit) >= 1;
|
|
475
|
+
}
|
|
476
|
+
function normalizeGoalPlanV5(text) {
|
|
477
|
+
const meta = parseGoalPlanFrontmatter(text);
|
|
478
|
+
if (meta === null || !hasCompleteV4GoalPlan(meta, String(meta.change)))
|
|
479
|
+
return text;
|
|
480
|
+
return text.replace(/^schema_version:\s*4\s*$/m, "schema_version: 5");
|
|
481
|
+
}
|
|
482
|
+
function normalizeGoalPlanV6(text, config) {
|
|
483
|
+
const meta = parseGoalPlanFrontmatter(text);
|
|
484
|
+
if (meta === null || meta.schema_version !== 5 || !hasCompleteV5GoalPlan(meta, String(meta.change)))
|
|
485
|
+
return text;
|
|
486
|
+
const execution = isJsonObject(config.execution) ? config.execution : {};
|
|
487
|
+
const attempts = Number.isInteger(execution.max_integration_attempts) && Number(execution.max_integration_attempts) >= 1
|
|
488
|
+
? Number(execution.max_integration_attempts)
|
|
489
|
+
: 3;
|
|
490
|
+
return text
|
|
491
|
+
.replace(/^schema_version:\s*5\s*$/m, "schema_version: 6")
|
|
492
|
+
.replace(/^(implementation_agent_limit:\s*[^\n]+)$/m, `$1\nintegration_attempt_limit: ${attempts}`);
|
|
493
|
+
}
|
|
494
|
+
function v5Authorization(scope) {
|
|
495
|
+
return { status: "not-authorized", source: null, granted_at: null, scope };
|
|
496
|
+
}
|
|
497
|
+
function normalizeChangeStatusV5(previous, globalEntry) {
|
|
498
|
+
if (previous.schema_version === 5)
|
|
499
|
+
return previous;
|
|
500
|
+
const change = String(previous.change);
|
|
501
|
+
const worktrees = Array.isArray(previous.worktrees) ? previous.worktrees.map((entry) => {
|
|
502
|
+
if (!isJsonObject(entry))
|
|
503
|
+
return entry;
|
|
504
|
+
const ticketId = String(entry.ticket_id);
|
|
505
|
+
const integration = isJsonObject(entry.integration) ? entry.integration : {};
|
|
506
|
+
const sourceCheckpoint = isStringOrNull(entry.source_checkpoint) ? entry.source_checkpoint : null;
|
|
507
|
+
return {
|
|
508
|
+
...entry,
|
|
509
|
+
workspace_ref: entry.workspace_ref === "current" || integration.method === "direct-parent"
|
|
510
|
+
? "current"
|
|
511
|
+
: `specdev-worktree/${change}/${ticketId}`,
|
|
512
|
+
branch: entry.workspace_ref === "current" || integration.method === "direct-parent"
|
|
513
|
+
? entry.parent_branch
|
|
514
|
+
: entry.branch,
|
|
515
|
+
integration: {
|
|
516
|
+
...integration,
|
|
517
|
+
parent_ref: typeof entry.parent_branch === "string" ? entry.parent_branch : null,
|
|
518
|
+
candidate_sha: entry.workspace_ref === "current" || integration.method === "direct-parent" ? null : integration.candidate_sha ?? null,
|
|
519
|
+
candidate_tree_sha: entry.workspace_ref === "current" || integration.method === "direct-parent" ? null : integration.candidate_sha ?? null,
|
|
520
|
+
candidate_branch: entry.workspace_ref === "current" || integration.method === "direct-parent" ? null : integration.candidate_branch ?? null,
|
|
521
|
+
candidate_workspace_ref: entry.workspace_ref === "current" || integration.method === "direct-parent"
|
|
522
|
+
? null
|
|
523
|
+
: integration.candidate_workspace_ref === null ? null : `specdev-worktree/.integration/${change}/${ticketId}`,
|
|
524
|
+
full_suite: { required: true, status: "pending", reason: null, evidence: null },
|
|
525
|
+
promotion_status: integration.status === "passed" ? "applied" : "pending",
|
|
526
|
+
source_sha: integration.source_sha ?? sourceCheckpoint,
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
}) : [];
|
|
530
|
+
const status = String(previous.change_status);
|
|
531
|
+
return {
|
|
532
|
+
...previous,
|
|
533
|
+
schema_version: 5,
|
|
534
|
+
current_work: typeof previous.current_work === "string" ? previous.current_work : globalEntry?.current_work ?? null,
|
|
535
|
+
works_run: Array.isArray(globalEntry?.works_run) && globalEntry.works_run.every((item) => typeof item === "string") ? globalEntry.works_run : [],
|
|
536
|
+
claimed_investigations: Array.isArray(globalEntry?.claimed_investigations) ? globalEntry.claimed_investigations.map((claim) => isJsonObject(claim) ? {
|
|
537
|
+
id: String(claim.id ?? ""), owner: String(claim.owner ?? ""), session: typeof claim.session === "string" ? claim.session : null, claimed_at: String(claim.claimed_at ?? ""),
|
|
538
|
+
} : claim) : [],
|
|
539
|
+
execution_authorization: {
|
|
540
|
+
implementation_commit: v5Authorization("Ticket implementation commits"),
|
|
541
|
+
local_candidate_integration: v5Authorization("Lead-owned local direct-parent or candidate integration and parent update"),
|
|
542
|
+
source_cleanup: v5Authorization("Source worktree and branch cleanup"),
|
|
543
|
+
},
|
|
544
|
+
leadership: { current: "unassigned", epoch: 1, assigned_at: String(previous.updated_at), history: [] },
|
|
545
|
+
archived: status === "archived",
|
|
546
|
+
archive_path: status === "archived" ? `<Path>{roots.state}/specdev/archive/${change.slice(0, 7)}/${change}</Path>` : null,
|
|
547
|
+
worktrees,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function hasCompleteV5ChangeStatus(status) {
|
|
551
|
+
const required = [
|
|
552
|
+
"schema_version", "artifact", "change", "change_status", "current_work", "works_run", "claimed_investigations",
|
|
553
|
+
"execution_authorization", "leadership", "created_at", "updated_at", "completed_at", "archived", "archive_path", "blockers", "deviations", "worktrees",
|
|
554
|
+
];
|
|
555
|
+
if (status.schema_version !== 5 || !hasExactKeys(status, required) || !isChangeName(status.change) ||
|
|
556
|
+
!isStringArray(status.works_run) || new Set(status.works_run).size !== status.works_run.length ||
|
|
557
|
+
!Array.isArray(status.claimed_investigations) || !isJsonObject(status.execution_authorization) || !isJsonObject(status.leadership))
|
|
558
|
+
return false;
|
|
559
|
+
const authorization = status.execution_authorization;
|
|
560
|
+
for (const key of ["implementation_commit", "local_candidate_integration", "source_cleanup"]) {
|
|
561
|
+
const entry = authorization[key];
|
|
562
|
+
if (!isJsonObject(entry) || !hasExactKeys(entry, ["status", "source", "granted_at", "scope"]) ||
|
|
563
|
+
!new Set(["authorized", "not-authorized", "revoked"]).has(String(entry.status)) || !isNonEmptyString(entry.scope) ||
|
|
564
|
+
!isStringOrNull(entry.source) || !isStringOrNull(entry.granted_at))
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
const leadership = status.leadership;
|
|
568
|
+
if (!isNonEmptyString(leadership.current) || !Number.isInteger(leadership.epoch) || Number(leadership.epoch) < 1 ||
|
|
569
|
+
!isNonEmptyString(leadership.assigned_at) || !Array.isArray(leadership.history) || !Array.isArray(status.worktrees))
|
|
570
|
+
return false;
|
|
571
|
+
const change = String(status.change);
|
|
572
|
+
const worktreeKeys = [
|
|
573
|
+
"ticket_id", "owner", "implementation_owner", "integration_owner", "provider", "base_sha",
|
|
574
|
+
"parent_branch", "branch", "workspace_ref", "source_checkpoint", "integration", "status", "updated_at",
|
|
575
|
+
];
|
|
576
|
+
const integrationKeys = [
|
|
577
|
+
"status", "parent_ref", "parent_before_sha", "source_sha", "candidate_sha", "candidate_tree_sha",
|
|
578
|
+
"candidate_branch", "candidate_workspace_ref", "result_sha", "method", "conflict_paths", "verification",
|
|
579
|
+
"full_suite", "e2e", "evidence", "attempts", "promotion_status",
|
|
580
|
+
];
|
|
581
|
+
return status.worktrees.every((entry) => {
|
|
582
|
+
if (!isJsonObject(entry) || !hasExactKeys(entry, worktreeKeys) || entry.provider !== "git" ||
|
|
583
|
+
typeof entry.ticket_id !== "string" || !/^T-[0-9]{2,}$/.test(entry.ticket_id) || !isJsonObject(entry.integration))
|
|
584
|
+
return false;
|
|
585
|
+
const current = entry.workspace_ref === "current";
|
|
586
|
+
if (current ? entry.branch !== entry.parent_branch : entry.workspace_ref !== `specdev-worktree/${change}/${entry.ticket_id}` || entry.branch === entry.parent_branch)
|
|
587
|
+
return false;
|
|
588
|
+
const integration = entry.integration;
|
|
589
|
+
if (!hasExactKeys(integration, integrationKeys) || !Number.isInteger(integration.attempts) || Number(integration.attempts) < 0 ||
|
|
590
|
+
!isStringArray(integration.conflict_paths) || !isNonEmptyString(integration.evidence))
|
|
591
|
+
return false;
|
|
592
|
+
for (const key of ["full_suite", "e2e"]) {
|
|
593
|
+
const suite = integration[key];
|
|
594
|
+
if (!isJsonObject(suite) || !hasExactKeys(suite, ["required", "status", "reason", "evidence"]) || typeof suite.required !== "boolean")
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
return true;
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
function hasCompleteV6ChangeStatus(status) {
|
|
601
|
+
if (status.schema_version !== CHANGE_STATUS_SCHEMA_VERSION)
|
|
602
|
+
return false;
|
|
603
|
+
const previous = { ...status, schema_version: 5 };
|
|
604
|
+
if (!hasCompleteV5ChangeStatus(previous) || !Array.isArray(status.worktrees))
|
|
605
|
+
return false;
|
|
606
|
+
return status.worktrees.every((entry) => {
|
|
607
|
+
if (!isJsonObject(entry) || !isJsonObject(entry.integration))
|
|
608
|
+
return false;
|
|
609
|
+
const current = entry.workspace_ref === "current";
|
|
610
|
+
if (current && entry.parent_branch !== entry.branch)
|
|
611
|
+
return false;
|
|
612
|
+
if (!current && entry.parent_branch === entry.branch)
|
|
613
|
+
return false;
|
|
614
|
+
const integration = entry.integration;
|
|
615
|
+
if (current && ["candidate_sha", "candidate_tree_sha", "candidate_branch", "candidate_workspace_ref"].some((key) => integration[key] !== null))
|
|
616
|
+
return false;
|
|
617
|
+
if (current && integration.method !== null && integration.method !== "direct-parent")
|
|
618
|
+
return false;
|
|
619
|
+
if (!current && integration.method === "direct-parent")
|
|
620
|
+
return false;
|
|
621
|
+
return true;
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
function hasCompleteV4GoalPlan(meta, change) {
|
|
625
|
+
const required = [
|
|
626
|
+
"schema_version", "artifact", "change", "status", "modes", "orchestration", "lead",
|
|
627
|
+
"implementation_agent_limit", "ticket_workspace_policy", "integration_gate", "ready_for_execution",
|
|
628
|
+
];
|
|
629
|
+
if (!hasExactKeys(meta, required))
|
|
630
|
+
return false;
|
|
631
|
+
if (meta.schema_version !== 4 ||
|
|
632
|
+
meta.artifact !== "goal-plan" ||
|
|
633
|
+
meta.change !== change ||
|
|
634
|
+
!new Set(["draft", "ready", "in_progress", "completed", "blocked"]).has(String(meta.status)) ||
|
|
635
|
+
meta.orchestration !== "lead-directed" ||
|
|
636
|
+
!isNonEmptyString(meta.lead) ||
|
|
637
|
+
!Number.isInteger(meta.implementation_agent_limit) ||
|
|
638
|
+
Number(meta.implementation_agent_limit) < 1 ||
|
|
639
|
+
!new Set(["current", "required"]).has(String(meta.ticket_workspace_policy)) ||
|
|
640
|
+
!new Set(["direct-parent", "candidate-merge"]).has(String(meta.integration_gate)) ||
|
|
641
|
+
(meta.ticket_workspace_policy === "current" && meta.integration_gate !== "direct-parent") ||
|
|
642
|
+
(meta.ticket_workspace_policy === "required" && meta.integration_gate !== "candidate-merge") ||
|
|
643
|
+
typeof meta.ready_for_execution !== "boolean" ||
|
|
644
|
+
!Array.isArray(meta.modes))
|
|
645
|
+
return false;
|
|
646
|
+
const modes = meta.modes;
|
|
647
|
+
return modes.every((mode) => new Set(["migration", "high-assurance", "reference-conformance", "release-coordination"]).has(String(mode))) &&
|
|
648
|
+
new Set(modes.map(String)).size === modes.length;
|
|
649
|
+
}
|
|
650
|
+
async function inspectGoalPlanVersion(stateRoot, change, blockers) {
|
|
651
|
+
const path = join(stateRoot, "specdev", "changes", change, "goal-plan.md");
|
|
652
|
+
if (!(await pathExists(path)))
|
|
653
|
+
return;
|
|
654
|
+
const frontmatter = parseGoalPlanFrontmatter(await readFile(path, "utf8"));
|
|
655
|
+
const currentContract = frontmatter !== null && (hasCompleteV6GoalPlan(frontmatter, change) || hasCompleteV5GoalPlan(frontmatter, change));
|
|
656
|
+
if (!currentContract) {
|
|
657
|
+
blockers.push({
|
|
658
|
+
code: "unsupported-goal-plan-contract",
|
|
659
|
+
path: `.speculo/specdev/changes/${change}/goal-plan.md`,
|
|
660
|
+
message: "Goal Plan must contain the complete Lead and workspace/integration v6 frontmatter contract",
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
151
664
|
async function inspectSpecdevState(stateRoot) {
|
|
152
665
|
const blockers = [];
|
|
153
666
|
const statusPath = join(stateRoot, "specdev", "status.json");
|
|
@@ -160,8 +673,8 @@ async function inspectSpecdevState(stateRoot) {
|
|
|
160
673
|
catch {
|
|
161
674
|
return blockers;
|
|
162
675
|
}
|
|
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" });
|
|
676
|
+
if (!isJsonObject(status) || !new Set([4, 5]).has(Number(status.schema_version)) || status.workflow !== "specdev" || !expectArray(status.active) || !expectArray(status.archived)) {
|
|
677
|
+
blockers.push({ code: "unsupported-specdev-status", path: ".speculo/specdev/status.json", message: "automatic migration supports SpecDev global status schema v4/v5 only" });
|
|
165
678
|
return blockers;
|
|
166
679
|
}
|
|
167
680
|
const activeNames = new Set();
|
|
@@ -171,6 +684,10 @@ async function inspectSpecdevState(stateRoot) {
|
|
|
171
684
|
continue;
|
|
172
685
|
}
|
|
173
686
|
const name = item.change;
|
|
687
|
+
if (!isChangeName(name)) {
|
|
688
|
+
blockers.push({ code: "invalid-change-name", path: ".speculo/specdev/status.json", message: "active entries must use canonical change names" });
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
174
691
|
if (activeNames.has(name)) {
|
|
175
692
|
blockers.push({ code: "duplicate-active-change", path: ".speculo/specdev/status.json", message: `${name} appears more than once in active` });
|
|
176
693
|
}
|
|
@@ -182,12 +699,44 @@ async function inspectSpecdevState(stateRoot) {
|
|
|
182
699
|
else {
|
|
183
700
|
try {
|
|
184
701
|
const changeStatus = await readJson(changeStatusPath);
|
|
185
|
-
if (changeStatus.schema_version
|
|
702
|
+
if (!new Set([3, 4, 5, 6]).has(Number(changeStatus.schema_version)) ||
|
|
186
703
|
changeStatus.artifact !== "change-status" ||
|
|
187
704
|
changeStatus.change !== name ||
|
|
188
705
|
!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" });
|
|
706
|
+
blockers.push({ code: "unsupported-change-status", path: `.speculo/specdev/changes/${name}/.status.json`, message: "active change state must use schema v3/v4/v5/v6 and match its index entry" });
|
|
707
|
+
}
|
|
708
|
+
else if (hasLegacyWorktreeState(changeStatus)) {
|
|
709
|
+
blockers.push({
|
|
710
|
+
code: "ambiguous-ticket-worktree-contract",
|
|
711
|
+
path: `.speculo/specdev/changes/${name}/.status.json`,
|
|
712
|
+
message: "legacy worktree state cannot determine required implementation owner, source commit, candidate gate, or E2E disposition",
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
else if (hasUnsupportedV3ChangeStatusFields(changeStatus)) {
|
|
716
|
+
blockers.push({
|
|
717
|
+
code: "unmapped-change-status-fields",
|
|
718
|
+
path: `.speculo/specdev/changes/${name}/.status.json`,
|
|
719
|
+
message: "legacy change-status contains additional fields that cannot be dropped during automatic migration",
|
|
720
|
+
});
|
|
190
721
|
}
|
|
722
|
+
else if (changeStatus.schema_version === 4) {
|
|
723
|
+
blockers.push({
|
|
724
|
+
code: "unmapped-change-runtime-authority",
|
|
725
|
+
path: `.speculo/specdev/changes/${name}/.status.json`,
|
|
726
|
+
message: "v4 change-status cannot infer execution authorization or Lead leadership; reconcile explicitly before v5 migration",
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
else if (changeStatus.schema_version === 5 && !hasCompleteV5ChangeStatus(changeStatus)) {
|
|
730
|
+
blockers.push({
|
|
731
|
+
code: "invalid-change-status-v5",
|
|
732
|
+
path: `.speculo/specdev/changes/${name}/.status.json`,
|
|
733
|
+
message: "change-status v5 is missing required runtime authority fields",
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
else if (changeStatus.schema_version === 6 && !hasCompleteV6ChangeStatus(changeStatus)) {
|
|
737
|
+
blockers.push({ code: "invalid-change-status-v6", path: `.speculo/specdev/changes/${name}/.status.json`, message: "change-status v6 has inconsistent current/required workspace fields" });
|
|
738
|
+
}
|
|
739
|
+
await inspectGoalPlanVersion(stateRoot, name, blockers);
|
|
191
740
|
}
|
|
192
741
|
catch {
|
|
193
742
|
// The JSON tree check reports the parse failure with the precise path.
|
|
@@ -200,6 +749,10 @@ async function inspectSpecdevState(stateRoot) {
|
|
|
200
749
|
blockers.push({ code: "invalid-archived-entry", path: ".speculo/specdev/status.json", message: "archived entries must be change names" });
|
|
201
750
|
continue;
|
|
202
751
|
}
|
|
752
|
+
if (!isChangeName(item)) {
|
|
753
|
+
blockers.push({ code: "invalid-change-name", path: ".speculo/specdev/status.json", message: "archived entries must use canonical change names" });
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
203
756
|
if (archivedNames.has(item))
|
|
204
757
|
blockers.push({ code: "duplicate-archived-change", path: ".speculo/specdev/status.json", message: `${item} appears more than once in archived` });
|
|
205
758
|
archivedNames.add(item);
|
|
@@ -213,11 +766,38 @@ async function inspectSpecdevState(stateRoot) {
|
|
|
213
766
|
else {
|
|
214
767
|
try {
|
|
215
768
|
const archivedStatus = await readJson(archivedStatusPath);
|
|
216
|
-
if (archivedStatus.schema_version
|
|
769
|
+
if (!new Set([3, 4, 5, 6]).has(Number(archivedStatus.schema_version)) ||
|
|
217
770
|
archivedStatus.artifact !== "change-status" ||
|
|
218
771
|
archivedStatus.change !== item ||
|
|
219
772
|
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" });
|
|
773
|
+
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" });
|
|
774
|
+
}
|
|
775
|
+
else if (hasLegacyWorktreeState(archivedStatus)) {
|
|
776
|
+
blockers.push({
|
|
777
|
+
code: "ambiguous-archived-worktree-contract",
|
|
778
|
+
path: `.speculo/specdev/archive/${month}/${item}/.status.json`,
|
|
779
|
+
message: "legacy archived worktree state requires an explicit v4 reconciliation decision",
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
else if (hasUnsupportedV3ChangeStatusFields(archivedStatus)) {
|
|
783
|
+
blockers.push({
|
|
784
|
+
code: "unmapped-archived-status-fields",
|
|
785
|
+
path: `.speculo/specdev/archive/${month}/${item}/.status.json`,
|
|
786
|
+
message: "legacy archived status contains additional fields that require an explicit migration decision",
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
else if (archivedStatus.schema_version === 4 && !hasCompleteV4ChangeStatus(archivedStatus)) {
|
|
790
|
+
blockers.push({
|
|
791
|
+
code: "invalid-archived-status-v4",
|
|
792
|
+
path: `.speculo/specdev/archive/${month}/${item}/.status.json`,
|
|
793
|
+
message: "archived change-status v4 is incomplete",
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
else if (archivedStatus.schema_version === 5 && !hasCompleteV5ChangeStatus(archivedStatus)) {
|
|
797
|
+
blockers.push({ code: "invalid-archived-status-v5", path: `.speculo/specdev/archive/${month}/${item}/.status.json`, message: "archived change-status v5 is incomplete" });
|
|
798
|
+
}
|
|
799
|
+
else if (archivedStatus.schema_version === 6 && !hasCompleteV6ChangeStatus(archivedStatus)) {
|
|
800
|
+
blockers.push({ code: "invalid-archived-status-v6", path: `.speculo/specdev/archive/${month}/${item}/.status.json`, message: "archived change-status v6 has inconsistent current/required workspace fields" });
|
|
221
801
|
}
|
|
222
802
|
}
|
|
223
803
|
catch {
|
|
@@ -262,8 +842,18 @@ async function inspectSpecdevState(stateRoot) {
|
|
|
262
842
|
if (await pathExists(configPath)) {
|
|
263
843
|
try {
|
|
264
844
|
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" });
|
|
845
|
+
if (!new Set([3, 4, 5]).has(Number(config.schema_version))) {
|
|
846
|
+
blockers.push({ code: "unsupported-specdev-config", path: ".speculo/specdev/config.json", message: "automatic migration supports SpecDev config schema v3/v4/v5 only" });
|
|
847
|
+
}
|
|
848
|
+
else if (hasUnsupportedV3SpecdevConfigFields(config)) {
|
|
849
|
+
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" });
|
|
850
|
+
}
|
|
851
|
+
else if (config.schema_version === 4 && !hasCompleteV4SpecdevConfig(config)) {
|
|
852
|
+
blockers.push({ code: "invalid-specdev-config-v4", path: ".speculo/specdev/config.json", message: "SpecDev config v4 is incomplete or contains legacy execution fields" });
|
|
853
|
+
}
|
|
854
|
+
else if (config.schema_version === 5 && !hasCompleteV5SpecdevConfig(config)) {
|
|
855
|
+
blockers.push({ code: "invalid-specdev-config-v5", path: ".speculo/specdev/config.json", message: "SpecDev config v5 is incomplete or contains invalid execution limits" });
|
|
856
|
+
}
|
|
267
857
|
}
|
|
268
858
|
catch {
|
|
269
859
|
// The JSON tree check reports the parse failure with the precise path.
|
|
@@ -377,31 +967,173 @@ function mergeDefaults(defaults, previous) {
|
|
|
377
967
|
}
|
|
378
968
|
return previous;
|
|
379
969
|
}
|
|
970
|
+
function normalizeSpecdevConfigV5(previous, defaults) {
|
|
971
|
+
if (previous.schema_version === CONFIG_SCHEMA_VERSION)
|
|
972
|
+
return previous;
|
|
973
|
+
const previousGit = isJsonObject(previous.git) ? previous.git : {};
|
|
974
|
+
const previousExecution = isJsonObject(previous.execution) ? previous.execution : {};
|
|
975
|
+
const defaultGit = isJsonObject(defaults.git) ? defaults.git : {};
|
|
976
|
+
const defaultExecution = isJsonObject(defaults.execution) ? defaults.execution : {};
|
|
977
|
+
const configuredLimit = Number(previousExecution.max_implementation_agents);
|
|
978
|
+
const legacyLimit = Number(previousExecution.max_parallel);
|
|
979
|
+
const maxImplementationAgents = Number.isInteger(configuredLimit) && configuredLimit >= 1
|
|
980
|
+
? configuredLimit
|
|
981
|
+
: Number.isInteger(legacyLimit) && legacyLimit >= 1
|
|
982
|
+
? legacyLimit
|
|
983
|
+
: Number(defaultExecution.max_implementation_agents ?? 3);
|
|
984
|
+
const merged = mergeDefaults(defaults, previous);
|
|
985
|
+
return {
|
|
986
|
+
...merged,
|
|
987
|
+
schema_version: CONFIG_SCHEMA_VERSION,
|
|
988
|
+
interaction_language: typeof previous.interaction_language === "string"
|
|
989
|
+
? previous.interaction_language
|
|
990
|
+
: defaults.interaction_language,
|
|
991
|
+
artifact_language: typeof previous.artifact_language === "string"
|
|
992
|
+
? previous.artifact_language
|
|
993
|
+
: defaults.artifact_language,
|
|
994
|
+
git: {
|
|
995
|
+
default_branch: typeof previousGit.default_branch === "string" || previousGit.default_branch === null
|
|
996
|
+
? previousGit.default_branch
|
|
997
|
+
: defaultGit.default_branch ?? null,
|
|
998
|
+
},
|
|
999
|
+
execution: {
|
|
1000
|
+
max_implementation_agents: maxImplementationAgents,
|
|
1001
|
+
max_integration_attempts: Number.isInteger(previousExecution.max_integration_attempts) && Number(previousExecution.max_integration_attempts) >= 1
|
|
1002
|
+
? previousExecution.max_integration_attempts
|
|
1003
|
+
: Number(defaultExecution.max_integration_attempts ?? 3),
|
|
1004
|
+
deep_ticket_human_approval: typeof previousExecution.deep_ticket_human_approval === "boolean"
|
|
1005
|
+
? previousExecution.deep_ticket_human_approval
|
|
1006
|
+
: defaultExecution.deep_ticket_human_approval,
|
|
1007
|
+
shared_path_owner: typeof previousExecution.shared_path_owner === "string"
|
|
1008
|
+
? previousExecution.shared_path_owner
|
|
1009
|
+
: defaultExecution.shared_path_owner,
|
|
1010
|
+
},
|
|
1011
|
+
verification: isJsonObject(previous.verification)
|
|
1012
|
+
? mergeDefaults(defaults.verification, previous.verification)
|
|
1013
|
+
: defaults.verification,
|
|
1014
|
+
planning: isJsonObject(previous.planning)
|
|
1015
|
+
? mergeDefaults(defaults.planning, previous.planning)
|
|
1016
|
+
: defaults.planning,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
function normalizeChangeStatusV4(previous) {
|
|
1020
|
+
if (previous.schema_version === 4)
|
|
1021
|
+
return previous;
|
|
1022
|
+
const change = String(previous.change);
|
|
1023
|
+
const status = String(previous.change_status);
|
|
1024
|
+
const timestamp = typeof previous.updated_at === "string" && previous.updated_at
|
|
1025
|
+
? previous.updated_at
|
|
1026
|
+
: typeof previous.created_at === "string" && previous.created_at
|
|
1027
|
+
? previous.created_at
|
|
1028
|
+
: new Date().toISOString();
|
|
1029
|
+
const archived = status === "archived";
|
|
1030
|
+
return {
|
|
1031
|
+
schema_version: 4,
|
|
1032
|
+
artifact: "change-status",
|
|
1033
|
+
change,
|
|
1034
|
+
change_status: status,
|
|
1035
|
+
current_work: typeof previous.current_work === "string" ? previous.current_work : null,
|
|
1036
|
+
created_at: typeof previous.created_at === "string" && previous.created_at ? previous.created_at : timestamp,
|
|
1037
|
+
updated_at: timestamp,
|
|
1038
|
+
completed_at: typeof previous.completed_at === "string" ? previous.completed_at : null,
|
|
1039
|
+
archived,
|
|
1040
|
+
archive_path: archived
|
|
1041
|
+
? typeof previous.archive_path === "string" && previous.archive_path
|
|
1042
|
+
? previous.archive_path
|
|
1043
|
+
: `<Path>{roots.state}/specdev/archive/${change.slice(0, 7)}/${change}</Path>`
|
|
1044
|
+
: null,
|
|
1045
|
+
blockers: Array.isArray(previous.blockers) ? previous.blockers : [],
|
|
1046
|
+
deviations: Array.isArray(previous.deviations) ? previous.deviations : [],
|
|
1047
|
+
worktrees: [],
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
async function upgradeSpecdevRuntimeV5(stagedRoot) {
|
|
1051
|
+
const stateRoot = join(stagedRoot, ".speculo", "specdev");
|
|
1052
|
+
if (!(await pathExists(stateRoot)))
|
|
1053
|
+
return;
|
|
1054
|
+
const configPath = join(stateRoot, "config.json");
|
|
1055
|
+
if (await pathExists(configPath)) {
|
|
1056
|
+
const previous = await readJson(configPath);
|
|
1057
|
+
const defaults = await readJson(join(stagedRoot, "workflows", "specdev", "I-init-setup", "config-template.json"));
|
|
1058
|
+
if (previous.schema_version !== CONFIG_SCHEMA_VERSION) {
|
|
1059
|
+
await writeFile(configPath, JSON.stringify(normalizeSpecdevConfigV5(previous, defaults), null, 2) + "\n", "utf8");
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const statusPath = join(stateRoot, "status.json");
|
|
1063
|
+
if (!(await pathExists(statusPath)))
|
|
1064
|
+
return;
|
|
1065
|
+
const status = await readJson(statusPath);
|
|
1066
|
+
const activeEntries = expectArray(status.active) ? status.active.filter(isJsonObject) : [];
|
|
1067
|
+
const changeStatusPaths = [];
|
|
1068
|
+
for (const entry of activeEntries) {
|
|
1069
|
+
if (isChangeName(entry.change))
|
|
1070
|
+
changeStatusPaths.push({ path: join(stateRoot, "changes", entry.change, ".status.json"), entry });
|
|
1071
|
+
}
|
|
1072
|
+
for (const entry of expectArray(status.archived) ? status.archived : []) {
|
|
1073
|
+
if (isChangeName(entry))
|
|
1074
|
+
changeStatusPaths.push({ path: join(stateRoot, "archive", entry.slice(0, 7), entry, ".status.json"), entry: null });
|
|
1075
|
+
}
|
|
1076
|
+
for (const { path, entry } of changeStatusPaths) {
|
|
1077
|
+
if (!(await pathExists(path)))
|
|
1078
|
+
continue;
|
|
1079
|
+
const previous = await readJson(path);
|
|
1080
|
+
if (previous.schema_version === 3) {
|
|
1081
|
+
await writeFile(path, JSON.stringify(normalizeChangeStatusV4(previous), null, 2) + "\n", "utf8");
|
|
1082
|
+
}
|
|
1083
|
+
let current = await readJson(path);
|
|
1084
|
+
if (current.schema_version === 4) {
|
|
1085
|
+
await writeFile(path, JSON.stringify(normalizeChangeStatusV5(current, entry), null, 2) + "\n", "utf8");
|
|
1086
|
+
}
|
|
1087
|
+
current = await readJson(path);
|
|
1088
|
+
if (current.schema_version === 5) {
|
|
1089
|
+
await writeFile(path, JSON.stringify({ ...current, schema_version: CHANGE_STATUS_SCHEMA_VERSION }, null, 2) + "\n", "utf8");
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
const config = await pathExists(configPath) ? await readJson(configPath) : {};
|
|
1093
|
+
for (const { path } of changeStatusPaths) {
|
|
1094
|
+
const goalPlanPath = join(dirname(path), "goal-plan.md");
|
|
1095
|
+
if (!(await pathExists(goalPlanPath)))
|
|
1096
|
+
continue;
|
|
1097
|
+
const goalPlan = await readFile(goalPlanPath, "utf8");
|
|
1098
|
+
if (parseGoalPlanFrontmatter(goalPlan)?.schema_version === 5) {
|
|
1099
|
+
await writeFile(goalPlanPath, normalizeGoalPlanV6(goalPlan, config), "utf8");
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (status.schema_version === 4) {
|
|
1103
|
+
await writeFile(statusPath, JSON.stringify({
|
|
1104
|
+
schema_version: 5,
|
|
1105
|
+
workflow: "specdev",
|
|
1106
|
+
active: activeEntries.map((entry) => ({ change: entry.change })),
|
|
1107
|
+
archived: expectArray(status.archived) ? status.archived : [],
|
|
1108
|
+
}, null, 2) + "\n", "utf8");
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
async function copyNode(source, target, recursive = false) {
|
|
1112
|
+
await cp(source, target, { recursive, force: true, verbatimSymlinks: true });
|
|
1113
|
+
}
|
|
380
1114
|
async function restoreCompatibleState(snapshotRoot, stagedRoot) {
|
|
381
1115
|
const snapshotState = join(snapshotRoot, "state");
|
|
382
1116
|
const stagedState = join(stagedRoot, ".speculo");
|
|
383
|
-
if (await
|
|
1117
|
+
if (await nodeExists(snapshotState)) {
|
|
384
1118
|
for (const entry of await readdir(snapshotState, { withFileTypes: true })) {
|
|
385
1119
|
if (MANAGED_STATE_ENTRIES.has(entry.name))
|
|
386
1120
|
continue;
|
|
387
|
-
await
|
|
388
|
-
recursive: entry.isDirectory(),
|
|
389
|
-
force: true,
|
|
390
|
-
});
|
|
1121
|
+
await copyNode(join(snapshotState, entry.name), join(stagedState, entry.name), entry.isDirectory());
|
|
391
1122
|
}
|
|
392
1123
|
}
|
|
393
1124
|
const previousConfig = join(snapshotRoot, "config.json");
|
|
394
|
-
if (await
|
|
1125
|
+
if (await nodeExists(previousConfig)) {
|
|
395
1126
|
const defaults = await readJson(join(stagedRoot, "config.json"));
|
|
396
1127
|
const previous = await readJson(previousConfig);
|
|
397
1128
|
await writeFile(join(stagedRoot, "config.json"), JSON.stringify(mergeDefaults(defaults, previous), null, 2) + "\n", "utf8");
|
|
398
1129
|
}
|
|
1130
|
+
await upgradeSpecdevRuntimeV5(stagedRoot);
|
|
399
1131
|
}
|
|
400
1132
|
async function restoreUnselectedState(snapshotRoot, stagedRoot, workflowIds) {
|
|
401
1133
|
for (const workflowId of workflowIds) {
|
|
402
1134
|
const source = join(snapshotRoot, "state", workflowId);
|
|
403
|
-
if (await
|
|
404
|
-
await
|
|
1135
|
+
if (await nodeExists(source))
|
|
1136
|
+
await copyNode(source, join(stagedRoot, ".speculo", workflowId), true);
|
|
405
1137
|
}
|
|
406
1138
|
}
|
|
407
1139
|
async function writeInstallManifest(stagedRoot, targetVersion, workflowIds) {
|