@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/state.js
CHANGED
|
@@ -1,815 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
export
|
|
7
|
-
export
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
export
|
|
11
|
-
export
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const handoffKinds = ["repair_exhausted", "review_exhausted", "g2_failed", "release_failed", "structural_change", "other"];
|
|
17
|
-
const terminal = (task) => task.status === "done" || task.status === "closed" || task.status === "superseded";
|
|
18
|
-
const phaseGate = (phase) => phase === "clarify" ? "G0_confirm" : phase === "plan" ? "G1_review" : phase === "build" ? "G2_codereview" : "none";
|
|
19
|
-
const iso = (value) => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(Date.parse(value)).toISOString() === value;
|
|
20
|
-
const text = (value) => typeof value === "string" && Boolean(value.trim());
|
|
21
|
-
const idPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
22
|
-
const digest = (content) => createHash("sha256").update(content).digest("hex");
|
|
23
|
-
const archiveRecordPath = (id) => `.agents/data/state/archive/${id}.json`;
|
|
24
|
-
const taskRecordContent = (task) => `${JSON.stringify(task, null, 2)}\n`;
|
|
25
|
-
const assertNoSymlinkPath = (root, relative) => { resolveProjectPathWithoutSymlinks(root, relative, "Unsafe state path", "State path crosses a symlink"); };
|
|
26
|
-
const writeAtomic = (path, content) => {
|
|
27
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
28
|
-
const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
29
|
-
writeFileSync(temporary, content, "utf8");
|
|
30
|
-
renameSync(temporary, path);
|
|
31
|
-
};
|
|
32
|
-
const sleep = (milliseconds) => {
|
|
33
|
-
if (milliseconds > 0)
|
|
34
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
35
|
-
};
|
|
36
|
-
const lockRecord = (path) => {
|
|
37
|
-
try {
|
|
38
|
-
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
39
|
-
return typeof value.token === "string" && typeof value.pid === "number" && Number.isInteger(value.pid) && value.pid > 0 && typeof value.createdAt === "number" && Number.isFinite(value.createdAt) ? value : undefined;
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return undefined;
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
const processAlive = (pid) => {
|
|
46
|
-
try {
|
|
47
|
-
process.kill(pid, 0);
|
|
48
|
-
return true;
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
return error.code === "EPERM";
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
const tryCreateLock = (path, record) => {
|
|
55
|
-
try {
|
|
56
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
57
|
-
const descriptor = openSync(path, "wx", 0o600);
|
|
58
|
-
try {
|
|
59
|
-
writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8");
|
|
60
|
-
}
|
|
61
|
-
finally {
|
|
62
|
-
closeSync(descriptor);
|
|
63
|
-
}
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
66
|
-
catch (error) {
|
|
67
|
-
if (error.code === "EEXIST")
|
|
68
|
-
return false;
|
|
69
|
-
throw error;
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
const removeOwnedLock = (path, token) => {
|
|
73
|
-
if (lockRecord(path)?.token === token)
|
|
74
|
-
unlinkSync(path);
|
|
75
|
-
};
|
|
76
|
-
const lockExpired = (record, now, staleMs) => Boolean(record && now - record.createdAt >= staleMs && !processAlive(record.pid));
|
|
77
|
-
/** Serializes lifecycle script mutations across local Node.js processes. */
|
|
78
|
-
export const acquireStateLock = (root, options = {}) => {
|
|
79
|
-
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
80
|
-
const retryMs = options.retryMs ?? 25;
|
|
81
|
-
const staleMs = options.staleMs ?? 30_000;
|
|
82
|
-
const lockPath = stateLockPath(root);
|
|
83
|
-
const guardPath = stateLockGuardPath(root);
|
|
84
|
-
const token = randomUUID();
|
|
85
|
-
const deadline = Date.now() + timeoutMs;
|
|
86
|
-
assertNoSymlinkPath(root, ".agents/data/state/.aidlc-state.lock");
|
|
87
|
-
assertNoSymlinkPath(root, ".agents/data/state/.aidlc-state.lock.guard");
|
|
88
|
-
const acquireGuard = () => {
|
|
89
|
-
const guardToken = randomUUID();
|
|
90
|
-
const now = Date.now();
|
|
91
|
-
const existing = lockRecord(guardPath);
|
|
92
|
-
if (lockExpired(existing, now, staleMs))
|
|
93
|
-
removeOwnedLock(guardPath, existing.token);
|
|
94
|
-
return tryCreateLock(guardPath, { token: guardToken, pid: process.pid, createdAt: now }) ? guardToken : undefined;
|
|
95
|
-
};
|
|
96
|
-
while (Date.now() <= deadline) {
|
|
97
|
-
const guardToken = acquireGuard();
|
|
98
|
-
if (!guardToken) {
|
|
99
|
-
sleep(retryMs);
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
try {
|
|
103
|
-
const now = Date.now();
|
|
104
|
-
const existing = lockRecord(lockPath);
|
|
105
|
-
if (lockExpired(existing, now, staleMs))
|
|
106
|
-
removeOwnedLock(lockPath, existing.token);
|
|
107
|
-
if (tryCreateLock(lockPath, { token, pid: process.pid, createdAt: now }))
|
|
108
|
-
return () => {
|
|
109
|
-
const existing = lockRecord(lockPath);
|
|
110
|
-
if (!existing) {
|
|
111
|
-
if (existsSync(lockPath))
|
|
112
|
-
throw new Error("Cannot release AI-DLC state lock because its owner record is unreadable");
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
if (existing.token !== token)
|
|
116
|
-
return;
|
|
117
|
-
try {
|
|
118
|
-
unlinkSync(lockPath);
|
|
119
|
-
}
|
|
120
|
-
catch (error) {
|
|
121
|
-
throw new Error(`Failed to release owned AI-DLC state lock: ${error instanceof Error ? error.message : String(error)}`);
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
finally {
|
|
126
|
-
removeOwnedLock(guardPath, guardToken);
|
|
127
|
-
}
|
|
128
|
-
sleep(retryMs);
|
|
129
|
-
}
|
|
130
|
-
throw new Error(`Timed out waiting for AI-DLC state lock after ${timeoutMs}ms`);
|
|
131
|
-
};
|
|
132
|
-
const validateLesson = (taskId, lesson) => {
|
|
133
|
-
if (!lesson || !idPattern.test(lesson.id) || lesson.taskId !== taskId || !Array.isArray(lesson.areas) || !lesson.areas.length || lesson.areas.some((area) => !text(area)) || !text(lesson.summary) || !text(lesson.prevention) || !text(lesson.example) || !text(lesson.promotion) || !text(lesson.source) || !iso(lesson.recordedAt))
|
|
134
|
-
throw new Error(`Invalid lesson: ${taskId}`);
|
|
135
|
-
};
|
|
136
|
-
const memoryPhases = ["clarify", "plan", "build", "wrap", "*"];
|
|
137
|
-
const validateMemoryEntry = (entry) => {
|
|
138
|
-
if (!entry || !idPattern.test(entry.id) || !text(entry.summary) || !text(entry.guidance) || !Array.isArray(entry.areas) || !entry.areas.length || entry.areas.some((area) => !text(area)) || !Array.isArray(entry.phases) || !entry.phases.length || entry.phases.some((phase) => !memoryPhases.includes(phase)) || !Number.isInteger(entry.priority) || entry.priority < 0 || entry.priority > 100 || !idPattern.test(entry.sourceTaskId) || !idPattern.test(entry.sourceLessonId) || !text(entry.approvedBy) || !iso(entry.approvedAt))
|
|
139
|
-
throw new Error(`Invalid agentic memory entry: ${entry?.id ?? "unknown"}`);
|
|
140
|
-
};
|
|
141
|
-
export const validateMemoryRegistry = (value) => {
|
|
142
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
143
|
-
throw new Error("Agentic memory registry must be an object");
|
|
144
|
-
const registry = value;
|
|
145
|
-
if (registry.schemaVersion !== 1 || !Array.isArray(registry.entries) || !Array.isArray(registry.retired))
|
|
146
|
-
throw new Error("Unsupported agentic memory registry schema");
|
|
147
|
-
const ids = new Set();
|
|
148
|
-
for (const entry of registry.entries) {
|
|
149
|
-
validateMemoryEntry(entry);
|
|
150
|
-
if (ids.has(entry.id))
|
|
151
|
-
throw new Error(`Duplicate agentic memory entry: ${entry.id}`);
|
|
152
|
-
ids.add(entry.id);
|
|
153
|
-
}
|
|
154
|
-
for (const entry of registry.retired) {
|
|
155
|
-
validateMemoryEntry(entry);
|
|
156
|
-
if (!text(entry.retiredBy) || !text(entry.reason) || !iso(entry.retiredAt))
|
|
157
|
-
throw new Error(`Invalid retired agentic memory entry: ${entry.id}`);
|
|
158
|
-
if (ids.has(entry.id))
|
|
159
|
-
throw new Error(`Agentic memory entry is active and retired: ${entry.id}`);
|
|
160
|
-
ids.add(entry.id);
|
|
161
|
-
}
|
|
162
|
-
return registry;
|
|
163
|
-
};
|
|
164
|
-
const parseJsonText = (content, label, recovery = "") => {
|
|
165
|
-
try {
|
|
166
|
-
return JSON.parse(content);
|
|
167
|
-
}
|
|
168
|
-
catch (error) {
|
|
169
|
-
throw new Error(`${label} is corrupted or unreadable: ${error instanceof Error ? error.message : String(error)}${recovery ? `; ${recovery}` : ""}`);
|
|
170
|
-
}
|
|
171
|
-
};
|
|
172
|
-
const parseJsonFile = (path, label, recovery = "") => parseJsonText(readFileSync(path, "utf8"), label, recovery);
|
|
173
|
-
export const loadMemoryRegistry = (root) => {
|
|
174
|
-
const relative = ".agents/data/memory/agentic-memory.json";
|
|
175
|
-
assertNoSymlinkPath(root, relative);
|
|
176
|
-
const path = memoryRegistryPath(root);
|
|
177
|
-
return existsSync(path) ? validateMemoryRegistry(parseJsonFile(path, "Agentic memory registry")) : emptyMemoryRegistry();
|
|
178
|
-
};
|
|
179
|
-
export const saveMemoryRegistry = (root, registry) => {
|
|
180
|
-
const relative = ".agents/data/memory/agentic-memory.json";
|
|
181
|
-
const normalized = validateMemoryRegistry(registry);
|
|
182
|
-
assertNoSymlinkPath(root, relative);
|
|
183
|
-
writeAtomic(memoryRegistryPath(root), `${JSON.stringify(normalized, null, 2)}\n`);
|
|
184
|
-
};
|
|
185
|
-
export const selectAgenticMemory = (registry, areas, phase, maxChars) => {
|
|
186
|
-
const areaSet = new Set(areas.map((area) => area.toLocaleLowerCase()));
|
|
187
|
-
const matches = registry.entries.filter((entry) => entry.phases.includes("*") || entry.phases.includes(phase)).filter((entry) => entry.areas.includes("*") || entry.areas.some((area) => areaSet.has(area.toLocaleLowerCase()))).sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
|
|
188
|
-
const selected = [];
|
|
189
|
-
let used = 0;
|
|
190
|
-
for (const entry of matches) {
|
|
191
|
-
const size = `- ${entry.id} — ${entry.summary} — guidance: ${entry.guidance} — source: ${entry.sourceTaskId}/${entry.sourceLessonId}\n`.length;
|
|
192
|
-
if (used + size > maxChars)
|
|
193
|
-
continue;
|
|
194
|
-
selected.push(entry);
|
|
195
|
-
used += size;
|
|
196
|
-
}
|
|
197
|
-
return selected;
|
|
198
|
-
};
|
|
199
|
-
export const promoteAgenticMemory = (root, entry) => {
|
|
200
|
-
const state = loadState(root);
|
|
201
|
-
const source = loadTask(root, entry.sourceTaskId, state);
|
|
202
|
-
if (!source?.lessons?.some((lesson) => lesson.id === entry.sourceLessonId))
|
|
203
|
-
throw new Error(`Agentic memory source lesson is missing: ${entry.sourceTaskId}/${entry.sourceLessonId}`);
|
|
204
|
-
const registry = loadMemoryRegistry(root);
|
|
205
|
-
const current = registry.entries.find((item) => item.id === entry.id);
|
|
206
|
-
if (current) {
|
|
207
|
-
if (JSON.stringify(current) === JSON.stringify(entry))
|
|
208
|
-
return current;
|
|
209
|
-
throw new Error(`Agentic memory entry already exists with different content: ${entry.id}`);
|
|
210
|
-
}
|
|
211
|
-
if (registry.retired.some((item) => item.id === entry.id))
|
|
212
|
-
throw new Error(`Retired agentic memory id cannot be reused: ${entry.id}`);
|
|
213
|
-
registry.entries.push(entry);
|
|
214
|
-
registry.entries.sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
|
|
215
|
-
saveMemoryRegistry(root, registry);
|
|
216
|
-
return entry;
|
|
217
|
-
};
|
|
218
|
-
export const retireAgenticMemory = (root, id, reason, retiredBy, retiredAt = new Date().toISOString()) => {
|
|
219
|
-
if (!text(reason) || !text(retiredBy) || !iso(retiredAt))
|
|
220
|
-
throw new Error("Retiring agentic memory requires a reason, approver, and valid timestamp");
|
|
221
|
-
const registry = loadMemoryRegistry(root);
|
|
222
|
-
const index = registry.entries.findIndex((entry) => entry.id === id);
|
|
223
|
-
if (index < 0)
|
|
224
|
-
throw new Error(`Unknown active agentic memory entry: ${id}`);
|
|
225
|
-
const [entry] = registry.entries.splice(index, 1);
|
|
226
|
-
registry.retired.push({ ...entry, retiredBy, retiredAt, reason });
|
|
227
|
-
registry.retired.sort((left, right) => right.retiredAt.localeCompare(left.retiredAt) || left.id.localeCompare(right.id));
|
|
228
|
-
saveMemoryRegistry(root, registry);
|
|
229
|
-
return entry;
|
|
230
|
-
};
|
|
231
|
-
const validateTask = (id, task) => {
|
|
232
|
-
if (!idPattern.test(id))
|
|
233
|
-
throw new Error(`Invalid task id: ${id}`);
|
|
234
|
-
if (!task || task.id !== id || !phases.includes(task.phase) || !gates.includes(task.gate) || task.gate !== phaseGate(task.phase))
|
|
235
|
-
throw new Error(`Invalid task state: ${id}`);
|
|
236
|
-
if (!Array.isArray(task.decisions) || !Array.isArray(task.tasks) || !Array.isArray(task.evidence) || (task.lessons !== undefined && !Array.isArray(task.lessons)))
|
|
237
|
-
throw new Error(`Invalid task collections: ${id}`);
|
|
238
|
-
if (!task.title || !["feature", "bug", "refactor", "infra"].includes(task.type) || !statuses.includes(task.status) || (task.phase === "done") !== (task.status === "done"))
|
|
239
|
-
throw new Error(`Invalid task fields: ${id}`);
|
|
240
|
-
if (!["vi", "en"].includes(task.language) || !["low", "normal", "high", "regulated"].includes(task.risk) || !Array.isArray(task.areas) || !task.areas.length || task.areas.some((area) => !text(area)))
|
|
241
|
-
throw new Error(`Invalid task language, risk, or areas: ${id}`);
|
|
242
|
-
if (task.decisions.some((item) => !item.id || !item.label || !["unresolved", "approved", "changed", "dropped"].includes(item.status) || (item.status === "changed" && !item.resolution)))
|
|
243
|
-
throw new Error(`Invalid decisions: ${id}`);
|
|
244
|
-
if (task.featureId !== undefined && !idPattern.test(task.featureId))
|
|
245
|
-
throw new Error(`Invalid feature id: ${id}`);
|
|
246
|
-
if (task.tasks.some((item) => !item.id || !item.label || !["todo", "in_progress", "done", "deferred"].includes(item.status)))
|
|
247
|
-
throw new Error(`Invalid execution tasks: ${id}`);
|
|
248
|
-
readyTaskItems(task);
|
|
249
|
-
if (task.evidence.some((item) => !["approval", "spec", "test", "lint", "review", "diagnostic"].includes(item.kind) || !["pass", "fail", "skip"].includes(item.result) || !item.source || !iso(item.recordedAt) || (item.area !== undefined && !text(item.area)) || (item.gate !== undefined && !gates.includes(item.gate)) || (item.kind === "approval" && (!item.gate || item.gate === "none"))))
|
|
250
|
-
throw new Error(`Invalid evidence: ${id}`);
|
|
251
|
-
const lessonIds = new Set();
|
|
252
|
-
for (const lesson of task.lessons ?? []) {
|
|
253
|
-
validateLesson(id, lesson);
|
|
254
|
-
if (lessonIds.has(lesson.id))
|
|
255
|
-
throw new Error(`Duplicate lesson id: ${id}/${lesson.id}`);
|
|
256
|
-
lessonIds.add(lesson.id);
|
|
257
|
-
}
|
|
258
|
-
if (task.lessonDisposition && (!["captured", "none"].includes(task.lessonDisposition.status) || !text(task.lessonDisposition.source) || !iso(task.lessonDisposition.recordedAt) || (task.lessonDisposition.status === "captured" && !(task.lessons?.length)) || (task.lessonDisposition.status === "none" && (Boolean(task.lessons?.length) || !text(task.lessonDisposition.reason)))))
|
|
259
|
-
throw new Error(`Invalid lesson disposition: ${id}`);
|
|
260
|
-
if (task.handoff && (!handoffKinds.includes(task.handoff.kind) || !text(task.handoff.reason) || !text(task.handoff.source) || !iso(task.handoff.recordedAt) || task.status !== "paused"))
|
|
261
|
-
throw new Error(`Invalid task handoff: ${id}`);
|
|
262
|
-
if (task.legacyG2Wait !== undefined && (typeof task.legacyG2Wait !== "object" || Array.isArray(task.legacyG2Wait) || !iso(task.legacyG2Wait.migratedAt) || !text(task.legacyG2Wait.source) || task.phase !== "build" || task.gate !== "G2_codereview" || task.status !== "blocked_on_user"))
|
|
263
|
-
throw new Error(`Invalid legacy G2 wait marker: ${id}`);
|
|
264
|
-
if ((task.status === "closed" || task.status === "superseded") !== Boolean(task.closure))
|
|
265
|
-
throw new Error(`Invalid task closure: ${id}`);
|
|
266
|
-
if (task.closure && (!text(task.closure.reason) || !text(task.closure.source) || !iso(task.closure.recordedAt)))
|
|
267
|
-
throw new Error(`Invalid task closure: ${id}`);
|
|
268
|
-
if ((task.status === "superseded") !== Boolean(task.successorTaskId) || (task.status === "closed" && task.successorTaskId))
|
|
269
|
-
throw new Error(`Invalid task successor: ${id}`);
|
|
270
|
-
if (task.predecessorTaskId !== undefined && !text(task.predecessorTaskId))
|
|
271
|
-
throw new Error(`Invalid task predecessor: ${id}`);
|
|
272
|
-
for (const path of Object.values(task.artifacts))
|
|
273
|
-
if (path) {
|
|
274
|
-
const cleaned = normalize(path);
|
|
275
|
-
if (isAbsolute(cleaned) || cleaned === ".." || cleaned.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || !cleaned.startsWith(`.agents${process.platform === "win32" ? "\\" : "/"}data${process.platform === "win32" ? "\\" : "/"}tasks${process.platform === "win32" ? "\\" : "/"}`))
|
|
276
|
-
throw new Error(`Invalid artifact path for ${id}: ${path}`);
|
|
277
|
-
}
|
|
278
|
-
};
|
|
279
|
-
const validateArchiveSummary = (id, item) => {
|
|
280
|
-
if (!idPattern.test(id) || !item || item.id !== id || !text(item.title) || !["feature", "bug", "refactor", "infra"].includes(item.type) || !phases.includes(item.phase) || !gates.includes(item.gate) || item.gate !== phaseGate(item.phase) || !["done", "closed", "superseded"].includes(item.status) || !["low", "normal", "high", "regulated"].includes(item.risk) || !Array.isArray(item.areas) || !item.areas.length || item.areas.some((area) => !text(area)) || item.record !== archiveRecordPath(id) || !/^[a-f0-9]{64}$/.test(item.digest) || !Number.isInteger(item.lessonCount) || item.lessonCount < 0 || !iso(item.createdAt) || !iso(item.updatedAt))
|
|
281
|
-
throw new Error(`Invalid archived task summary: ${id}`);
|
|
282
|
-
if ((item.status === "superseded") !== Boolean(item.successorTaskId) || (item.status === "closed" && item.successorTaskId) || (item.predecessorTaskId !== undefined && !text(item.predecessorTaskId)))
|
|
283
|
-
throw new Error(`Invalid archived task links: ${id}`);
|
|
284
|
-
};
|
|
285
|
-
export const validateState = (value) => {
|
|
286
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
287
|
-
throw new Error("Workflow state must be an object");
|
|
288
|
-
const state = value;
|
|
289
|
-
if (![1, 2, 3, 4].includes(Number(state.schemaVersion)) || !state.tasks || typeof state.tasks !== "object" || Array.isArray(state.tasks))
|
|
290
|
-
throw new Error("Unsupported workflow state schema");
|
|
291
|
-
if (Number(state.schemaVersion) >= 3 && (!state.archive || typeof state.archive !== "object" || Array.isArray(state.archive)))
|
|
292
|
-
throw new Error("Schema v3+ requires an archive catalog");
|
|
293
|
-
if (Number(state.schemaVersion) < 3 && state.archive !== undefined)
|
|
294
|
-
throw new Error("Legacy workflow state cannot contain an archive catalog");
|
|
295
|
-
if (Number(state.schemaVersion) < 4 && Object.values(state.tasks).some((task) => task?.legacyG2Wait !== undefined))
|
|
296
|
-
throw new Error("Legacy G2 wait markers require schema v4");
|
|
297
|
-
for (const [id, task] of Object.entries(state.tasks))
|
|
298
|
-
validateTask(id, task);
|
|
299
|
-
for (const [id, item] of Object.entries(state.archive ?? {})) {
|
|
300
|
-
if (state.tasks[id])
|
|
301
|
-
throw new Error(`Task exists in active and archive state: ${id}`);
|
|
302
|
-
validateArchiveSummary(id, item);
|
|
303
|
-
}
|
|
304
|
-
const references = { ...(state.archive ?? {}), ...state.tasks };
|
|
305
|
-
for (const [id, item] of Object.entries(references)) {
|
|
306
|
-
if (item.successorTaskId) {
|
|
307
|
-
const successor = references[item.successorTaskId];
|
|
308
|
-
if (!successor || successor.id === id || successor.predecessorTaskId !== id)
|
|
309
|
-
throw new Error(`Invalid successor link: ${id}`);
|
|
310
|
-
}
|
|
311
|
-
if (item.predecessorTaskId) {
|
|
312
|
-
const predecessor = references[item.predecessorTaskId];
|
|
313
|
-
if (!predecessor || predecessor.id === id || predecessor.status !== "superseded" || predecessor.successorTaskId !== id)
|
|
314
|
-
throw new Error(`Invalid predecessor link: ${id}`);
|
|
315
|
-
}
|
|
316
|
-
const seen = new Set([id]);
|
|
317
|
-
let cursor = item;
|
|
318
|
-
while (cursor.successorTaskId) {
|
|
319
|
-
if (seen.has(cursor.successorTaskId))
|
|
320
|
-
throw new Error(`Cyclic successor link: ${id}`);
|
|
321
|
-
seen.add(cursor.successorTaskId);
|
|
322
|
-
cursor = references[cursor.successorTaskId];
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
return state;
|
|
326
|
-
};
|
|
327
|
-
const archivedSummary = (task, content = taskRecordContent(task)) => ({
|
|
328
|
-
id: task.id,
|
|
329
|
-
title: task.title,
|
|
330
|
-
type: task.type,
|
|
331
|
-
phase: task.phase,
|
|
332
|
-
gate: task.gate,
|
|
333
|
-
status: task.status,
|
|
334
|
-
risk: task.risk,
|
|
335
|
-
areas: task.areas,
|
|
336
|
-
record: archiveRecordPath(task.id),
|
|
337
|
-
digest: digest(content),
|
|
338
|
-
lessonCount: task.lessons?.length ?? 0,
|
|
339
|
-
predecessorTaskId: task.predecessorTaskId,
|
|
340
|
-
successorTaskId: task.successorTaskId,
|
|
341
|
-
createdAt: task.createdAt,
|
|
342
|
-
updatedAt: task.updatedAt
|
|
343
|
-
});
|
|
344
|
-
/** Marks pre-existing ready G2 waits during migration; the marker is the only exemption from repair-bound enforcement. */
|
|
345
|
-
export const stampLegacyG2Waits = (state) => {
|
|
346
|
-
const stamped = [];
|
|
347
|
-
for (const task of Object.values(state.tasks)) {
|
|
348
|
-
if (task.legacyG2Wait !== undefined)
|
|
349
|
-
continue;
|
|
350
|
-
if (task.phase !== "build" || task.gate !== "G2_codereview" || task.status !== "blocked_on_user")
|
|
351
|
-
continue;
|
|
352
|
-
if (!task.tasks.every((item) => item.status === "done" || item.status === "deferred") || !hasVerification(task) || !hasReview(task))
|
|
353
|
-
continue;
|
|
354
|
-
task.legacyG2Wait = { migratedAt: task.updatedAt, source: legacyG2WaitSource };
|
|
355
|
-
stamped.push(task.id);
|
|
356
|
-
}
|
|
357
|
-
return stamped;
|
|
358
|
-
};
|
|
359
|
-
export const prepareV4Migration = (value) => {
|
|
360
|
-
const source = validateState(value);
|
|
361
|
-
const next = { schemaVersion: 4, tasks: {}, archive: {} };
|
|
362
|
-
const records = [];
|
|
363
|
-
for (const task of Object.values(source.tasks)) {
|
|
364
|
-
if (!terminal(task))
|
|
365
|
-
next.tasks[task.id] = task;
|
|
366
|
-
else {
|
|
367
|
-
const content = taskRecordContent(task);
|
|
368
|
-
next.archive[task.id] = archivedSummary(task, content);
|
|
369
|
-
records.push({ path: archiveRecordPath(task.id), content });
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
for (const item of Object.values(source.archive ?? {}))
|
|
373
|
-
next.archive[item.id] = item;
|
|
374
|
-
const stamped = Number(source.schemaVersion) < 4 ? stampLegacyG2Waits(next) : [];
|
|
375
|
-
validateState(next);
|
|
376
|
-
const lessons = Object.values(source.tasks).flatMap((task) => task.lessons ?? []);
|
|
377
|
-
return { state: next, records, lessonIndex: createLessonIndex(lessons, lessonStateDigest(next)), stamped };
|
|
378
|
-
};
|
|
379
|
-
export const migrateState = (root, state = loadState(root)) => {
|
|
380
|
-
if (state.schemaVersion === 4)
|
|
381
|
-
return { migrated: false, archived: [], stamped: [] };
|
|
382
|
-
const migration = prepareV4Migration(state);
|
|
383
|
-
for (const record of migration.records) {
|
|
384
|
-
assertNoSymlinkPath(root, record.path);
|
|
385
|
-
const path = join(resolve(root), record.path);
|
|
386
|
-
if (existsSync(path)) {
|
|
387
|
-
if (readFileSync(path, "utf8") !== record.content)
|
|
388
|
-
throw new Error(`Archived task record conflicts: ${record.path}`);
|
|
389
|
-
}
|
|
390
|
-
else
|
|
391
|
-
writeAtomic(path, record.content);
|
|
392
|
-
}
|
|
393
|
-
saveState(root, migration.state);
|
|
394
|
-
rebuildLessonIndex(root, migration.state);
|
|
395
|
-
return { migrated: true, archived: Object.keys(migration.state.archive ?? {}), stamped: migration.stamped };
|
|
396
|
-
};
|
|
397
|
-
export const loadState = (root) => {
|
|
398
|
-
const path = statePath(root);
|
|
399
|
-
assertNoSymlinkPath(root, ".agents/data/state/aidlc-state.json");
|
|
400
|
-
return existsSync(path) ? validateState(parseJsonFile(path, "AI-DLC state file", "restore it from git history or the archive records")) : emptyState();
|
|
401
|
-
};
|
|
402
|
-
export const loadTask = (root, id, state = loadState(root)) => {
|
|
403
|
-
if (state.tasks[id])
|
|
404
|
-
return state.tasks[id];
|
|
405
|
-
const summary = state.archive?.[id];
|
|
406
|
-
if (!summary)
|
|
407
|
-
return undefined;
|
|
408
|
-
const path = join(resolve(root), summary.record);
|
|
409
|
-
assertNoSymlinkPath(root, summary.record);
|
|
410
|
-
if (!existsSync(path))
|
|
411
|
-
throw new Error(`Archived task record is missing: ${summary.record}`);
|
|
412
|
-
const content = readFileSync(path, "utf8");
|
|
413
|
-
if (digest(content) !== summary.digest)
|
|
414
|
-
throw new Error(`Archived task digest mismatch: ${id}`);
|
|
415
|
-
const task = parseJsonText(content, `Archived task record ${id}`);
|
|
416
|
-
validateTask(id, task);
|
|
417
|
-
if (!terminal(task) || task.status !== summary.status || task.updatedAt !== summary.updatedAt || (task.lessons?.length ?? 0) !== summary.lessonCount)
|
|
418
|
-
throw new Error(`Archived task summary mismatch: ${id}`);
|
|
419
|
-
return task;
|
|
420
|
-
};
|
|
421
|
-
export const saveState = (root, state) => {
|
|
422
|
-
const normalized = validateState(structuredClone(state));
|
|
423
|
-
const persisted = existsSync(statePath(root)) ? loadState(root) : undefined;
|
|
424
|
-
const archived = [];
|
|
425
|
-
if (normalized.schemaVersion >= 3) {
|
|
426
|
-
normalized.archive ??= {};
|
|
427
|
-
for (const task of Object.values(normalized.tasks))
|
|
428
|
-
if (terminal(task)) {
|
|
429
|
-
const content = taskRecordContent(task);
|
|
430
|
-
const summary = archivedSummary(task, content);
|
|
431
|
-
const path = join(resolve(root), summary.record);
|
|
432
|
-
assertNoSymlinkPath(root, summary.record);
|
|
433
|
-
if (existsSync(path)) {
|
|
434
|
-
const current = readFileSync(path, "utf8");
|
|
435
|
-
if (digest(current) !== summary.digest) {
|
|
436
|
-
let orphan;
|
|
437
|
-
try {
|
|
438
|
-
orphan = JSON.parse(current);
|
|
439
|
-
validateTask(task.id, orphan);
|
|
440
|
-
}
|
|
441
|
-
catch {
|
|
442
|
-
throw new Error(`Archived task record conflicts: ${task.id}`);
|
|
443
|
-
}
|
|
444
|
-
const authority = persisted?.tasks[task.id];
|
|
445
|
-
if (persisted?.archive?.[task.id] || !authority || authority.createdAt !== task.createdAt || !terminal(orphan) || orphan.id !== task.id || orphan.createdAt !== task.createdAt)
|
|
446
|
-
throw new Error(`Archived task record conflicts: ${task.id}`);
|
|
447
|
-
writeAtomic(path, content);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
else
|
|
451
|
-
writeAtomic(path, content);
|
|
452
|
-
normalized.archive[task.id] = summary;
|
|
453
|
-
delete normalized.tasks[task.id];
|
|
454
|
-
archived.push(task.id);
|
|
455
|
-
}
|
|
456
|
-
validateState(normalized);
|
|
457
|
-
}
|
|
458
|
-
assertNoSymlinkPath(root, ".agents/data/state/aidlc-state.json");
|
|
459
|
-
writeAtomic(statePath(root), `${JSON.stringify(normalized, null, 2)}\n`);
|
|
460
|
-
state.tasks = normalized.tasks;
|
|
461
|
-
state.archive = normalized.archive;
|
|
462
|
-
state.schemaVersion = normalized.schemaVersion;
|
|
463
|
-
return archived;
|
|
464
|
-
};
|
|
465
|
-
export const latestApproval = (task, gate) => {
|
|
466
|
-
for (let index = task.evidence.length - 1; index >= 0; index -= 1) {
|
|
467
|
-
const item = task.evidence[index];
|
|
468
|
-
if (item.kind === "approval" && item.gate === gate)
|
|
469
|
-
return item;
|
|
470
|
-
}
|
|
471
|
-
return undefined;
|
|
472
|
-
};
|
|
473
|
-
export const buildBoundary = (task) => {
|
|
474
|
-
let boundary;
|
|
475
|
-
for (let index = 0; index < task.evidence.length; index += 1) {
|
|
476
|
-
const item = task.evidence[index];
|
|
477
|
-
if (item.kind === "approval" && item.gate === "G1_review" && item.result === "pass")
|
|
478
|
-
boundary = index;
|
|
479
|
-
}
|
|
480
|
-
return boundary;
|
|
481
|
-
};
|
|
482
|
-
export const hasApproval = (task, gate) => latestApproval(task, gate)?.result === "pass";
|
|
483
|
-
const currentBuildEvidence = (task) => {
|
|
484
|
-
const boundary = buildBoundary(task);
|
|
485
|
-
return boundary === undefined ? [] : task.evidence.slice(boundary + 1);
|
|
486
|
-
};
|
|
487
|
-
const latestInOrder = (items, predicate) => {
|
|
488
|
-
for (let index = items.length - 1; index >= 0; index -= 1)
|
|
489
|
-
if (predicate(items[index]))
|
|
490
|
-
return items[index];
|
|
491
|
-
return undefined;
|
|
492
|
-
};
|
|
493
|
-
export const hasCurrentBuildApproval = (task, gate) => latestInOrder(currentBuildEvidence(task), (item) => item.kind === "approval" && item.gate === gate)?.result === "pass";
|
|
494
|
-
export const hasAreaVerification = (task, area) => {
|
|
495
|
-
const evidence = currentBuildEvidence(task).filter((item) => (item.kind === "test" || item.kind === "lint") && (item.area === area || (!item.area && task.areas.length === 1)));
|
|
496
|
-
const kinds = [...new Set(evidence.map((item) => item.kind))];
|
|
497
|
-
return kinds.length > 0 && kinds.every((kind) => latestInOrder(evidence, (item) => item.kind === kind)?.result === "pass");
|
|
498
|
-
};
|
|
499
|
-
export const hasVerification = (task) => task.areas.every((area) => hasAreaVerification(task, area));
|
|
500
|
-
export const hasReview = (task) => latestInOrder(currentBuildEvidence(task), (item) => item.kind === "review")?.result === "pass";
|
|
501
|
-
export const repairBounds = (task) => {
|
|
502
|
-
const evidence = currentBuildEvidence(task);
|
|
503
|
-
const verifyFailures = {};
|
|
504
|
-
for (const item of evidence)
|
|
505
|
-
if (["test", "lint"].includes(item.kind) && item.result === "fail") {
|
|
506
|
-
const area = item.area ?? (task.areas.length === 1 ? task.areas[0] : "unscoped");
|
|
507
|
-
verifyFailures[area] = (verifyFailures[area] ?? 0) + 1;
|
|
508
|
-
}
|
|
509
|
-
const reviewFailures = evidence.filter((item) => item.kind === "review" && item.result === "fail").length;
|
|
510
|
-
const exhaustedAreas = Object.keys(verifyFailures).filter((area) => verifyFailures[area] >= 3).sort();
|
|
511
|
-
return { verifyFailures, reviewFailures, exhaustedAreas, verifyExhausted: exhaustedAreas.length > 0, reviewExhausted: reviewFailures >= 2 };
|
|
512
|
-
};
|
|
513
|
-
/** True only for a migration-stamped legacy G2 wait that is still fully ready; the stamp is written exclusively by schema migration. */
|
|
514
|
-
export const isLegacyG2Wait = (task) => task.legacyG2Wait !== undefined && task.phase === "build" && task.gate === "G2_codereview" && task.status === "blocked_on_user" && task.tasks.every((item) => item.status === "done" || item.status === "deferred") && hasVerification(task) && hasReview(task);
|
|
515
|
-
export const transitionDiagnostics = (task, target) => {
|
|
516
|
-
const diagnostics = [];
|
|
517
|
-
const expected = phases.indexOf(task.phase) + 1;
|
|
518
|
-
if (target === task.phase || phases.indexOf(target) !== expected)
|
|
519
|
-
diagnostics.push({ level: "ERROR", code: "STATE_TRANSITION", message: `Cannot transition ${task.phase} → ${target}` });
|
|
520
|
-
if (target === "plan" && !hasApproval(task, "G0_confirm"))
|
|
521
|
-
diagnostics.push({ level: "ERROR", code: "G0_APPROVAL", message: "G0 approval evidence is required" });
|
|
522
|
-
if (target === "build") {
|
|
523
|
-
if (!hasApproval(task, "G1_review"))
|
|
524
|
-
diagnostics.push({ level: "ERROR", code: "G1_APPROVAL", message: "G1 approval evidence is required" });
|
|
525
|
-
for (const decision of task.decisions)
|
|
526
|
-
if (decision.status === "unresolved")
|
|
527
|
-
diagnostics.push({ level: "ERROR", code: "UNRESOLVED_DECISION", message: `Decision ${decision.id} is unresolved` });
|
|
528
|
-
}
|
|
529
|
-
if (target === "wrap") {
|
|
530
|
-
if (!hasVerification(task))
|
|
531
|
-
diagnostics.push({ level: "ERROR", code: "VERIFY_EVIDENCE", message: "Latest current-build verification evidence must pass for every affected area" });
|
|
532
|
-
if (!hasReview(task))
|
|
533
|
-
diagnostics.push({ level: "ERROR", code: "REVIEW_EVIDENCE", message: "Latest current-build code-review evidence must pass" });
|
|
534
|
-
if (!hasCurrentBuildApproval(task, "G2_codereview"))
|
|
535
|
-
diagnostics.push({ level: "ERROR", code: "G2_APPROVAL", message: "Current-build G2 approval evidence is required" });
|
|
536
|
-
}
|
|
537
|
-
if (target === "done" && !task.lessonDisposition)
|
|
538
|
-
diagnostics.push({ level: "ERROR", code: "LESSON_DISPOSITION", message: "Wrap must record lessons or an explicit no-lesson disposition" });
|
|
539
|
-
return diagnostics;
|
|
540
|
-
};
|
|
541
|
-
export const transitionTask = (state, id, target) => {
|
|
542
|
-
const task = state.tasks[id];
|
|
543
|
-
if (!task)
|
|
544
|
-
throw new Error(`Unknown active task: ${id}`);
|
|
545
|
-
if (terminal(task))
|
|
546
|
-
throw new Error(`Terminal task cannot transition: ${id}`);
|
|
547
|
-
const errors = transitionDiagnostics(task, target).filter((item) => item.level === "ERROR");
|
|
548
|
-
if (errors.length)
|
|
549
|
-
throw new Error(errors.map((item) => item.message).join("; "));
|
|
550
|
-
task.phase = target;
|
|
551
|
-
task.gate = phaseGate(target);
|
|
552
|
-
task.status = target === "done" ? "done" : "active";
|
|
553
|
-
delete task.legacyG2Wait;
|
|
554
|
-
task.updatedAt = new Date().toISOString();
|
|
555
|
-
return task;
|
|
556
|
-
};
|
|
557
|
-
export const readyTaskItems = (task) => {
|
|
558
|
-
const byId = new Map(task.tasks.map((item) => [item.id, item]));
|
|
559
|
-
if (byId.size !== task.tasks.length)
|
|
560
|
-
throw new Error(`Duplicate execution item: ${task.id}`);
|
|
561
|
-
const visiting = new Set();
|
|
562
|
-
const visited = new Set();
|
|
563
|
-
const visit = (id) => {
|
|
564
|
-
if (visiting.has(id))
|
|
565
|
-
throw new Error(`Cyclic execution dependency: ${task.id}/${id}`);
|
|
566
|
-
if (visited.has(id))
|
|
567
|
-
return;
|
|
568
|
-
visiting.add(id);
|
|
569
|
-
for (const dep of byId.get(id)?.dependsOn ?? []) {
|
|
570
|
-
if (dep === id || !byId.has(dep))
|
|
571
|
-
throw new Error(`Unknown execution dependency: ${task.id}/${id}/${dep}`);
|
|
572
|
-
visit(dep);
|
|
573
|
-
}
|
|
574
|
-
visiting.delete(id);
|
|
575
|
-
visited.add(id);
|
|
576
|
-
};
|
|
577
|
-
for (const item of task.tasks) {
|
|
578
|
-
if (item.dependsOn !== undefined && (!Array.isArray(item.dependsOn) || new Set(item.dependsOn).size !== item.dependsOn.length || item.dependsOn.some((dep) => typeof dep !== "string" || !dep)))
|
|
579
|
-
throw new Error(`Invalid execution dependencies: ${task.id}/${item.id}`);
|
|
580
|
-
if (item.ownedPaths !== undefined && (!Array.isArray(item.ownedPaths) || new Set(item.ownedPaths).size !== item.ownedPaths.length || item.ownedPaths.some((path) => typeof path !== "string" || !path || isAbsolute(path) || path === ".." || path.startsWith("../") || path.includes("\\") || path.split("/").includes(".."))))
|
|
581
|
-
throw new Error(`Invalid owned paths: ${task.id}/${item.id}`);
|
|
582
|
-
visit(item.id);
|
|
583
|
-
}
|
|
584
|
-
const ready = [];
|
|
585
|
-
const blocked = [];
|
|
586
|
-
for (const item of task.tasks) {
|
|
587
|
-
const waitingOn = (item.dependsOn ?? []).filter((dep) => byId.get(dep)?.status !== "done");
|
|
588
|
-
if (item.status === "done" && waitingOn.length)
|
|
589
|
-
throw new Error(`Completed item has incomplete prerequisites: ${task.id}/${item.id}`);
|
|
590
|
-
if (item.status !== "todo" && item.status !== "in_progress")
|
|
591
|
-
continue;
|
|
592
|
-
if (waitingOn.length)
|
|
593
|
-
blocked.push({ id: item.id, waitingOn });
|
|
594
|
-
else
|
|
595
|
-
ready.push(item.id);
|
|
596
|
-
}
|
|
597
|
-
return { ready, blocked };
|
|
598
|
-
};
|
|
599
|
-
export const nextAction = (task, root) => {
|
|
600
|
-
const rootSuffix = root === undefined ? "" : ` --root ${JSON.stringify(resolve(root))}`;
|
|
601
|
-
const script = (name) => root === undefined ? `node .agents/aidlc/scripts/${name}` : `node ${JSON.stringify(join(resolve(root), ".agents/aidlc/scripts", name))}`;
|
|
602
|
-
const stateCommand = script("state.mjs");
|
|
603
|
-
const contextCommand = script("context.mjs");
|
|
604
|
-
if (task.phase === "done")
|
|
605
|
-
return { classification: "complete", phase: task.phase, gate: task.gate, reason: "Task is complete" };
|
|
606
|
-
if (task.status === "closed" || task.status === "superseded")
|
|
607
|
-
return { classification: "terminal", phase: task.phase, gate: task.gate, outcome: task.status, successorTaskId: task.successorTaskId, reason: task.closure?.reason ?? "Task ended without successful completion" };
|
|
608
|
-
if (task.handoff)
|
|
609
|
-
return { classification: "blocked", phase: task.phase, gate: task.gate, reason: task.handoff.reason, actions: [
|
|
610
|
-
...(task.phase === "build" ? [{ id: "reopen_g1", command: `${stateCommand} task reopen ${task.id} --to plan --reason <reason> --source <explicit-user-request>${rootSuffix}` }] : []),
|
|
611
|
-
{ id: "create_successor", command: `${stateCommand} task create <new-task-id> --title <title>${rootSuffix}` }, { id: "supersede", command: `${stateCommand} task supersede ${task.id} --successor <new-task-id> --reason <reason> --source <explicit-user-request>${rootSuffix}` },
|
|
612
|
-
{ id: "close", command: `${stateCommand} task close ${task.id} --reason <reason> --source <explicit-user-request>${rootSuffix}` }
|
|
613
|
-
] };
|
|
614
|
-
if (task.status === "paused")
|
|
615
|
-
return { classification: "blocked", phase: task.phase, gate: task.gate, reason: "Task is paused", actions: [{ id: "resume", command: `${stateCommand} task status ${task.id} --status active${rootSuffix}` }, { id: "record_handoff", command: `${stateCommand} task handoff ${task.id} --kind <kind> --reason <reason> --source <source>${rootSuffix}` }] };
|
|
616
|
-
if (task.phase === "build" && !isLegacyG2Wait(task)) {
|
|
617
|
-
const bounds = repairBounds(task);
|
|
618
|
-
if (bounds.verifyExhausted || bounds.reviewExhausted) {
|
|
619
|
-
const kind = bounds.reviewExhausted ? "review_exhausted" : "repair_exhausted";
|
|
620
|
-
return { classification: "blocked", phase: task.phase, gate: task.gate, reason: `Repair bound exhausted (${bounds.verifyExhausted ? `3+ failed verify cycles in: ${bounds.exhaustedAreas.join(", ")}` : `${bounds.reviewFailures} failed review passes`}); record a durable handoff`, actions: [{ id: "record_handoff", command: `${stateCommand} task handoff ${task.id} --kind ${kind} --reason <summary-of-exhausted-repairs> --source <agent>${rootSuffix}` }] };
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
if (task.status === "blocked_on_user" && task.gate === "G1_review" && task.decisions.some((decision) => decision.status === "unresolved"))
|
|
624
|
-
return { classification: "run_phase", phase: task.phase, gate: task.gate, command: `${contextCommand} ${task.id} --phase plan${rootSuffix}`, reason: "Invalid premature G1 wait; resolve every decision before presenting G1" };
|
|
625
|
-
if (task.status === "blocked_on_user" && task.gate === "G2_codereview" && (task.tasks.some((item) => !["done", "deferred"].includes(item.status)) || !hasVerification(task) || !hasReview(task))) {
|
|
626
|
-
const open = task.tasks.filter((item) => item.status === "in_progress" || item.status === "todo");
|
|
627
|
-
const ready = new Set(readyTaskItems(task).ready);
|
|
628
|
-
const item = open.find((entry) => entry.status === "in_progress" && ready.has(entry.id)) ?? open.find((entry) => ready.has(entry.id));
|
|
629
|
-
if (item)
|
|
630
|
-
return { classification: "run_phase", phase: task.phase, gate: task.gate, itemId: item.id, remainingItems: open.length, command: `${contextCommand} ${task.id} --phase build --item ${item.id}${rootSuffix}`, reason: `Invalid premature G2 wait; continue build item ${item.id}` };
|
|
631
|
-
if (open.length)
|
|
632
|
-
return { classification: "blocked", phase: task.phase, gate: task.gate, reason: `No dependency-ready build item: ${readyTaskItems(task).blocked.map((entry) => `${entry.id} waits for ${entry.waitingOn.join(",")}`).join("; ")}` };
|
|
633
|
-
return { classification: "run_phase", phase: task.phase, gate: task.gate, remainingItems: 0, command: `${contextCommand} ${task.id} --phase build${rootSuffix}`, reason: "Invalid premature G2 wait; continue verification and adversarial review before presenting G2" };
|
|
634
|
-
}
|
|
635
|
-
if (task.status === "blocked_on_user" && task.gate === "none")
|
|
636
|
-
return { classification: "run_phase", phase: task.phase, gate: task.gate, command: `${stateCommand} task status ${task.id} --status active${rootSuffix}`, reason: "Recover invalid wait at a gateless wrap phase before continuing wrap" };
|
|
637
|
-
if (task.status === "blocked_on_user")
|
|
638
|
-
return { classification: "await_user", phase: task.phase, gate: task.gate, command: `${stateCommand} gate approve ${task.id} --gate ${task.gate} --source <explicit-user-approval>${rootSuffix}`, reason: `Explicit human approval is required at ${task.gate}` };
|
|
639
|
-
if (task.phase === "build") {
|
|
640
|
-
const open = task.tasks.filter((item) => item.status === "in_progress" || item.status === "todo");
|
|
641
|
-
const ready = new Set(readyTaskItems(task).ready);
|
|
642
|
-
const item = open.find((entry) => entry.status === "in_progress" && ready.has(entry.id)) ?? open.find((entry) => ready.has(entry.id));
|
|
643
|
-
if (item)
|
|
644
|
-
return { classification: "run_phase", phase: task.phase, gate: task.gate, itemId: item.id, remainingItems: open.length, command: `${contextCommand} ${task.id} --phase build --item ${item.id}${rootSuffix}`, reason: `Continue build item ${item.id}; ${open.length} actionable item(s) remain before verification, review, and G2` };
|
|
645
|
-
if (open.length)
|
|
646
|
-
return { classification: "blocked", phase: task.phase, gate: task.gate, reason: `No dependency-ready build item: ${readyTaskItems(task).blocked.map((entry) => `${entry.id} waits for ${entry.waitingOn.join(",")}`).join("; ")}` };
|
|
647
|
-
return { classification: "run_phase", phase: task.phase, gate: task.gate, remainingItems: 0, command: `${contextCommand} ${task.id} --phase build${rootSuffix}`, reason: "All build items are terminal; continue verification, adversarial review, and G2 preparation" };
|
|
648
|
-
}
|
|
649
|
-
return { classification: "run_phase", phase: task.phase, gate: task.gate, command: `${contextCommand} ${task.id} --phase ${task.phase}${rootSuffix}`, reason: `Continue ${task.phase} until the next human gate, real blocker, or completion` };
|
|
650
|
-
};
|
|
651
|
-
const audited = (reason, source) => { if (!text(reason) || !text(source))
|
|
652
|
-
throw new Error("Lifecycle mutation requires non-empty --reason and --source"); };
|
|
653
|
-
export const handoffTask = (state, id, kind, reason, source, recordedAt = new Date().toISOString()) => {
|
|
654
|
-
const task = state.tasks[id];
|
|
655
|
-
if (!task || terminal(task) || !handoffKinds.includes(kind))
|
|
656
|
-
throw new Error(`Task cannot enter handoff: ${id}`);
|
|
657
|
-
audited(reason, source);
|
|
658
|
-
if (task.handoff) {
|
|
659
|
-
if (task.handoff.kind === kind && task.handoff.reason === reason && task.handoff.source === source)
|
|
660
|
-
return task;
|
|
661
|
-
throw new Error(`Task already has a different handoff: ${id}`);
|
|
662
|
-
}
|
|
663
|
-
task.status = "paused";
|
|
664
|
-
task.handoff = { kind, reason, source, recordedAt };
|
|
665
|
-
delete task.legacyG2Wait;
|
|
666
|
-
task.updatedAt = recordedAt;
|
|
667
|
-
return task;
|
|
668
|
-
};
|
|
669
|
-
export const closeTask = (state, id, reason, source, recordedAt = new Date().toISOString()) => {
|
|
670
|
-
const task = state.tasks[id];
|
|
671
|
-
if (!task || task.status === "done" || task.status === "superseded")
|
|
672
|
-
throw new Error(`Task cannot be closed: ${id}`);
|
|
673
|
-
audited(reason, source);
|
|
674
|
-
if (task.status === "closed") {
|
|
675
|
-
if (task.closure?.reason === reason && task.closure.source === source)
|
|
676
|
-
return task;
|
|
677
|
-
throw new Error(`Task is already closed with different metadata: ${id}`);
|
|
678
|
-
}
|
|
679
|
-
task.status = "closed";
|
|
680
|
-
task.closure = { reason, source, recordedAt };
|
|
681
|
-
delete task.handoff;
|
|
682
|
-
delete task.successorTaskId;
|
|
683
|
-
delete task.legacyG2Wait;
|
|
684
|
-
task.updatedAt = recordedAt;
|
|
685
|
-
return task;
|
|
686
|
-
};
|
|
687
|
-
export const supersedeTask = (state, id, successorId, reason, source, recordedAt = new Date().toISOString()) => {
|
|
688
|
-
const task = state.tasks[id];
|
|
689
|
-
const successor = state.tasks[successorId];
|
|
690
|
-
audited(reason, source);
|
|
691
|
-
if (!task || task.status === "done" || task.status === "closed" || !successor || id === successorId)
|
|
692
|
-
throw new Error(`Task cannot be superseded: ${id}`);
|
|
693
|
-
if (task.status === "superseded") {
|
|
694
|
-
if (task.successorTaskId === successorId && task.closure?.reason === reason && task.closure.source === source)
|
|
695
|
-
return task;
|
|
696
|
-
throw new Error(`Task is already superseded with different metadata: ${id}`);
|
|
697
|
-
}
|
|
698
|
-
if (successor.phase !== "clarify" || successor.gate !== "G0_confirm" || terminal(successor) || hasApproval(successor, "G0_confirm") || (successor.predecessorTaskId && successor.predecessorTaskId !== id))
|
|
699
|
-
throw new Error(`Successor must be a fresh pre-G0 task: ${successorId}`);
|
|
700
|
-
task.status = "superseded";
|
|
701
|
-
task.closure = { reason, source, recordedAt };
|
|
702
|
-
task.successorTaskId = successorId;
|
|
703
|
-
delete task.handoff;
|
|
704
|
-
delete task.legacyG2Wait;
|
|
705
|
-
task.updatedAt = recordedAt;
|
|
706
|
-
successor.predecessorTaskId = id;
|
|
707
|
-
successor.updatedAt = recordedAt;
|
|
708
|
-
validateState(state);
|
|
709
|
-
return task;
|
|
710
|
-
};
|
|
711
|
-
export const reopenTask = (state, id, target, reason, source, recordedAt = new Date().toISOString()) => {
|
|
712
|
-
const task = state.tasks[id];
|
|
713
|
-
audited(reason, source);
|
|
714
|
-
if (!task || target !== "plan" || task.phase !== "build" || task.status !== "paused" || !task.handoff)
|
|
715
|
-
throw new Error(`Task cannot reopen G1: ${id}`);
|
|
716
|
-
task.evidence.push({ kind: "approval", gate: "G1_review", result: "fail", source, detail: `Reopened G1: ${reason}`, recordedAt });
|
|
717
|
-
for (const item of task.tasks)
|
|
718
|
-
if (item.status !== "deferred")
|
|
719
|
-
item.status = "todo";
|
|
720
|
-
task.phase = "plan";
|
|
721
|
-
task.gate = "G1_review";
|
|
722
|
-
task.status = "active";
|
|
723
|
-
delete task.handoff;
|
|
724
|
-
task.updatedAt = recordedAt;
|
|
725
|
-
return task;
|
|
726
|
-
};
|
|
727
|
-
export const recordLesson = (task, lesson, recordedAt = new Date().toISOString()) => {
|
|
728
|
-
if (task.phase !== "wrap" || task.status !== "active")
|
|
729
|
-
throw new Error(`Lessons can only be recorded during active wrap: ${task.id}`);
|
|
730
|
-
const current = task.lessons?.find((item) => item.id === lesson.id);
|
|
731
|
-
const next = { ...lesson, taskId: task.id, recordedAt };
|
|
732
|
-
validateLesson(task.id, next);
|
|
733
|
-
if (current) {
|
|
734
|
-
if (JSON.stringify({ ...current, recordedAt: undefined }) === JSON.stringify({ ...next, recordedAt: undefined }))
|
|
735
|
-
return current;
|
|
736
|
-
throw new Error(`Lesson already exists with different content: ${task.id}/${lesson.id}`);
|
|
737
|
-
}
|
|
738
|
-
task.lessons ??= [];
|
|
739
|
-
task.lessons.push(next);
|
|
740
|
-
task.lessonDisposition = { status: "captured", source: lesson.source, recordedAt };
|
|
741
|
-
task.updatedAt = recordedAt;
|
|
742
|
-
return next;
|
|
743
|
-
};
|
|
744
|
-
export const recordNoLessons = (task, reason, source, recordedAt = new Date().toISOString()) => {
|
|
745
|
-
if (task.phase !== "wrap" || task.status !== "active")
|
|
746
|
-
throw new Error(`Lesson disposition can only be recorded during active wrap: ${task.id}`);
|
|
747
|
-
audited(reason, source);
|
|
748
|
-
if (task.lessons?.length)
|
|
749
|
-
throw new Error(`Task already has captured lessons: ${task.id}`);
|
|
750
|
-
task.lessonDisposition = { status: "none", reason, source, recordedAt };
|
|
751
|
-
task.updatedAt = recordedAt;
|
|
752
|
-
};
|
|
753
|
-
const lessonEntry = (lesson) => ({ id: lesson.id, taskId: lesson.taskId, areas: lesson.areas, summary: lesson.summary, prevention: lesson.prevention, source: lesson.source, recordedAt: lesson.recordedAt });
|
|
754
|
-
export const lessonStateDigest = (state) => digest(JSON.stringify({
|
|
755
|
-
active: Object.values(state.tasks).flatMap((task) => task.lessons ?? []).sort((a, b) => `${a.taskId}:${a.id}`.localeCompare(`${b.taskId}:${b.id}`)),
|
|
756
|
-
archive: Object.values(state.archive ?? {}).map((item) => ({ id: item.id, digest: item.digest, lessonCount: item.lessonCount })).sort((a, b) => a.id.localeCompare(b.id))
|
|
757
|
-
}));
|
|
758
|
-
export const createLessonIndex = (lessons, sourceDigest) => ({ schemaVersion: 1, sourceDigest: sourceDigest ?? digest(JSON.stringify(lessons)), lessons: lessons.map(lessonEntry).sort((a, b) => b.recordedAt.localeCompare(a.recordedAt) || a.taskId.localeCompare(b.taskId) || a.id.localeCompare(b.id)) });
|
|
759
|
-
const allLessons = (root, state) => [
|
|
760
|
-
...Object.values(state.tasks).flatMap((task) => task.lessons ?? []),
|
|
761
|
-
...Object.keys(state.archive ?? {}).flatMap((id) => loadTask(root, id, state)?.lessons ?? [])
|
|
762
|
-
];
|
|
763
|
-
export const rebuildLessonIndex = (root, state = loadState(root)) => {
|
|
764
|
-
const index = createLessonIndex(allLessons(root, state), lessonStateDigest(state));
|
|
765
|
-
assertNoSymlinkPath(root, ".agents/data/lessons/index.json");
|
|
766
|
-
writeAtomic(lessonIndexPath(root), `${JSON.stringify(index, null, 2)}\n`);
|
|
767
|
-
return index;
|
|
768
|
-
};
|
|
769
|
-
const tokens = (value) => [...new Set(value.toLocaleLowerCase().split(/[^\p{L}\p{N}]+/u).filter((item) => item.length > 1))];
|
|
770
|
-
export const searchLessons = (root, state, query, areas = [], limit = 5) => {
|
|
771
|
-
const path = lessonIndexPath(root);
|
|
772
|
-
assertNoSymlinkPath(root, ".agents/data/lessons/index.json");
|
|
773
|
-
if (!existsSync(path)) {
|
|
774
|
-
const hasLessons = Object.values(state.tasks).some((task) => Boolean(task.lessons?.length)) || Object.values(state.archive ?? {}).some((task) => task.lessonCount > 0);
|
|
775
|
-
if (!hasLessons)
|
|
776
|
-
return [];
|
|
777
|
-
throw new Error("Lesson index is missing; run state.mjs lesson rebuild");
|
|
778
|
-
}
|
|
779
|
-
const index = parseJsonFile(path, "Lesson index", "run state.mjs lesson rebuild");
|
|
780
|
-
if (index.schemaVersion !== 1 || !Array.isArray(index.lessons) || index.sourceDigest !== lessonStateDigest(state))
|
|
781
|
-
throw new Error("Lesson index is stale; run state.mjs lesson rebuild");
|
|
782
|
-
const queryTokens = tokens(query);
|
|
783
|
-
const areaSet = new Set(areas.map((area) => area.toLocaleLowerCase()));
|
|
784
|
-
return index.lessons.map((item) => {
|
|
785
|
-
const haystack = new Set(tokens(`${item.summary} ${item.prevention} ${item.taskId} ${item.areas.join(" ")}`));
|
|
786
|
-
const areaScore = item.areas.some((area) => areaSet.has(area.toLocaleLowerCase())) ? 100 : 0;
|
|
787
|
-
const tokenScore = queryTokens.filter((token) => haystack.has(token)).length;
|
|
788
|
-
return { item, score: areaScore + tokenScore };
|
|
789
|
-
}).filter(({ score }) => score > 0 || (!queryTokens.length && !areaSet.size)).sort((a, b) => b.score - a.score || b.item.recordedAt.localeCompare(a.item.recordedAt)).slice(0, Math.max(1, Math.min(limit, 20))).map(({ item }) => item);
|
|
790
|
-
};
|
|
791
|
-
export const listTaskSummaries = (state, options = {}) => {
|
|
792
|
-
const active = Object.values(state.tasks).map((task) => ({ id: task.id, title: task.title, phase: task.phase, gate: task.gate, status: task.status, risk: task.risk, areas: task.areas, updatedAt: task.updatedAt, source: "active" }));
|
|
793
|
-
const archived = options.includeArchive ? Object.values(state.archive ?? {}).map((task) => ({ id: task.id, title: task.title, phase: task.phase, gate: task.gate, status: task.status, risk: task.risk, areas: task.areas, updatedAt: task.updatedAt, source: "archive" })) : [];
|
|
794
|
-
const queryTokens = tokens(options.query ?? "");
|
|
795
|
-
const statuses = new Set(options.statuses ?? []);
|
|
796
|
-
const filtered = [...active, ...archived].filter((item) => (!statuses.size || statuses.has(item.status)) && (!queryTokens.length || queryTokens.every((token) => tokens(`${item.id} ${item.title} ${item.areas.join(" ")}`).includes(token)))).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
|
|
797
|
-
const cursor = Math.max(0, options.cursor ?? 0);
|
|
798
|
-
const limit = Math.max(1, Math.min(options.limit ?? 20, 100));
|
|
799
|
-
const items = filtered.slice(cursor, cursor + limit);
|
|
800
|
-
return { items, nextCursor: cursor + items.length < filtered.length ? String(cursor + items.length) : null, total: filtered.length };
|
|
801
|
-
};
|
|
802
|
-
export const renderWorkplan = (task) => {
|
|
803
|
-
const decisions = task.decisions.length ? task.decisions.map((item) => `- [${item.status === "approved" ? "x" : " "}] ${item.id} — ${item.label}${item.resolution ? ` — ${item.resolution}` : ""}`).join("\n") : "- None";
|
|
804
|
-
const tasks = task.tasks.length ? task.tasks.map((item) => `- [${item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "}] ${item.id} — ${item.label}`).join("\n") : "- None";
|
|
805
|
-
const lifecycle = [`- Status: \`${task.status}\``, task.handoff ? `- Handoff: \`${task.handoff.kind}\` — ${task.handoff.reason}` : "", task.closure ? `- Closure: ${task.closure.reason}` : "", task.predecessorTaskId ? `- Predecessor: \`${task.predecessorTaskId}\`` : "", task.successorTaskId ? `- Successor: \`${task.successorTaskId}\`` : "", task.lessonDisposition ? `- Lessons: \`${task.lessonDisposition.status}\`` : ""].filter(Boolean).join("\n");
|
|
806
|
-
return `# Workplan — ${task.title} (\`${task.id}\`)\n\n> Generated from canonical JSON state.\n\n## Lifecycle\n${lifecycle}\n\n## 🧩 Decisions (Approve plan — before implementation)\n${decisions}\n\n## 🧩 Tasks (Review implementation — work items)\n${tasks}\n`;
|
|
807
|
-
};
|
|
808
|
-
export const renderViews = (root, state = loadState(root), selected = Object.values(state.tasks)) => {
|
|
809
|
-
for (const task of selected) {
|
|
810
|
-
if (!task.artifacts.workplan)
|
|
811
|
-
continue;
|
|
812
|
-
assertNoSymlinkPath(root, task.artifacts.workplan);
|
|
813
|
-
writeAtomic(join(resolve(root), task.artifacts.workplan), renderWorkplan(task));
|
|
814
|
-
}
|
|
815
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Facade for the lifecycle-state domain. Behavior-preserving re-export of the
|
|
3
|
+
* split modules under `src/state/`; import paths from outside remain unchanged.
|
|
4
|
+
*/
|
|
5
|
+
export * from "./state/shared.js";
|
|
6
|
+
export * from "./state/lock.js";
|
|
7
|
+
export * from "./state/items.js";
|
|
8
|
+
export * from "./state/transitions.js";
|
|
9
|
+
export * from "./state/validate.js";
|
|
10
|
+
export * from "./state/store.js";
|
|
11
|
+
export * from "./state/memory.js";
|
|
12
|
+
export * from "./state/lessons.js";
|
|
13
|
+
export * from "./state/archive.js";
|
|
14
|
+
export * from "./state/lifecycle.js";
|
|
15
|
+
export * from "./state/render.js";
|