@felixdotgo/aidlc-kit 0.1.0 → 0.1.1
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/README.md +16 -6
- package/dist/assets/.agents/aidlc/schemas/manifest.schema.json +1 -1
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +6 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +6 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +8 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +1 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +7 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +3 -2
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +2 -2
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/state.mjs +4 -0
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +2 -2
- package/dist/src/adapters.js +8 -1
- package/dist/src/cli.js +111 -95
- package/dist/src/context.js +2 -0
- package/dist/src/gate.d.ts +1 -0
- package/dist/src/gate.js +48 -35
- package/dist/src/installer.d.ts +5 -0
- package/dist/src/installer.js +116 -80
- package/dist/src/model.d.ts +1 -1
- package/dist/src/profiles.d.ts +6 -0
- package/dist/src/profiles.js +11 -0
- package/dist/src/project-migration.d.ts +1 -0
- package/dist/src/project-migration.js +7 -0
- package/dist/src/state/archive.d.ts +17 -0
- package/dist/src/state/archive.js +60 -0
- package/dist/src/state/items.d.ts +25 -0
- package/dist/src/state/items.js +146 -0
- package/dist/src/state/lessons.d.ts +7 -0
- package/dist/src/state/lessons.js +67 -0
- package/dist/src/state/lifecycle.d.ts +5 -0
- package/dist/src/state/lifecycle.js +78 -0
- package/dist/src/state/lock.d.ts +7 -0
- package/dist/src/state/lock.js +124 -0
- package/dist/src/state/memory.d.ts +8 -0
- package/dist/src/state/memory.js +88 -0
- package/dist/src/state/render.d.ts +26 -0
- package/dist/src/state/render.js +29 -0
- package/dist/src/state/shared.d.ts +29 -0
- package/dist/src/state/shared.js +71 -0
- package/dist/src/state/store.d.ts +4 -0
- package/dist/src/state/store.js +81 -0
- package/dist/src/state/transitions.d.ts +21 -0
- package/dist/src/state/transitions.js +93 -0
- package/dist/src/state/validate.d.ts +4 -0
- package/dist/src/state/validate.js +100 -0
- package/dist/src/state.d.ts +15 -117
- package/dist/src/state.js +15 -815
- package/dist/src/tracker.d.ts +7 -0
- package/dist/src/tracker.js +3 -0
- package/dist/src/upgrade-board.d.ts +5 -0
- package/dist/src/upgrade-board.js +89 -0
- package/dist/src/upgrade.d.ts +4 -5
- package/dist/src/upgrade.js +197 -214
- package/dist/src/workflow.js +1 -1
- package/docs/README.md +2 -2
- package/docs/architecture.md +4 -1
- package/docs/command-reference.md +15 -12
- package/docs/configuration.md +7 -4
- package/docs/coordination-service.md +5 -2
- package/docs/development.md +15 -1
- package/docs/operating-workflow.md +4 -1
- package/docs/testing-and-release.md +7 -7
- package/package.json +2 -2
package/dist/src/tracker.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backlog tracker integration: resolves tracker credentials from the environment
|
|
3
|
+
* and builds the HTTP requests used to mirror lifecycle phase transitions.
|
|
4
|
+
*/
|
|
1
5
|
import type { TrackerConfig } from "./model.js";
|
|
2
6
|
export interface TrackerDiagnostic {
|
|
3
7
|
code: "TRACKER_DISABLED" | "TRACKER_AUTH" | "TRACKER_RATE_LIMIT" | "TRACKER_REQUEST";
|
|
@@ -18,7 +22,9 @@ export type TrackerResolution = {
|
|
|
18
22
|
config: TrackerConfig;
|
|
19
23
|
token: string;
|
|
20
24
|
};
|
|
25
|
+
/** Resolves a tracker configuration into either an enabled resolution (with token) or a disabled diagnostic. */
|
|
21
26
|
export declare const resolveTracker: (config: TrackerConfig | undefined, environment?: NodeJS.ProcessEnv) => TrackerResolution;
|
|
27
|
+
/** Executes a single Backlog request, mapping HTTP failures to retryable diagnostics. */
|
|
22
28
|
export declare const executeBacklog: (resolution: TrackerResolution, request: BacklogRequest, fetcher?: FetchLike) => Promise<{
|
|
23
29
|
ok: true;
|
|
24
30
|
body: unknown;
|
|
@@ -26,4 +32,5 @@ export declare const executeBacklog: (resolution: TrackerResolution, request: Ba
|
|
|
26
32
|
ok: false;
|
|
27
33
|
diagnostic: TrackerDiagnostic;
|
|
28
34
|
}>;
|
|
35
|
+
/** Builds the Backlog PATCH request that mirrors a lifecycle phase transition for an issue. */
|
|
29
36
|
export declare const gateUpdate: (config: TrackerConfig, issue: string, phase: "clarify" | "plan" | "build" | "wrap" | "done", idempotencyKey: string) => BacklogRequest;
|
package/dist/src/tracker.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** Resolves a tracker configuration into either an enabled resolution (with token) or a disabled diagnostic. */
|
|
1
2
|
export const resolveTracker = (config, environment = process.env) => {
|
|
2
3
|
if (!config)
|
|
3
4
|
return { enabled: false, diagnostic: { code: "TRACKER_DISABLED", message: "Tracker is not configured; Git-only mode is active.", retryable: false } };
|
|
@@ -12,6 +13,7 @@ const form = (values) => {
|
|
|
12
13
|
body.set(key, String(value));
|
|
13
14
|
return body;
|
|
14
15
|
};
|
|
16
|
+
/** Executes a single Backlog request, mapping HTTP failures to retryable diagnostics. */
|
|
15
17
|
export const executeBacklog = async (resolution, request, fetcher = fetch) => {
|
|
16
18
|
if (!resolution.enabled)
|
|
17
19
|
return { ok: false, diagnostic: resolution.diagnostic };
|
|
@@ -28,6 +30,7 @@ export const executeBacklog = async (resolution, request, fetcher = fetch) => {
|
|
|
28
30
|
return { ok: false, diagnostic: { code: "TRACKER_REQUEST", message: `Backlog request failed with HTTP ${response.status}.`, retryable: response.status >= 500 } };
|
|
29
31
|
return { ok: true, body: await response.json() };
|
|
30
32
|
};
|
|
33
|
+
/** Builds the Backlog PATCH request that mirrors a lifecycle phase transition for an issue. */
|
|
31
34
|
export const gateUpdate = (config, issue, phase, idempotencyKey) => {
|
|
32
35
|
const comment = `[aidlc:${idempotencyKey}] ${phase}`;
|
|
33
36
|
if (config.workflow === "waterfall") {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy v0.2.x BOARD.md migration. Parses the markdown task table into
|
|
3
|
+
* canonical lifecycle state and preserves each legacy task's prose files in
|
|
4
|
+
* the stable artifact layout so no legacy content is lost.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { resolveProjectPath } from "./project-path.js";
|
|
9
|
+
import { emptyState, renderWorkplan, validateState } from "./state.js";
|
|
10
|
+
const safePath = (root, relative) => resolveProjectPath(root, relative, "Unsafe upgrade path");
|
|
11
|
+
const parseFrontmatter = (content) => {
|
|
12
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
13
|
+
if (!match)
|
|
14
|
+
return {};
|
|
15
|
+
return Object.fromEntries(match[1].split("\n").flatMap((line) => {
|
|
16
|
+
const separator = line.indexOf(":");
|
|
17
|
+
return separator < 0 ? [] : [[line.slice(0, separator).trim(), line.slice(separator + 1).trim().replace(/^['"]|['"]$/g, "")]];
|
|
18
|
+
}));
|
|
19
|
+
};
|
|
20
|
+
const cells = (line) => line.split("|").slice(1, -1).map((cell) => cell.trim().replace(/^`|`$/g, ""));
|
|
21
|
+
/* Approval evidence is rebuilt from the row's phase: everything beyond plan implies G0 passed, etc. */
|
|
22
|
+
const legacyEvidence = (phase, recordedAt) => {
|
|
23
|
+
const evidence = [];
|
|
24
|
+
if (["plan", "build", "wrap", "done"].includes(phase))
|
|
25
|
+
evidence.push({ kind: "approval", gate: "G0_confirm", source: "legacy state migration", result: "pass", recordedAt });
|
|
26
|
+
if (["build", "wrap", "done"].includes(phase))
|
|
27
|
+
evidence.push({ kind: "approval", gate: "G1_review", source: "legacy state migration", result: "pass", recordedAt });
|
|
28
|
+
return evidence;
|
|
29
|
+
};
|
|
30
|
+
/** Maps one legacy BOARD row to a canonical task, copying its prose files into the stable artifact layout. */
|
|
31
|
+
const taskFromLegacyRow = (root, files, row, recordedAt) => {
|
|
32
|
+
const [id, title, phaseRaw, gateRaw, statusRaw, branch, areasRaw, doc] = row;
|
|
33
|
+
const oldDoc = safePath(root, doc);
|
|
34
|
+
const source = existsSync(oldDoc) ? readFileSync(oldDoc, "utf8") : "";
|
|
35
|
+
const frontmatter = parseFrontmatter(source);
|
|
36
|
+
const stableDir = `.agents/data/tasks/${id}`;
|
|
37
|
+
const prosePath = `${stableDir}/intent-design.md`;
|
|
38
|
+
const workplanPath = `${stableDir}/workplan.md`;
|
|
39
|
+
if (source)
|
|
40
|
+
files.push({ path: prosePath, owner: "aidlc-state", ownershipClass: "state", content: source });
|
|
41
|
+
const oldWorkplan = doc.replace(/\.md$/, ".workplan.md");
|
|
42
|
+
if (existsSync(safePath(root, oldWorkplan)))
|
|
43
|
+
files.push({ path: `${stableDir}/legacy-workplan.md`, owner: "aidlc-state", ownershipClass: "state", content: readFileSync(safePath(root, oldWorkplan), "utf8") });
|
|
44
|
+
const phase = ["clarify", "plan", "build", "wrap", "done"].includes(phaseRaw) ? phaseRaw : "clarify";
|
|
45
|
+
const gate = ["none", "G0_confirm", "G1_review", "G2_codereview"].includes(gateRaw) ? gateRaw : "G0_confirm";
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
title,
|
|
49
|
+
type: (["feature", "bug", "refactor", "infra"].includes(frontmatter.type) ? frontmatter.type : "infra"),
|
|
50
|
+
phase,
|
|
51
|
+
gate,
|
|
52
|
+
status: (["active", "blocked_on_user", "paused", "done"].includes(statusRaw) ? statusRaw : "paused"),
|
|
53
|
+
language: frontmatter.language === "en" ? "en" : "vi",
|
|
54
|
+
risk: "normal",
|
|
55
|
+
areas: areasRaw.split(",").map((area) => area.trim()).filter(Boolean),
|
|
56
|
+
branch: branch || "—",
|
|
57
|
+
artifacts: { intent: source ? prosePath : undefined, design: source ? prosePath : undefined, workplan: workplanPath },
|
|
58
|
+
decisions: [],
|
|
59
|
+
tasks: [],
|
|
60
|
+
evidence: [...legacyEvidence(phase, recordedAt), { kind: "diagnostic", source: "legacy state migration", result: "pass", detail: `Original artifacts preserved at ${doc}`, recordedAt }],
|
|
61
|
+
createdAt: recordedAt,
|
|
62
|
+
updatedAt: recordedAt
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
export const migrateLegacyBoard = (root) => {
|
|
66
|
+
const boardPath = join(resolve(root), ".agents/data/state/BOARD.md");
|
|
67
|
+
if (!existsSync(boardPath))
|
|
68
|
+
return { state: emptyState(), files: [] };
|
|
69
|
+
const content = readFileSync(boardPath, "utf8");
|
|
70
|
+
const state = emptyState();
|
|
71
|
+
const files = [];
|
|
72
|
+
let migratedRows = 0;
|
|
73
|
+
for (const line of content.split("\n")) {
|
|
74
|
+
if (!line.startsWith("|") || /^\|[- ]+\|/.test(line) || line.includes("| id |"))
|
|
75
|
+
continue;
|
|
76
|
+
const row = cells(line);
|
|
77
|
+
if (row.length < 8)
|
|
78
|
+
throw new Error("Legacy BOARD contains a malformed task row; refusing lossy migration");
|
|
79
|
+
migratedRows += 1;
|
|
80
|
+
const task = taskFromLegacyRow(root, files, row, new Date().toISOString());
|
|
81
|
+
state.tasks[task.id] = task;
|
|
82
|
+
}
|
|
83
|
+
if (!migratedRows && !content.includes("_No tasks in flight._"))
|
|
84
|
+
throw new Error("Legacy BOARD contains no recognizable task rows; refusing lossy migration");
|
|
85
|
+
for (const task of Object.values(state.tasks))
|
|
86
|
+
files.push({ path: task.artifacts.workplan, owner: "aidlc-state", ownershipClass: "state", content: renderWorkplan(task) });
|
|
87
|
+
validateState(state);
|
|
88
|
+
return { state, files };
|
|
89
|
+
};
|
package/dist/src/upgrade.d.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
files: FileSpec[];
|
|
5
|
-
};
|
|
1
|
+
import type { LoopConfig, PlannedWrite } from "./model.js";
|
|
2
|
+
export { migrateLegacyBoard } from "./upgrade-board.js";
|
|
3
|
+
/** Plans an upgrade transaction from the installed manifest to the current package. */
|
|
6
4
|
export declare const planUpgrade: (root: string, loop?: LoopConfig) => PlannedWrite[];
|
|
7
5
|
export interface UpgradeResult {
|
|
8
6
|
backup: string;
|
|
9
7
|
changed: number;
|
|
10
8
|
}
|
|
9
|
+
/** Applies a planned upgrade transaction with backup and rollback on failure. */
|
|
11
10
|
export declare const applyUpgrade: (root: string, plan: PlannedWrite[]) => UpgradeResult;
|
package/dist/src/upgrade.js
CHANGED
|
@@ -6,8 +6,10 @@ import { legacyV021AdapterContents, legacyV021Hashes } from "./legacy.js";
|
|
|
6
6
|
import { planProjectLayoutMigration } from "./project-migration.js";
|
|
7
7
|
import { mergeManagedBlock } from "./managed-content.js";
|
|
8
8
|
import { resolveProjectPath, resolveProjectPathWithoutSymlinks } from "./project-path.js";
|
|
9
|
-
import {
|
|
9
|
+
import { prepareV4Migration, validateState } from "./state.js";
|
|
10
10
|
import { contentHash, coreWorkflowFiles, initialProjectFiles, manifestSpec } from "./workflow.js";
|
|
11
|
+
import { migrateLegacyBoard } from "./upgrade-board.js";
|
|
12
|
+
export { migrateLegacyBoard } from "./upgrade-board.js";
|
|
11
13
|
const safePath = (root, relative) => resolveProjectPath(root, relative, "Unsafe upgrade path");
|
|
12
14
|
const assertNoSymlinkPath = (root, relative) => { resolveProjectPathWithoutSymlinks(root, relative, "Unsafe upgrade path", "Refusing to upgrade symlink path"); };
|
|
13
15
|
const legacyManifest = (root) => {
|
|
@@ -20,79 +22,9 @@ const legacyManifest = (root) => {
|
|
|
20
22
|
}
|
|
21
23
|
catch {
|
|
22
24
|
return false;
|
|
23
|
-
}
|
|
24
|
-
};
|
|
25
|
-
const parseFrontmatter = (content) => {
|
|
26
|
-
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
27
|
-
if (!match)
|
|
28
|
-
return {};
|
|
29
|
-
return Object.fromEntries(match[1].split("\n").flatMap((line) => {
|
|
30
|
-
const separator = line.indexOf(":");
|
|
31
|
-
return separator < 0 ? [] : [[line.slice(0, separator).trim(), line.slice(separator + 1).trim().replace(/^['"]|['"]$/g, "")]];
|
|
32
|
-
}));
|
|
33
|
-
};
|
|
34
|
-
const cells = (line) => line.split("|").slice(1, -1).map((cell) => cell.trim().replace(/^`|`$/g, ""));
|
|
35
|
-
export const migrateLegacyBoard = (root) => {
|
|
36
|
-
const boardPath = join(resolve(root), ".agents/data/state/BOARD.md");
|
|
37
|
-
if (!existsSync(boardPath))
|
|
38
|
-
return { state: emptyState(), files: [] };
|
|
39
|
-
const state = emptyState();
|
|
40
|
-
const files = [];
|
|
41
|
-
const boardContent = readFileSync(boardPath, "utf8");
|
|
42
|
-
let migratedRows = 0;
|
|
43
|
-
for (const line of boardContent.split("\n")) {
|
|
44
|
-
if (!line.startsWith("|") || /^\|[- ]+\|/.test(line) || line.includes("| id |"))
|
|
45
|
-
continue;
|
|
46
|
-
const row = cells(line);
|
|
47
|
-
if (row.length < 8)
|
|
48
|
-
throw new Error("Legacy BOARD contains a malformed task row; refusing lossy migration");
|
|
49
|
-
const [id, title, phaseRaw, gateRaw, statusRaw, branch, areasRaw, doc] = row;
|
|
50
|
-
migratedRows += 1;
|
|
51
|
-
const oldDoc = safePath(root, doc);
|
|
52
|
-
const source = existsSync(oldDoc) ? readFileSync(oldDoc, "utf8") : "";
|
|
53
|
-
const frontmatter = parseFrontmatter(source);
|
|
54
|
-
const stableDir = `.agents/data/tasks/${id}`;
|
|
55
|
-
const prosePath = `${stableDir}/intent-design.md`;
|
|
56
|
-
const workplanPath = `${stableDir}/workplan.md`;
|
|
57
|
-
if (source)
|
|
58
|
-
files.push({ path: prosePath, owner: "aidlc-state", ownershipClass: "state", content: source });
|
|
59
|
-
const oldWorkplan = doc.replace(/\.md$/, ".workplan.md");
|
|
60
|
-
if (existsSync(safePath(root, oldWorkplan)))
|
|
61
|
-
files.push({ path: `${stableDir}/legacy-workplan.md`, owner: "aidlc-state", ownershipClass: "state", content: readFileSync(safePath(root, oldWorkplan), "utf8") });
|
|
62
|
-
const phase = ["clarify", "plan", "build", "wrap", "done"].includes(phaseRaw) ? phaseRaw : "clarify";
|
|
63
|
-
const gate = ["none", "G0_confirm", "G1_review", "G2_codereview"].includes(gateRaw) ? gateRaw : "G0_confirm";
|
|
64
|
-
const evidence = [];
|
|
65
|
-
const now = new Date().toISOString();
|
|
66
|
-
if (["plan", "build", "wrap", "done"].includes(phase))
|
|
67
|
-
evidence.push({ kind: "approval", gate: "G0_confirm", source: "legacy state migration", result: "pass", recordedAt: now });
|
|
68
|
-
if (["build", "wrap", "done"].includes(phase))
|
|
69
|
-
evidence.push({ kind: "approval", gate: "G1_review", source: "legacy state migration", result: "pass", recordedAt: now });
|
|
70
|
-
state.tasks[id] = {
|
|
71
|
-
id,
|
|
72
|
-
title,
|
|
73
|
-
type: (["feature", "bug", "refactor", "infra"].includes(frontmatter.type) ? frontmatter.type : "infra"),
|
|
74
|
-
phase,
|
|
75
|
-
gate,
|
|
76
|
-
status: (["active", "blocked_on_user", "paused", "done"].includes(statusRaw) ? statusRaw : "paused"),
|
|
77
|
-
language: frontmatter.language === "en" ? "en" : "vi",
|
|
78
|
-
risk: "normal",
|
|
79
|
-
areas: areasRaw.split(",").map((area) => area.trim()).filter(Boolean),
|
|
80
|
-
branch: branch || "—",
|
|
81
|
-
artifacts: { intent: source ? prosePath : undefined, design: source ? prosePath : undefined, workplan: workplanPath },
|
|
82
|
-
decisions: [],
|
|
83
|
-
tasks: [],
|
|
84
|
-
evidence: [...evidence, { kind: "diagnostic", source: "legacy state migration", result: "pass", detail: `Original artifacts preserved at ${doc}`, recordedAt: now }],
|
|
85
|
-
createdAt: now,
|
|
86
|
-
updatedAt: now
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
if (!migratedRows && !boardContent.includes("_No tasks in flight._"))
|
|
90
|
-
throw new Error("Legacy BOARD contains no recognizable task rows; refusing lossy migration");
|
|
91
|
-
for (const task of Object.values(state.tasks))
|
|
92
|
-
files.push({ path: task.artifacts.workplan, owner: "aidlc-state", ownershipClass: "state", content: renderWorkplan(task) });
|
|
93
|
-
validateState(state);
|
|
94
|
-
return { state, files };
|
|
25
|
+
} /* intentional: a malformed/missing manifest is not a legacy v0.x install */
|
|
95
26
|
};
|
|
27
|
+
/* --- Managed file planning -------------------------------------------------- */
|
|
96
28
|
const planManaged = (root, spec, installedHash, legacy) => {
|
|
97
29
|
const target = safePath(root, spec.path);
|
|
98
30
|
if (!existsSync(target))
|
|
@@ -113,124 +45,221 @@ const planManaged = (root, spec, installedHash, legacy) => {
|
|
|
113
45
|
return { ...spec, content: next, action: "conflict", reason: "managed prompt block was modified" };
|
|
114
46
|
return { ...spec, content: next, action: "conflict", reason: legacy ? "legacy core differs from known baseline" : "managed file differs from installed hash" };
|
|
115
47
|
};
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
48
|
+
/** Merges a later owner of a shared managed-block path into the already-emitted plan entry. */
|
|
49
|
+
const mergeSharedBlockOwner = (project, previous, spec, installed) => {
|
|
50
|
+
if (!["create", "update", "skip"].includes(previous.action))
|
|
51
|
+
return;
|
|
52
|
+
const target = safePath(project, spec.path);
|
|
53
|
+
const current = existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
54
|
+
// Every owner block is checked before merging so a user edit to a later
|
|
55
|
+
// adapter's block is reported instead of being silently overwritten.
|
|
56
|
+
const installedBlockHash = installedHashFor(installed?.files, spec);
|
|
57
|
+
const currentBlockHash = managedContentHash(spec, current);
|
|
58
|
+
if (installedBlockHash !== undefined && currentBlockHash !== undefined && installedBlockHash !== currentBlockHash) {
|
|
59
|
+
previous.action = "conflict";
|
|
60
|
+
previous.reason = "managed prompt block was modified";
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
previous.content = mergeManagedBlock(spec, previous.content);
|
|
64
|
+
previous.action = existsSync(target) ? (previous.content === current ? "skip" : "update") : "create";
|
|
65
|
+
previous.reason = "managed prompt block";
|
|
66
|
+
};
|
|
67
|
+
/** Plans managed replacements and shared managed-block writes across every owner. */
|
|
68
|
+
const planManagedFiles = (project, managed, installed, legacy) => {
|
|
127
69
|
const plan = [];
|
|
128
70
|
const mergedBlocks = new Map();
|
|
129
71
|
for (const spec of managed) {
|
|
72
|
+
const installedHash = installedHashFor(installed?.files, spec);
|
|
130
73
|
if (spec.strategy !== "managed-block") {
|
|
131
|
-
plan.push(planManaged(project, spec,
|
|
74
|
+
plan.push(planManaged(project, spec, installedHash, legacy));
|
|
132
75
|
continue;
|
|
133
76
|
}
|
|
134
77
|
const previous = mergedBlocks.get(spec.path);
|
|
135
78
|
if (!previous) {
|
|
136
|
-
const write = planManaged(project, spec,
|
|
79
|
+
const write = planManaged(project, spec, installedHash, legacy);
|
|
137
80
|
plan.push(write);
|
|
138
81
|
mergedBlocks.set(spec.path, write);
|
|
139
82
|
continue;
|
|
140
83
|
}
|
|
141
|
-
|
|
84
|
+
mergeSharedBlockOwner(project, previous, spec, installed);
|
|
85
|
+
}
|
|
86
|
+
return plan;
|
|
87
|
+
};
|
|
88
|
+
/* --- Obsolete files and project-owned files ---------------------------------- */
|
|
89
|
+
/** Deletes obsolete managed files tracked by the installed manifest that no longer ship in this release. */
|
|
90
|
+
const planObsoleteRemovals = (project, plan, installed, managed) => {
|
|
91
|
+
for (const [key, item] of Object.entries(installed.files)) {
|
|
92
|
+
const path = key.includes("#") ? key.slice(0, key.lastIndexOf("#")) : key;
|
|
93
|
+
// Shared prompt blocks are upgraded through the merged plan above; never delete their whole path here.
|
|
94
|
+
if (item.strategy === "managed-block")
|
|
142
95
|
continue;
|
|
143
|
-
|
|
144
|
-
const current = existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
145
|
-
// Every owner block is checked before merging so a user edit to a later
|
|
146
|
-
// adapter's block is reported instead of being silently overwritten.
|
|
147
|
-
const installedBlockHash = installedHashFor(installed?.files, spec);
|
|
148
|
-
const currentBlockHash = managedContentHash(spec, current);
|
|
149
|
-
if (installedBlockHash !== undefined && currentBlockHash !== undefined && installedBlockHash !== currentBlockHash) {
|
|
150
|
-
previous.action = "conflict";
|
|
151
|
-
previous.reason = "managed prompt block was modified";
|
|
96
|
+
if (managed.some((spec) => spec.path === path))
|
|
152
97
|
continue;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
for (const [key, item] of Object.entries(installed.files)) {
|
|
160
|
-
const path = key.includes("#") ? key.slice(0, key.lastIndexOf("#")) : key;
|
|
161
|
-
// Shared prompt blocks are upgraded through the merged plan above; never delete their whole path here.
|
|
162
|
-
if (item.strategy === "managed-block")
|
|
163
|
-
continue;
|
|
164
|
-
if (managed.some((spec) => spec.path === path))
|
|
165
|
-
continue;
|
|
166
|
-
const target = safePath(project, path);
|
|
167
|
-
if (!existsSync(target))
|
|
168
|
-
continue;
|
|
169
|
-
const current = readFileSync(target, "utf8");
|
|
170
|
-
const hash = managedContentHash({ owner: item.owner, strategy: item.strategy }, current);
|
|
171
|
-
plan.push({ path, owner: item.owner, strategy: item.strategy, ownershipClass: "managed", content: "", action: hash === item.hash ? "delete" : "conflict", reason: hash === item.hash ? "obsolete unchanged managed file" : "obsolete managed file was modified" });
|
|
172
|
-
}
|
|
98
|
+
const target = safePath(project, path);
|
|
99
|
+
if (!existsSync(target))
|
|
100
|
+
continue;
|
|
101
|
+
const current = readFileSync(target, "utf8");
|
|
102
|
+
const hash = managedContentHash({ owner: item.owner, strategy: item.strategy }, current);
|
|
103
|
+
plan.push({ path, owner: item.owner, strategy: item.strategy, ownershipClass: "managed", content: "", action: hash === item.hash ? "delete" : "conflict", reason: hash === item.hash ? "obsolete unchanged managed file" : "obsolete managed file was modified" });
|
|
173
104
|
}
|
|
105
|
+
};
|
|
106
|
+
const preserveProjectFiles = (project, plan, legacy) => {
|
|
174
107
|
for (const spec of initialProjectFiles()) {
|
|
175
108
|
if (legacy && spec.path === ".agents/data/state/aidlc-state.json" && !existsSync(safePath(project, spec.path)))
|
|
176
109
|
continue;
|
|
177
110
|
const target = safePath(project, spec.path);
|
|
178
111
|
plan.push(existsSync(target) ? { ...spec, content: readFileSync(target, "utf8"), action: "preserve", reason: `${spec.ownershipClass}-owned file` } : { ...spec, action: "create", reason: `new ${spec.ownershipClass}-owned file` });
|
|
179
112
|
}
|
|
113
|
+
};
|
|
114
|
+
/* --- Canonical state and legacy BOARD migration -------------------------------- */
|
|
115
|
+
/** Migrates a preserved canonical state file (schema v1–v3) to v4 with immutable archived-task records. */
|
|
116
|
+
const migrateCanonicalState = (project, plan, canonicalState) => {
|
|
117
|
+
if (canonicalState.action !== "preserve")
|
|
118
|
+
return;
|
|
119
|
+
const parsed = JSON.parse(canonicalState.content);
|
|
120
|
+
const normalized = validateState(parsed);
|
|
121
|
+
if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2 && parsed.schemaVersion !== 3)
|
|
122
|
+
return;
|
|
123
|
+
const migration = prepareV4Migration(normalized);
|
|
124
|
+
canonicalState.content = `${JSON.stringify(migration.state, null, 2)}\n`;
|
|
125
|
+
canonicalState.action = "migrate";
|
|
126
|
+
canonicalState.reason = `migrate canonical state schema v${parsed.schemaVersion} to v4 compact catalog${migration.stamped.length ? ` and stamp pre-existing ready G2 waits: ${migration.stamped.join(", ")}` : " without changing task content"}`;
|
|
127
|
+
for (const record of migration.records) {
|
|
128
|
+
assertNoSymlinkPath(project, record.path);
|
|
129
|
+
const target = safePath(project, record.path);
|
|
130
|
+
if (!existsSync(target))
|
|
131
|
+
plan.push({ path: record.path, owner: "aidlc-state", ownershipClass: "state", content: record.content, action: "migrate", reason: "write immutable terminal task record" });
|
|
132
|
+
else if (readFileSync(target, "utf8") !== record.content)
|
|
133
|
+
plan.push({ path: record.path, owner: "aidlc-state", ownershipClass: "state", content: record.content, action: "conflict", reason: "terminal task record conflicts with prior canonical content" });
|
|
134
|
+
}
|
|
135
|
+
const lessonIndex = plan.find((item) => item.path === ".agents/data/lessons/index.json");
|
|
136
|
+
if (!lessonIndex)
|
|
137
|
+
throw new Error("Lesson index plan is missing");
|
|
138
|
+
lessonIndex.content = `${JSON.stringify(migration.lessonIndex, null, 2)}\n`;
|
|
139
|
+
lessonIndex.action = "migrate";
|
|
140
|
+
lessonIndex.reason = "build derived lesson index from canonical task records";
|
|
141
|
+
};
|
|
142
|
+
/** Seeds the plan from a legacy BOARD.md when no canonical state file exists yet. */
|
|
143
|
+
const migrateLegacyState = (project, plan, legacy) => {
|
|
144
|
+
if (!legacy || existsSync(safePath(project, ".agents/data/state/aidlc-state.json")))
|
|
145
|
+
return;
|
|
146
|
+
const migration = migrateLegacyBoard(project);
|
|
147
|
+
const compact = prepareV4Migration(migration.state);
|
|
148
|
+
plan.push({ path: ".agents/data/state/aidlc-state.json", owner: "aidlc-state", ownershipClass: "state", content: `${JSON.stringify(compact.state, null, 2)}\n`, action: "migrate", reason: "migrate legacy BOARD to canonical v4 state" });
|
|
149
|
+
for (const record of compact.records)
|
|
150
|
+
plan.push({ path: record.path, owner: "aidlc-state", ownershipClass: "state", content: record.content, action: "migrate", reason: "preserve legacy terminal task as immutable record" });
|
|
151
|
+
const lessonIndex = plan.find((item) => item.path === ".agents/data/lessons/index.json");
|
|
152
|
+
if (lessonIndex) {
|
|
153
|
+
lessonIndex.content = `${JSON.stringify(compact.lessonIndex, null, 2)}\n`;
|
|
154
|
+
lessonIndex.action = "migrate";
|
|
155
|
+
lessonIndex.reason = "build derived lesson index during legacy migration";
|
|
156
|
+
}
|
|
157
|
+
for (const file of migration.files)
|
|
158
|
+
plan.push({ ...file, action: "migrate", reason: "preserve legacy task prose in stable task directory" });
|
|
159
|
+
};
|
|
160
|
+
/** Removes the duplicate BOARD.md projection only when the planned canonical state validates. */
|
|
161
|
+
const removeLegacyBoard = (project, plan) => {
|
|
162
|
+
const boardPath = safePath(project, ".agents/data/state/BOARD.md");
|
|
163
|
+
if (!existsSync(boardPath))
|
|
164
|
+
return;
|
|
165
|
+
const stateItem = plan.find((item) => item.path === ".agents/data/state/aidlc-state.json");
|
|
166
|
+
if (!stateItem)
|
|
167
|
+
throw new Error("Canonical state must be present before removing legacy BOARD");
|
|
168
|
+
validateState(JSON.parse(stateItem.content));
|
|
169
|
+
plan.push({ path: ".agents/data/state/BOARD.md", owner: "aidlc-state", ownershipClass: "state", content: "", action: "delete", reason: "remove duplicate lifecycle-state projection after canonical JSON validation" });
|
|
170
|
+
};
|
|
171
|
+
/* --- planUpgrade orchestration ------------------------------------------------ */
|
|
172
|
+
/** Selects which adapters to upgrade: manifest-registered ones, else auto-detected installable ones. */
|
|
173
|
+
const selectedUpgradeAgents = (project, installed) => {
|
|
174
|
+
const upgradeAdapters = resolvedAdaptersForUpgrade();
|
|
175
|
+
const requested = installed?.adapters?.length ? installed.adapters : upgradeAdapters.filter((adapter) => adapter.detect(project)).map((adapter) => adapter.id);
|
|
176
|
+
return requested.filter((id) => findAdapterForUpgrade(id));
|
|
177
|
+
};
|
|
178
|
+
const agentUpgradeFiles = (selected) => selected.flatMap((id) => findAdapterForUpgrade(id)?.files() ?? []).map((file) => ({ ...file, ownershipClass: "managed" }));
|
|
179
|
+
/** Plans an upgrade transaction from the installed manifest to the current package. */
|
|
180
|
+
export const planUpgrade = (root, loop) => {
|
|
181
|
+
const project = resolve(root);
|
|
182
|
+
const installed = readManifest(project);
|
|
183
|
+
const legacy = !installed && legacyManifest(project);
|
|
184
|
+
if (!installed && !legacy)
|
|
185
|
+
throw new Error("No owned AI-DLC installation found. Use init instead of upgrade.");
|
|
186
|
+
const selected = selectedUpgradeAgents(project, installed);
|
|
187
|
+
const managed = [...coreWorkflowFiles(), ...agentUpgradeFiles(selected)];
|
|
188
|
+
const plan = planManagedFiles(project, managed, installed, legacy);
|
|
189
|
+
if (installed)
|
|
190
|
+
planObsoleteRemovals(project, plan, installed, managed);
|
|
191
|
+
preserveProjectFiles(project, plan, legacy);
|
|
180
192
|
const canonicalState = plan.find((item) => item.path === ".agents/data/state/aidlc-state.json");
|
|
181
|
-
if (canonicalState
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
193
|
+
if (canonicalState)
|
|
194
|
+
migrateCanonicalState(project, plan, canonicalState);
|
|
195
|
+
planProjectLayoutMigration(project, plan);
|
|
196
|
+
migrateLegacyState(project, plan, legacy);
|
|
197
|
+
removeLegacyBoard(project, plan);
|
|
198
|
+
plan.push({ ...manifestSpec(managed, selected), action: "update", reason: legacy ? "migrate manifest to schema v2" : "update manifest inventory" });
|
|
199
|
+
planLoopSelection(plan, loop);
|
|
200
|
+
return plan;
|
|
201
|
+
};
|
|
202
|
+
/** Copies each affected original into the backup and stages the new content outside the live tree. */
|
|
203
|
+
const stageUpgradedWrites = (project, writes, backup, stage, journal) => {
|
|
204
|
+
for (const item of writes) {
|
|
205
|
+
const target = safePath(project, item.path);
|
|
206
|
+
assertNoSymlinkPath(project, item.path);
|
|
207
|
+
const existed = existsSync(target);
|
|
208
|
+
journal.push({ path: item.path, existed, action: item.action });
|
|
209
|
+
if (existed) {
|
|
210
|
+
const destination = join(backup, item.path);
|
|
211
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
212
|
+
copyFileSync(target, destination);
|
|
213
|
+
}
|
|
214
|
+
if (item.action !== "delete") {
|
|
215
|
+
const staged = join(stage, item.path);
|
|
216
|
+
mkdirSync(dirname(staged), { recursive: true });
|
|
217
|
+
writeFileSync(staged, item.contentEncoding === "base64" ? Buffer.from(item.content, "base64") : item.content);
|
|
218
|
+
if (item.path.endsWith(".json") && item.contentEncoding !== "base64")
|
|
219
|
+
JSON.parse(item.content);
|
|
203
220
|
}
|
|
204
221
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
222
|
+
};
|
|
223
|
+
const writeUpgradeJournal = (backup, journal) => {
|
|
224
|
+
mkdirSync(backup, { recursive: true });
|
|
225
|
+
writeFileSync(join(backup, "journal.json"), `${JSON.stringify(journal, null, 2)}\n`, "utf8");
|
|
226
|
+
};
|
|
227
|
+
const commitStagedWrites = (project, writes, stage) => {
|
|
228
|
+
for (const item of writes) {
|
|
229
|
+
const target = safePath(project, item.path);
|
|
230
|
+
if (item.action === "delete")
|
|
231
|
+
rmSync(target, { force: true });
|
|
232
|
+
else {
|
|
233
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
234
|
+
if (existsSync(target))
|
|
235
|
+
rmSync(target, { force: true });
|
|
236
|
+
renameSync(join(stage, item.path), target);
|
|
217
237
|
}
|
|
218
|
-
for (const file of migration.files)
|
|
219
|
-
plan.push({ ...file, action: "migrate", reason: "preserve legacy task prose in stable task directory" });
|
|
220
238
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
239
|
+
};
|
|
240
|
+
const pruneEmptyDirectories = (directory) => {
|
|
241
|
+
if (!existsSync(directory) || !lstatSync(directory).isDirectory())
|
|
242
|
+
return;
|
|
243
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }))
|
|
244
|
+
if (entry.isDirectory())
|
|
245
|
+
pruneEmptyDirectories(join(directory, entry.name));
|
|
246
|
+
if (readdirSync(directory).length === 0)
|
|
247
|
+
rmdirSync(directory);
|
|
248
|
+
};
|
|
249
|
+
/** Restores the journaled originals so a failed upgrade leaves no partial state. */
|
|
250
|
+
const restoreFromJournal = (project, journal, backup) => {
|
|
251
|
+
for (const entry of journal.reverse()) {
|
|
252
|
+
const target = safePath(project, entry.path);
|
|
253
|
+
const source = join(backup, entry.path);
|
|
254
|
+
if (entry.existed && existsSync(source)) {
|
|
255
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
256
|
+
copyFileSync(source, target);
|
|
257
|
+
}
|
|
258
|
+
else if (!entry.existed)
|
|
259
|
+
rmSync(target, { force: true });
|
|
228
260
|
}
|
|
229
|
-
const manifest = manifestSpec(managed, selected);
|
|
230
|
-
plan.push({ ...manifest, action: "update", reason: legacy ? "migrate manifest to schema v2" : "update manifest inventory" });
|
|
231
|
-
planLoopSelection(plan, loop);
|
|
232
|
-
return plan;
|
|
233
261
|
};
|
|
262
|
+
/** Applies a planned upgrade transaction with backup and rollback on failure. */
|
|
234
263
|
export const applyUpgrade = (root, plan) => {
|
|
235
264
|
const conflicts = plan.filter((item) => item.action === "conflict");
|
|
236
265
|
if (conflicts.length)
|
|
@@ -244,61 +273,15 @@ export const applyUpgrade = (root, plan) => {
|
|
|
244
273
|
mkdirSync(stage, { recursive: true });
|
|
245
274
|
const journal = [];
|
|
246
275
|
try {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
journal.push({ path: item.path, existed, action: item.action });
|
|
252
|
-
if (existed) {
|
|
253
|
-
const destination = join(backup, item.path);
|
|
254
|
-
mkdirSync(dirname(destination), { recursive: true });
|
|
255
|
-
copyFileSync(target, destination);
|
|
256
|
-
}
|
|
257
|
-
if (item.action !== "delete") {
|
|
258
|
-
const staged = join(stage, item.path);
|
|
259
|
-
mkdirSync(dirname(staged), { recursive: true });
|
|
260
|
-
writeFileSync(staged, item.contentEncoding === "base64" ? Buffer.from(item.content, "base64") : item.content);
|
|
261
|
-
if (item.path.endsWith(".json") && item.contentEncoding !== "base64")
|
|
262
|
-
JSON.parse(item.content);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
mkdirSync(backup, { recursive: true });
|
|
266
|
-
writeFileSync(join(backup, "journal.json"), `${JSON.stringify(journal, null, 2)}\n`, "utf8");
|
|
267
|
-
for (const item of writes) {
|
|
268
|
-
const target = safePath(project, item.path);
|
|
269
|
-
if (item.action === "delete")
|
|
270
|
-
rmSync(target, { force: true });
|
|
271
|
-
else {
|
|
272
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
273
|
-
if (existsSync(target))
|
|
274
|
-
rmSync(target, { force: true });
|
|
275
|
-
renameSync(join(stage, item.path), target);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
const pruneEmpty = (directory) => {
|
|
279
|
-
if (!existsSync(directory) || !lstatSync(directory).isDirectory())
|
|
280
|
-
return;
|
|
281
|
-
for (const entry of readdirSync(directory, { withFileTypes: true }))
|
|
282
|
-
if (entry.isDirectory())
|
|
283
|
-
pruneEmpty(join(directory, entry.name));
|
|
284
|
-
if (readdirSync(directory).length === 0)
|
|
285
|
-
rmdirSync(directory);
|
|
286
|
-
};
|
|
287
|
-
pruneEmpty(safePath(project, ".aidlc"));
|
|
276
|
+
stageUpgradedWrites(project, writes, backup, stage, journal);
|
|
277
|
+
writeUpgradeJournal(backup, journal);
|
|
278
|
+
commitStagedWrites(project, writes, stage);
|
|
279
|
+
pruneEmptyDirectories(safePath(project, ".aidlc"));
|
|
288
280
|
rmSync(stage, { recursive: true, force: true });
|
|
289
281
|
return { backup: backupRelative, changed: writes.length };
|
|
290
282
|
}
|
|
291
283
|
catch (error) {
|
|
292
|
-
|
|
293
|
-
const target = safePath(project, entry.path);
|
|
294
|
-
const source = join(backup, entry.path);
|
|
295
|
-
if (entry.existed && existsSync(source)) {
|
|
296
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
297
|
-
copyFileSync(source, target);
|
|
298
|
-
}
|
|
299
|
-
else if (!entry.existed)
|
|
300
|
-
rmSync(target, { force: true });
|
|
301
|
-
}
|
|
284
|
+
restoreFromJournal(project, journal, backup);
|
|
302
285
|
rmSync(stage, { recursive: true, force: true });
|
|
303
286
|
throw error;
|
|
304
287
|
}
|