@felixdotgo/aidlc-kit 0.1.0 → 0.2.0
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/DEVELOPMENT.md +14 -0
- package/README.md +14 -6
- package/dist/assets/.agents/aidlc/docker/Dockerfile.claude +7 -0
- package/dist/assets/.agents/aidlc/docker/Dockerfile.codex +9 -0
- package/dist/assets/.agents/aidlc/docker/Dockerfile.opencode +8 -0
- package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
- package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
- package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
- package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
- 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-authority.mjs +245 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +232 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +21 -4
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +86 -28
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +7 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +22 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +46 -15
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +22 -9
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +23 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +43 -15
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +82 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +60 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +142 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +31 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +3 -2
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +59 -4
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +85 -73
- package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
- package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/state.mjs +4 -0
- package/dist/assets/.agents/aidlc/templates/design.md +9 -0
- package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
- package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
- package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
- package/dist/assets/ui/bulma.min.css +3 -0
- package/dist/assets/ui/dashboard.css +414 -0
- package/dist/services/aidlc-coordination/Dockerfile +11 -3
- package/dist/services/aidlc-coordination/compose.yaml +12 -0
- package/dist/services/aidlc-coordination/package-lock.json +3 -1
- package/dist/services/aidlc-coordination/package.json +9 -4
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +59 -4
- package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
- package/dist/services/aidlc-coordination/src/server.mjs +9 -3
- package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
- package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
- package/dist/src/adapters.d.ts +0 -3
- package/dist/src/adapters.js +11 -6
- package/dist/src/cli/argv.d.ts +12 -0
- package/dist/src/cli/argv.js +62 -0
- package/dist/src/cli/commands.d.ts +8 -0
- package/dist/src/cli/commands.js +129 -0
- package/dist/src/cli/options.d.ts +25 -0
- package/dist/src/cli/options.js +32 -0
- package/dist/src/cli/prompt.d.ts +9 -0
- package/dist/src/cli/prompt.js +66 -0
- package/dist/src/cli.js +67 -217
- package/dist/src/context.js +2 -0
- package/dist/src/gate.d.ts +1 -0
- package/dist/src/gate.js +51 -37
- package/dist/src/installer.d.ts +19 -7
- package/dist/src/installer.js +111 -178
- package/dist/src/legacy.js +1 -0
- package/dist/src/managed-content.d.ts +7 -0
- package/dist/src/managed-content.js +15 -4
- package/dist/src/managed-planning.d.ts +65 -0
- package/dist/src/managed-planning.js +219 -0
- package/dist/src/mcp-setup.js +3 -1
- package/dist/src/model/config-contracts.d.ts +65 -0
- package/dist/src/model/config-contracts.js +1 -0
- package/dist/src/model/install-contracts.d.ts +29 -0
- package/dist/src/model/install-contracts.js +1 -0
- package/dist/src/model/lifecycle-enums.d.ts +13 -0
- package/dist/src/model/lifecycle-enums.js +1 -0
- package/dist/src/model/loop.d.ts +20 -0
- package/dist/src/model/loop.js +1 -0
- package/dist/src/model/manifest.d.ts +16 -0
- package/dist/src/model/manifest.js +1 -0
- package/dist/src/model/records.d.ts +138 -0
- package/dist/src/model/records.js +1 -0
- package/dist/src/model.d.ts +11 -264
- package/dist/src/model.js +1 -1
- package/dist/src/profiles.d.ts +8 -0
- package/dist/src/profiles.js +63 -8
- package/dist/src/project-migration.d.ts +1 -0
- package/dist/src/project-migration.js +7 -0
- package/dist/src/state/archive-record.d.ts +9 -0
- package/dist/src/state/archive-record.js +26 -0
- package/dist/src/state/commands.d.ts +18 -0
- package/dist/src/state/commands.js +27 -0
- package/dist/src/state/domain.d.ts +9 -0
- package/dist/src/state/domain.js +9 -0
- package/dist/src/state/guards.d.ts +7 -0
- package/dist/src/state/guards.js +17 -0
- package/dist/src/state/io.d.ts +10 -0
- package/dist/src/state/io.js +35 -0
- package/dist/src/state/items.d.ts +24 -0
- package/dist/src/state/items.js +147 -0
- package/dist/src/state/lessons.d.ts +7 -0
- package/dist/src/state/lessons.js +74 -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 +125 -0
- package/dist/src/state/memory.d.ts +8 -0
- package/dist/src/state/memory.js +100 -0
- package/dist/src/state/migrate.d.ts +17 -0
- package/dist/src/state/migrate.js +60 -0
- package/dist/src/state/partition.d.ts +15 -0
- package/dist/src/state/partition.js +90 -0
- package/dist/src/state/paths.d.ts +11 -0
- package/dist/src/state/paths.js +12 -0
- package/dist/src/state/render.d.ts +26 -0
- package/dist/src/state/render.js +32 -0
- package/dist/src/state/store.d.ts +4 -0
- package/dist/src/state/store.js +75 -0
- package/dist/src/state/transitions.d.ts +21 -0
- package/dist/src/state/transitions.js +95 -0
- package/dist/src/state/validate.d.ts +4 -0
- package/dist/src/state/validate.js +143 -0
- package/dist/src/state.d.ts +20 -117
- package/dist/src/state.js +20 -815
- package/dist/src/task-ui.d.ts +54 -0
- package/dist/src/task-ui.js +1570 -0
- package/dist/src/upgrade-board.d.ts +5 -0
- package/dist/src/upgrade-board.js +89 -0
- package/dist/src/upgrade.d.ts +3 -5
- package/dist/src/upgrade.js +174 -255
- package/dist/src/workflow.js +4 -4
- package/docs/README.md +6 -2
- package/docs/architecture.md +12 -8
- package/docs/command-reference.md +83 -19
- package/docs/configuration.md +30 -7
- package/docs/coordination-service.md +20 -5
- package/docs/design-decisions.md +83 -0
- package/docs/development.md +41 -2
- package/docs/loop-local-run.md +495 -0
- package/docs/loop-runbook.md +235 -0
- package/docs/operating-workflow.md +5 -2
- package/docs/testing-and-release.md +11 -7
- package/docs/troubleshooting.md +125 -0
- package/package.json +5 -2
- package/dist/src/ledger.d.ts +0 -22
- package/dist/src/ledger.js +0 -119
- package/dist/src/tracker.d.ts +0 -29
- package/dist/src/tracker.js +0 -41
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { buildArchiveRecord } from "./archive-record.js";
|
|
3
|
+
import { legacyG2WaitSource, terminal } from "./domain.js";
|
|
4
|
+
import { safeResolvedPath, writeAtomic } from "./io.js";
|
|
5
|
+
import { createLessonIndex, lessonStateDigest, rebuildLessonIndex } from "./lessons.js";
|
|
6
|
+
import { hasReview, hasVerification } from "./transitions.js";
|
|
7
|
+
import { loadState, saveState } from "./store.js";
|
|
8
|
+
import { validateState } from "./validate.js";
|
|
9
|
+
/** Marks pre-existing ready G2 waits during migration; the marker is the only exemption from repair-bound enforcement. */
|
|
10
|
+
export const stampLegacyG2Waits = (state) => {
|
|
11
|
+
const stamped = [];
|
|
12
|
+
for (const task of Object.values(state.tasks)) {
|
|
13
|
+
if (task.legacyG2Wait !== undefined)
|
|
14
|
+
continue;
|
|
15
|
+
if (task.phase !== "build" || task.gate !== "G2_codereview" || task.status !== "blocked_on_user")
|
|
16
|
+
continue;
|
|
17
|
+
if (!task.tasks.every((item) => item.status === "done" || item.status === "deferred") || !hasVerification(task) || !hasReview(task))
|
|
18
|
+
continue;
|
|
19
|
+
task.legacyG2Wait = { migratedAt: task.updatedAt, source: legacyG2WaitSource };
|
|
20
|
+
stamped.push(task.id);
|
|
21
|
+
}
|
|
22
|
+
return stamped;
|
|
23
|
+
};
|
|
24
|
+
export const prepareV4Migration = (value) => {
|
|
25
|
+
const source = validateState(value);
|
|
26
|
+
const next = { schemaVersion: 4, tasks: {}, archive: {} };
|
|
27
|
+
const records = [];
|
|
28
|
+
for (const task of Object.values(source.tasks)) {
|
|
29
|
+
if (!terminal(task))
|
|
30
|
+
next.tasks[task.id] = task;
|
|
31
|
+
else {
|
|
32
|
+
const { content, summary } = buildArchiveRecord(task);
|
|
33
|
+
next.archive[task.id] = summary;
|
|
34
|
+
records.push({ path: summary.record, content });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const item of Object.values(source.archive ?? {}))
|
|
38
|
+
next.archive[item.id] = item;
|
|
39
|
+
const stamped = Number(source.schemaVersion) < 4 ? stampLegacyG2Waits(next) : [];
|
|
40
|
+
validateState(next);
|
|
41
|
+
const lessons = Object.values(source.tasks).flatMap((task) => task.lessons ?? []);
|
|
42
|
+
return { state: next, records, lessonIndex: createLessonIndex(lessons, lessonStateDigest(next)), stamped };
|
|
43
|
+
};
|
|
44
|
+
export const migrateState = (root, state = loadState(root)) => {
|
|
45
|
+
if (state.schemaVersion === 4)
|
|
46
|
+
return { migrated: false, archived: [], stamped: [] };
|
|
47
|
+
const migration = prepareV4Migration(state);
|
|
48
|
+
for (const record of migration.records) {
|
|
49
|
+
const path = safeResolvedPath(root, record.path);
|
|
50
|
+
if (existsSync(path)) {
|
|
51
|
+
if (readFileSync(path, "utf8") !== record.content)
|
|
52
|
+
throw new Error(`Archived task record conflicts: ${record.path}`);
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
writeAtomic(path, record.content);
|
|
56
|
+
}
|
|
57
|
+
saveState(root, migration.state);
|
|
58
|
+
rebuildLessonIndex(root, migration.state);
|
|
59
|
+
return { migrated: true, archived: Object.keys(migration.state.archive ?? {}), stamped: migration.stamped };
|
|
60
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Diagnostic, TaskState } from "../model.js";
|
|
2
|
+
type Item = TaskState["tasks"][number];
|
|
3
|
+
export declare const pathOverlaps: (left: string, right: string) => boolean;
|
|
4
|
+
export declare const pathsOverlap: (left?: readonly string[], right?: readonly string[]) => boolean;
|
|
5
|
+
/** Pairs of open (not done/deferred) items that are not ordered by `dependsOn`. */
|
|
6
|
+
export declare const concurrentItemPairs: (task: TaskState) => Array<[Item, Item]>;
|
|
7
|
+
/** Approve-plan partition check. `strict` (enabled loop) makes findings ERRORs; otherwise WARNs. */
|
|
8
|
+
export declare const partitionDiagnostics: (task: TaskState, { strict }?: {
|
|
9
|
+
strict?: boolean;
|
|
10
|
+
}) => Diagnostic[];
|
|
11
|
+
/** Strict when `loop.mode` is local/fleet or inside a fleet-enabled coordination service; a missing config is `off`; an unreadable one fails closed. */
|
|
12
|
+
export declare const loopPartitionStrict: (root: string | undefined) => boolean;
|
|
13
|
+
/** ` — owns: a, b · after: x` suffix for a rendered workplan line. */
|
|
14
|
+
export declare const workplanScope: (item: Item) => string;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel work partition checks, mirroring `assets/aidlc/scripts/lib/store.mjs`
|
|
3
|
+
* (`pathOverlaps`, `pathsOverlap`, `concurrentItemPairs`, `partitionDiagnostics`,
|
|
4
|
+
* `loopPartitionStrict`). Owned paths overlap by path segment (`a` overlaps
|
|
5
|
+
* `a/b`, not `ab`); two open items can run concurrently unless one is a direct
|
|
6
|
+
* or transitive dependency of the other.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
const ownedPathKey = (path) => String(path).replace(/\/+$/, "");
|
|
11
|
+
export const pathOverlaps = (left, right) => {
|
|
12
|
+
const a = ownedPathKey(left);
|
|
13
|
+
const b = ownedPathKey(right);
|
|
14
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
15
|
+
};
|
|
16
|
+
export const pathsOverlap = (left = [], right = []) => left.some((a) => right.some((b) => pathOverlaps(a, b)));
|
|
17
|
+
const itemAncestors = (task) => {
|
|
18
|
+
const byId = new Map(task.tasks.map((item) => [item.id, item]));
|
|
19
|
+
const memo = new Map();
|
|
20
|
+
const walk = (id, trail = new Set()) => {
|
|
21
|
+
const cached = memo.get(id);
|
|
22
|
+
if (cached)
|
|
23
|
+
return cached;
|
|
24
|
+
const found = new Set();
|
|
25
|
+
if (trail.has(id))
|
|
26
|
+
return found;
|
|
27
|
+
trail.add(id);
|
|
28
|
+
for (const dep of byId.get(id)?.dependsOn ?? []) {
|
|
29
|
+
if (!byId.has(dep))
|
|
30
|
+
continue;
|
|
31
|
+
found.add(dep);
|
|
32
|
+
for (const deeper of walk(dep, trail))
|
|
33
|
+
found.add(deeper);
|
|
34
|
+
}
|
|
35
|
+
trail.delete(id);
|
|
36
|
+
memo.set(id, found);
|
|
37
|
+
return found;
|
|
38
|
+
};
|
|
39
|
+
return (id) => walk(id);
|
|
40
|
+
};
|
|
41
|
+
/** Pairs of open (not done/deferred) items that are not ordered by `dependsOn`. */
|
|
42
|
+
export const concurrentItemPairs = (task) => {
|
|
43
|
+
const open = task.tasks.filter((item) => item.status !== "done" && item.status !== "deferred");
|
|
44
|
+
const ancestors = itemAncestors(task);
|
|
45
|
+
const pairs = [];
|
|
46
|
+
for (let i = 0; i < open.length; i += 1)
|
|
47
|
+
for (let j = i + 1; j < open.length; j += 1) {
|
|
48
|
+
const a = open[i];
|
|
49
|
+
const b = open[j];
|
|
50
|
+
if (!ancestors(a.id).has(b.id) && !ancestors(b.id).has(a.id))
|
|
51
|
+
pairs.push([a, b]);
|
|
52
|
+
}
|
|
53
|
+
return pairs;
|
|
54
|
+
};
|
|
55
|
+
/** Approve-plan partition check. `strict` (enabled loop) makes findings ERRORs; otherwise WARNs. */
|
|
56
|
+
export const partitionDiagnostics = (task, { strict = true } = {}) => {
|
|
57
|
+
const level = strict ? "ERROR" : "WARN";
|
|
58
|
+
const diagnostics = [];
|
|
59
|
+
const unscoped = task.tasks.filter((item) => item.status !== "done" && item.status !== "deferred" && !(Array.isArray(item.ownedPaths) && item.ownedPaths.length));
|
|
60
|
+
if (unscoped.length)
|
|
61
|
+
diagnostics.push({ level, code: "PARTITION_SCOPE", message: `Build items need owned paths (--owns) for parallel workers: ${unscoped.map((item) => item.id).join(", ")}` });
|
|
62
|
+
for (const [a, b] of concurrentItemPairs(task)) {
|
|
63
|
+
const shared = (a.ownedPaths ?? []).filter((path) => (b.ownedPaths ?? []).some((other) => pathOverlaps(path, other)));
|
|
64
|
+
if (shared.length)
|
|
65
|
+
diagnostics.push({ level, code: "PARTITION_OVERLAP", message: `Items ${a.id} and ${b.id} can run concurrently but both own ${shared.join(", ")}; split the paths or add --depends-on` });
|
|
66
|
+
}
|
|
67
|
+
return diagnostics;
|
|
68
|
+
};
|
|
69
|
+
/** Strict when `loop.mode` is local/fleet or inside a fleet-enabled coordination service; a missing config is `off`; an unreadable one fails closed. */
|
|
70
|
+
export const loopPartitionStrict = (root) => {
|
|
71
|
+
if (process.env["MCP_FLEET_ENABLED"] === "1")
|
|
72
|
+
return true;
|
|
73
|
+
if (root === undefined)
|
|
74
|
+
return false;
|
|
75
|
+
const path = join(resolve(root), ".agents", "config.json");
|
|
76
|
+
if (!existsSync(path))
|
|
77
|
+
return false;
|
|
78
|
+
try {
|
|
79
|
+
const mode = JSON.parse(readFileSync(path, "utf8"))?.loop?.mode;
|
|
80
|
+
return mode === "local" || mode === "fleet";
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
/** ` — owns: a, b · after: x` suffix for a rendered workplan line. */
|
|
87
|
+
export const workplanScope = (item) => {
|
|
88
|
+
const parts = [item.ownedPaths?.length ? `owns: ${item.ownedPaths.join(", ")}` : "", item.dependsOn?.length ? `after: ${item.dependsOn.join(", ")}` : ""].filter(Boolean);
|
|
89
|
+
return parts.length ? ` — ${parts.join(" · ")}` : "";
|
|
90
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const statePathRelative = ".agents/data/state/aidlc-state.json";
|
|
2
|
+
export declare const lessonIndexPathRelative = ".agents/data/lessons/index.json";
|
|
3
|
+
export declare const memoryRegistryPathRelative = ".agents/data/memory/agentic-memory.json";
|
|
4
|
+
export declare const stateLockPathRelative = ".agents/data/state/.aidlc-state.lock";
|
|
5
|
+
export declare const stateLockGuardPathRelative = ".agents/data/state/.aidlc-state.lock.guard";
|
|
6
|
+
export declare const statePath: (root: string) => string;
|
|
7
|
+
export declare const lessonIndexPath: (root: string) => string;
|
|
8
|
+
export declare const memoryRegistryPath: (root: string) => string;
|
|
9
|
+
export declare const stateLockPath: (root: string) => string;
|
|
10
|
+
export declare const stateLockGuardPath: (root: string) => string;
|
|
11
|
+
export declare const archiveRecordPath: (id: string) => string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
export const statePathRelative = ".agents/data/state/aidlc-state.json";
|
|
3
|
+
export const lessonIndexPathRelative = ".agents/data/lessons/index.json";
|
|
4
|
+
export const memoryRegistryPathRelative = ".agents/data/memory/agentic-memory.json";
|
|
5
|
+
export const stateLockPathRelative = ".agents/data/state/.aidlc-state.lock";
|
|
6
|
+
export const stateLockGuardPathRelative = ".agents/data/state/.aidlc-state.lock.guard";
|
|
7
|
+
export const statePath = (root) => join(resolve(root), statePathRelative);
|
|
8
|
+
export const lessonIndexPath = (root) => join(resolve(root), lessonIndexPathRelative);
|
|
9
|
+
export const memoryRegistryPath = (root) => join(resolve(root), memoryRegistryPathRelative);
|
|
10
|
+
export const stateLockPath = (root) => join(resolve(root), stateLockPathRelative);
|
|
11
|
+
export const stateLockGuardPath = (root) => join(resolve(root), stateLockGuardPathRelative);
|
|
12
|
+
export const archiveRecordPath = (id) => `.agents/data/state/archive/${id}.json`;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Phase, Gate, TaskState, WorkflowState } from "../model.js";
|
|
2
|
+
export interface TaskSummary {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
phase: Phase;
|
|
6
|
+
gate: Gate;
|
|
7
|
+
status: TaskState["status"];
|
|
8
|
+
risk: TaskState["risk"];
|
|
9
|
+
areas: string[];
|
|
10
|
+
updatedAt: string;
|
|
11
|
+
source: "active" | "archive";
|
|
12
|
+
}
|
|
13
|
+
export declare const listTaskSummaries: (state: WorkflowState, options?: {
|
|
14
|
+
includeArchive?: boolean;
|
|
15
|
+
statuses?: string[];
|
|
16
|
+
query?: string;
|
|
17
|
+
limit?: number;
|
|
18
|
+
cursor?: number;
|
|
19
|
+
}) => {
|
|
20
|
+
items: TaskSummary[];
|
|
21
|
+
nextCursor: string | null;
|
|
22
|
+
total: number;
|
|
23
|
+
};
|
|
24
|
+
/** Renders the markdown workplan for a task. */
|
|
25
|
+
export declare const renderWorkplan: (task: TaskState) => string;
|
|
26
|
+
export declare const renderViews: (root: string, state?: WorkflowState, selected?: TaskState[]) => void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { tokens } from "./guards.js";
|
|
2
|
+
import { safeResolvedPath, writeAtomic } from "./io.js";
|
|
3
|
+
import { loadState } from "./store.js";
|
|
4
|
+
import { workplanScope } from "./partition.js";
|
|
5
|
+
export const listTaskSummaries = (state, options = {}) => {
|
|
6
|
+
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" }));
|
|
7
|
+
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" })) : [];
|
|
8
|
+
const queryTokens = tokens(options.query ?? "");
|
|
9
|
+
const statuses = new Set(options.statuses ?? []);
|
|
10
|
+
const filtered = [...active, ...archived]
|
|
11
|
+
.filter((item) => (!statuses.size || statuses.has(item.status)) &&
|
|
12
|
+
(!queryTokens.length || queryTokens.every((token) => tokens(`${item.id} ${item.title} ${item.areas.join(" ")}`).includes(token))))
|
|
13
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
|
|
14
|
+
const cursor = Math.max(0, options.cursor ?? 0);
|
|
15
|
+
const limit = Math.max(1, Math.min(options.limit ?? 20, 100));
|
|
16
|
+
const items = filtered.slice(cursor, cursor + limit);
|
|
17
|
+
return { items, nextCursor: cursor + items.length < filtered.length ? String(cursor + items.length) : null, total: filtered.length };
|
|
18
|
+
};
|
|
19
|
+
/** Renders the markdown workplan for a task. */
|
|
20
|
+
export const renderWorkplan = (task) => {
|
|
21
|
+
const decisions = task.decisions.length ? task.decisions.map((item) => `- [${item.status === "approved" ? "x" : " "}] ${item.id} — ${item.label}${item.resolution ? ` — ${item.resolution}` : ""}`).join("\n") : "- None";
|
|
22
|
+
const tasks = task.tasks.length ? task.tasks.map((item) => `- [${item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "}] ${item.id} — ${item.label}${workplanScope(item)}`).join("\n") : "- None";
|
|
23
|
+
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");
|
|
24
|
+
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`;
|
|
25
|
+
};
|
|
26
|
+
export const renderViews = (root, state = loadState(root), selected = Object.values(state.tasks)) => {
|
|
27
|
+
for (const task of selected) {
|
|
28
|
+
if (!task.artifacts.workplan)
|
|
29
|
+
continue;
|
|
30
|
+
writeAtomic(safeResolvedPath(root, task.artifacts.workplan), renderWorkplan(task));
|
|
31
|
+
}
|
|
32
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { TaskState, WorkflowState } from "../model.js";
|
|
2
|
+
export declare const loadState: (root: string) => WorkflowState;
|
|
3
|
+
export declare const loadTask: (root: string, id: string, state?: WorkflowState) => TaskState | undefined;
|
|
4
|
+
export declare const saveState: (root: string, state: WorkflowState) => string[];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { buildArchiveRecord } from "./archive-record.js";
|
|
3
|
+
import { digest, emptyState, terminal } from "./domain.js";
|
|
4
|
+
import { loadJsonOr, parseJsonText, safeResolvedPath, saveJson, writeAtomic } from "./io.js";
|
|
5
|
+
import { statePath, statePathRelative } from "./paths.js";
|
|
6
|
+
import { validateState, validateTask } from "./validate.js";
|
|
7
|
+
export const loadState = (root) => loadJsonOr(root, statePathRelative, validateState, emptyState, "AI-DLC state file", "restore it from git history or the archive records");
|
|
8
|
+
export const loadTask = (root, id, state = loadState(root)) => {
|
|
9
|
+
if (state.tasks[id])
|
|
10
|
+
return state.tasks[id];
|
|
11
|
+
const summary = state.archive?.[id];
|
|
12
|
+
if (!summary)
|
|
13
|
+
return undefined;
|
|
14
|
+
const path = safeResolvedPath(root, summary.record);
|
|
15
|
+
if (!existsSync(path))
|
|
16
|
+
throw new Error(`Archived task record is missing: ${summary.record}`);
|
|
17
|
+
const content = readFileSync(path, "utf8");
|
|
18
|
+
if (digest(content) !== summary.digest)
|
|
19
|
+
throw new Error(`Archived task digest mismatch: ${id}`);
|
|
20
|
+
const task = parseJsonText(content, `Archived task record ${id}`);
|
|
21
|
+
validateTask(id, task);
|
|
22
|
+
if (!terminal(task) || task.status !== summary.status || task.updatedAt !== summary.updatedAt || (task.lessons?.length ?? 0) !== summary.lessonCount)
|
|
23
|
+
throw new Error(`Archived task summary mismatch: ${id}`);
|
|
24
|
+
return task;
|
|
25
|
+
};
|
|
26
|
+
const archiveTerminalTasks = (normalized, persisted, root) => {
|
|
27
|
+
const archived = [];
|
|
28
|
+
normalized.archive ??= {};
|
|
29
|
+
for (const task of Object.values(normalized.tasks)) {
|
|
30
|
+
if (!terminal(task))
|
|
31
|
+
continue;
|
|
32
|
+
const { content, summary } = buildArchiveRecord(task);
|
|
33
|
+
const path = safeResolvedPath(root, summary.record);
|
|
34
|
+
if (existsSync(path)) {
|
|
35
|
+
const current = readFileSync(path, "utf8");
|
|
36
|
+
if (digest(current) !== summary.digest)
|
|
37
|
+
commitRecoveryRecordOrThrow(task, content, path, current, persisted);
|
|
38
|
+
}
|
|
39
|
+
else
|
|
40
|
+
writeAtomic(path, content);
|
|
41
|
+
normalized.archive[task.id] = summary;
|
|
42
|
+
delete normalized.tasks[task.id];
|
|
43
|
+
archived.push(task.id);
|
|
44
|
+
}
|
|
45
|
+
return archived;
|
|
46
|
+
};
|
|
47
|
+
/** Replaces an interrupted-write orphan only when the persisted catalog still proves the same active task identity. */
|
|
48
|
+
const commitRecoveryRecordOrThrow = (task, content, path, current, persisted) => {
|
|
49
|
+
let orphan;
|
|
50
|
+
try {
|
|
51
|
+
orphan = JSON.parse(current);
|
|
52
|
+
validateTask(task.id, orphan);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error(`Archived task record conflicts: ${task.id}`);
|
|
56
|
+
}
|
|
57
|
+
const authority = persisted?.tasks[task.id];
|
|
58
|
+
const identityHolds = authority && authority.createdAt === task.createdAt;
|
|
59
|
+
const orphanMatches = terminal(orphan) && orphan.id === task.id && orphan.createdAt === task.createdAt;
|
|
60
|
+
if (persisted?.archive?.[task.id] || !identityHolds || !orphanMatches)
|
|
61
|
+
throw new Error(`Archived task record conflicts: ${task.id}`);
|
|
62
|
+
writeAtomic(path, content);
|
|
63
|
+
};
|
|
64
|
+
export const saveState = (root, state) => {
|
|
65
|
+
const normalized = validateState(structuredClone(state));
|
|
66
|
+
const persisted = existsSync(statePath(root)) ? loadState(root) : undefined;
|
|
67
|
+
const archived = normalized.schemaVersion >= 3 ? archiveTerminalTasks(normalized, persisted, root) : [];
|
|
68
|
+
if (normalized.schemaVersion >= 3)
|
|
69
|
+
validateState(normalized);
|
|
70
|
+
saveJson(root, statePathRelative, normalized);
|
|
71
|
+
state.tasks = normalized.tasks;
|
|
72
|
+
state.archive = normalized.archive;
|
|
73
|
+
state.schemaVersion = normalized.schemaVersion;
|
|
74
|
+
return archived;
|
|
75
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Diagnostic, Gate, Phase, TaskState, WorkflowState } from "../model.js";
|
|
2
|
+
export declare const latestApproval: (task: TaskState, gate: Gate) => import("../model.js").Evidence | undefined;
|
|
3
|
+
/** Evidence index where the currently effective Approve plan approval starts; undefined when none exists. */
|
|
4
|
+
export declare const buildBoundary: (task: TaskState) => number | undefined;
|
|
5
|
+
export declare const hasApproval: (task: TaskState, gate: Gate) => boolean;
|
|
6
|
+
export declare const hasCurrentBuildApproval: (task: TaskState, gate: Gate) => boolean;
|
|
7
|
+
export declare const hasAreaVerification: (task: TaskState, area: string) => boolean;
|
|
8
|
+
export declare const hasVerification: (task: TaskState) => boolean;
|
|
9
|
+
export declare const hasReview: (task: TaskState) => boolean;
|
|
10
|
+
export interface RepairBounds {
|
|
11
|
+
verifyFailures: Record<string, number>;
|
|
12
|
+
reviewFailures: number;
|
|
13
|
+
exhaustedAreas: string[];
|
|
14
|
+
verifyExhausted: boolean;
|
|
15
|
+
reviewExhausted: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare const repairBounds: (task: TaskState) => RepairBounds;
|
|
18
|
+
/** True only for a migration-stamped legacy G2 wait that is still fully ready; the stamp is written exclusively by schema migration. */
|
|
19
|
+
export declare const isLegacyG2Wait: (task: TaskState) => boolean;
|
|
20
|
+
export declare const transitionDiagnostics: (task: TaskState, target: Phase) => Diagnostic[];
|
|
21
|
+
export declare const transitionTask: (state: WorkflowState, id: string, target: Phase) => TaskState;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { phaseGate, phases, terminal } from "./domain.js";
|
|
2
|
+
export const latestApproval = (task, gate) => {
|
|
3
|
+
for (let index = task.evidence.length - 1; index >= 0; index -= 1) {
|
|
4
|
+
const item = task.evidence[index];
|
|
5
|
+
if (item && item.kind === "approval" && item.gate === gate)
|
|
6
|
+
return item;
|
|
7
|
+
}
|
|
8
|
+
return undefined;
|
|
9
|
+
};
|
|
10
|
+
/** Evidence index where the currently effective Approve plan approval starts; undefined when none exists. */
|
|
11
|
+
export const buildBoundary = (task) => {
|
|
12
|
+
let boundary;
|
|
13
|
+
for (let index = 0; index < task.evidence.length; index += 1) {
|
|
14
|
+
const item = task.evidence[index];
|
|
15
|
+
if (item && item.kind === "approval" && item.gate === "G1_review" && item.result === "pass")
|
|
16
|
+
boundary = index;
|
|
17
|
+
}
|
|
18
|
+
return boundary;
|
|
19
|
+
};
|
|
20
|
+
export const hasApproval = (task, gate) => latestApproval(task, gate)?.result === "pass";
|
|
21
|
+
const currentBuildEvidence = (task) => {
|
|
22
|
+
const boundary = buildBoundary(task);
|
|
23
|
+
return boundary === undefined ? [] : task.evidence.slice(boundary + 1);
|
|
24
|
+
};
|
|
25
|
+
const latestInOrder = (items, predicate) => {
|
|
26
|
+
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
27
|
+
const item = items[index];
|
|
28
|
+
if (item && predicate(item))
|
|
29
|
+
return item;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
};
|
|
33
|
+
export const hasCurrentBuildApproval = (task, gate) => latestInOrder(currentBuildEvidence(task), (item) => item.kind === "approval" && item.gate === gate)?.result === "pass";
|
|
34
|
+
export const hasAreaVerification = (task, area) => {
|
|
35
|
+
const evidence = currentBuildEvidence(task).filter((item) => (item.kind === "test" || item.kind === "lint") && (item.area === area || (!item.area && task.areas.length === 1)));
|
|
36
|
+
const kinds = [...new Set(evidence.map((item) => item.kind))];
|
|
37
|
+
return kinds.length > 0 && kinds.every((kind) => latestInOrder(evidence, (item) => item.kind === kind)?.result === "pass");
|
|
38
|
+
};
|
|
39
|
+
export const hasVerification = (task) => task.areas.every((area) => hasAreaVerification(task, area));
|
|
40
|
+
export const hasReview = (task) => latestInOrder(currentBuildEvidence(task), (item) => item.kind === "review")?.result === "pass";
|
|
41
|
+
export const repairBounds = (task) => {
|
|
42
|
+
const evidence = currentBuildEvidence(task);
|
|
43
|
+
const verifyFailures = {};
|
|
44
|
+
for (const item of evidence)
|
|
45
|
+
if (["test", "lint"].includes(item.kind) && item.result === "fail") {
|
|
46
|
+
const area = item.area ?? (task.areas.length === 1 ? task.areas[0] : "unscoped");
|
|
47
|
+
verifyFailures[area] = (verifyFailures[area] ?? 0) + 1;
|
|
48
|
+
}
|
|
49
|
+
const reviewFailures = evidence.filter((item) => item.kind === "review" && item.result === "fail").length;
|
|
50
|
+
const exhaustedAreas = Object.keys(verifyFailures).filter((area) => (verifyFailures[area] ?? 0) >= 3).sort();
|
|
51
|
+
return { verifyFailures, reviewFailures, exhaustedAreas, verifyExhausted: exhaustedAreas.length > 0, reviewExhausted: reviewFailures >= 2 };
|
|
52
|
+
};
|
|
53
|
+
/** True only for a migration-stamped legacy G2 wait that is still fully ready; the stamp is written exclusively by schema migration. */
|
|
54
|
+
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);
|
|
55
|
+
export const transitionDiagnostics = (task, target) => {
|
|
56
|
+
const diagnostics = [];
|
|
57
|
+
if (target === task.phase || phases.indexOf(target) !== phases.indexOf(task.phase) + 1)
|
|
58
|
+
diagnostics.push({ level: "ERROR", code: "STATE_TRANSITION", message: `Cannot transition ${task.phase} → ${target}` });
|
|
59
|
+
if (target === "plan" && !hasApproval(task, "G0_confirm"))
|
|
60
|
+
diagnostics.push({ level: "ERROR", code: "G0_APPROVAL", message: "G0 approval evidence is required" });
|
|
61
|
+
if (target === "build") {
|
|
62
|
+
if (!hasApproval(task, "G1_review"))
|
|
63
|
+
diagnostics.push({ level: "ERROR", code: "G1_APPROVAL", message: "G1 approval evidence is required" });
|
|
64
|
+
for (const decision of task.decisions)
|
|
65
|
+
if (decision.status === "unresolved")
|
|
66
|
+
diagnostics.push({ level: "ERROR", code: "UNRESOLVED_DECISION", message: `Decision ${decision.id} is unresolved` });
|
|
67
|
+
}
|
|
68
|
+
if (target === "wrap") {
|
|
69
|
+
if (!hasVerification(task))
|
|
70
|
+
diagnostics.push({ level: "ERROR", code: "VERIFY_EVIDENCE", message: "Latest current-build verification evidence must pass for every affected area" });
|
|
71
|
+
if (!hasReview(task))
|
|
72
|
+
diagnostics.push({ level: "ERROR", code: "REVIEW_EVIDENCE", message: "Latest current-build code-review evidence must pass" });
|
|
73
|
+
if (!hasCurrentBuildApproval(task, "G2_codereview"))
|
|
74
|
+
diagnostics.push({ level: "ERROR", code: "G2_APPROVAL", message: "Current-build G2 approval evidence is required" });
|
|
75
|
+
}
|
|
76
|
+
if (target === "done" && !task.lessonDisposition)
|
|
77
|
+
diagnostics.push({ level: "ERROR", code: "LESSON_DISPOSITION", message: "Wrap must record lessons or an explicit no-lesson disposition" });
|
|
78
|
+
return diagnostics;
|
|
79
|
+
};
|
|
80
|
+
export const transitionTask = (state, id, target) => {
|
|
81
|
+
const task = state.tasks[id];
|
|
82
|
+
if (!task)
|
|
83
|
+
throw new Error(`Unknown active task: ${id}`);
|
|
84
|
+
if (terminal(task))
|
|
85
|
+
throw new Error(`Terminal task cannot transition: ${id}`);
|
|
86
|
+
const errors = transitionDiagnostics(task, target).filter((item) => item.level === "ERROR");
|
|
87
|
+
if (errors.length)
|
|
88
|
+
throw new Error(errors.map((item) => item.message).join("; "));
|
|
89
|
+
task.phase = target;
|
|
90
|
+
task.gate = phaseGate(target);
|
|
91
|
+
task.status = target === "done" ? "done" : "active";
|
|
92
|
+
delete task.legacyG2Wait;
|
|
93
|
+
task.updatedAt = new Date().toISOString();
|
|
94
|
+
return task;
|
|
95
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { LessonRecord, TaskState, WorkflowState } from "../model.js";
|
|
2
|
+
export declare const validateLesson: (taskId: string, lesson: LessonRecord) => void;
|
|
3
|
+
export declare const validateTask: (id: string, task: TaskState) => void;
|
|
4
|
+
export declare const validateState: (value: unknown) => WorkflowState;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { gates, handoffKinds, phaseGate, phases } from "./domain.js";
|
|
2
|
+
import { assertTaskArtifactPath, idPattern, iso, text } from "./guards.js";
|
|
3
|
+
import { readyTaskItems } from "./items.js";
|
|
4
|
+
import { archiveRecordPath } from "./paths.js";
|
|
5
|
+
const taskStatuses = ["active", "blocked_on_user", "paused", "done", "closed", "superseded"];
|
|
6
|
+
export const validateLesson = (taskId, lesson) => {
|
|
7
|
+
if (!lesson)
|
|
8
|
+
throw new Error(`Invalid lesson: ${taskId}`);
|
|
9
|
+
if (!idPattern.test(lesson.id) || lesson.taskId !== taskId)
|
|
10
|
+
throw new Error(`Invalid lesson: ${taskId}`);
|
|
11
|
+
if (!Array.isArray(lesson.areas) || !lesson.areas.length || lesson.areas.some((area) => !text(area)))
|
|
12
|
+
throw new Error(`Invalid lesson: ${taskId}`);
|
|
13
|
+
if (!text(lesson.summary) || !text(lesson.prevention) || !text(lesson.example))
|
|
14
|
+
throw new Error(`Invalid lesson: ${taskId}`);
|
|
15
|
+
if (!text(lesson.promotion) || !text(lesson.source) || !iso(lesson.recordedAt))
|
|
16
|
+
throw new Error(`Invalid lesson: ${taskId}`);
|
|
17
|
+
};
|
|
18
|
+
export const validateTask = (id, task) => {
|
|
19
|
+
if (!idPattern.test(id))
|
|
20
|
+
throw new Error(`Invalid task id: ${id}`);
|
|
21
|
+
if (!task || task.id !== id || !phases.includes(task.phase) || !gates.includes(task.gate) || task.gate !== phaseGate(task.phase))
|
|
22
|
+
throw new Error(`Invalid task state: ${id}`);
|
|
23
|
+
if (!Array.isArray(task.decisions) || !Array.isArray(task.tasks) || !Array.isArray(task.evidence) || (task.lessons !== undefined && !Array.isArray(task.lessons)))
|
|
24
|
+
throw new Error(`Invalid task collections: ${id}`);
|
|
25
|
+
if (!task.title || !["feature", "bug", "refactor", "infra"].includes(task.type) || !taskStatuses.includes(task.status) || (task.phase === "done") !== (task.status === "done"))
|
|
26
|
+
throw new Error(`Invalid task fields: ${id}`);
|
|
27
|
+
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)))
|
|
28
|
+
throw new Error(`Invalid task language, risk, or areas: ${id}`);
|
|
29
|
+
if (task.decisions.some((item) => !item.id || !item.label || !["unresolved", "approved", "changed", "dropped"].includes(item.status) || (item.status === "changed" && !item.resolution)))
|
|
30
|
+
throw new Error(`Invalid decisions: ${id}`);
|
|
31
|
+
if (task.featureId !== undefined && !idPattern.test(task.featureId))
|
|
32
|
+
throw new Error(`Invalid feature id: ${id}`);
|
|
33
|
+
if (task.tasks.some((item) => !item.id || !item.label || !["todo", "in_progress", "done", "deferred"].includes(item.status)))
|
|
34
|
+
throw new Error(`Invalid execution tasks: ${id}`);
|
|
35
|
+
readyTaskItems(task);
|
|
36
|
+
if (task.evidence.some((item) => !["approval", "spec", "test", "lint", "review", "diagnostic"].includes(item.kind) ||
|
|
37
|
+
!["pass", "fail", "skip"].includes(item.result) ||
|
|
38
|
+
!item.source ||
|
|
39
|
+
!iso(item.recordedAt) ||
|
|
40
|
+
(item.area !== undefined && !text(item.area)) ||
|
|
41
|
+
(item.gate !== undefined && !gates.includes(item.gate)) ||
|
|
42
|
+
(item.kind === "approval" && (!item.gate || item.gate === "none"))))
|
|
43
|
+
throw new Error(`Invalid evidence: ${id}`);
|
|
44
|
+
const lessonIds = new Set();
|
|
45
|
+
for (const lesson of task.lessons ?? []) {
|
|
46
|
+
validateLesson(id, lesson);
|
|
47
|
+
if (lessonIds.has(lesson.id))
|
|
48
|
+
throw new Error(`Duplicate lesson id: ${id}/${lesson.id}`);
|
|
49
|
+
lessonIds.add(lesson.id);
|
|
50
|
+
}
|
|
51
|
+
if (task.lessonDisposition && (!["captured", "none"].includes(task.lessonDisposition.status) ||
|
|
52
|
+
!text(task.lessonDisposition.source) ||
|
|
53
|
+
!iso(task.lessonDisposition.recordedAt) ||
|
|
54
|
+
(task.lessonDisposition.status === "captured" && !(task.lessons?.length)) ||
|
|
55
|
+
(task.lessonDisposition.status === "none" && (Boolean(task.lessons?.length) || !text(task.lessonDisposition.reason)))))
|
|
56
|
+
throw new Error(`Invalid lesson disposition: ${id}`);
|
|
57
|
+
if (task.handoff && (!handoffKinds.includes(task.handoff.kind) || !text(task.handoff.reason) || !text(task.handoff.source) || !iso(task.handoff.recordedAt) || task.status !== "paused"))
|
|
58
|
+
throw new Error(`Invalid task handoff: ${id}`);
|
|
59
|
+
if (task.legacyG2Wait !== undefined && (typeof task.legacyG2Wait !== "object" ||
|
|
60
|
+
Array.isArray(task.legacyG2Wait) ||
|
|
61
|
+
!iso(task.legacyG2Wait.migratedAt) ||
|
|
62
|
+
!text(task.legacyG2Wait.source) ||
|
|
63
|
+
task.phase !== "build" ||
|
|
64
|
+
task.gate !== "G2_codereview" ||
|
|
65
|
+
task.status !== "blocked_on_user"))
|
|
66
|
+
throw new Error(`Invalid legacy G2 wait marker: ${id}`);
|
|
67
|
+
if ((task.status === "closed" || task.status === "superseded") !== Boolean(task.closure))
|
|
68
|
+
throw new Error(`Invalid task closure: ${id}`);
|
|
69
|
+
if (task.closure && (!text(task.closure.reason) || !text(task.closure.source) || !iso(task.closure.recordedAt)))
|
|
70
|
+
throw new Error(`Invalid task closure: ${id}`);
|
|
71
|
+
if ((task.status === "superseded") !== Boolean(task.successorTaskId) || (task.status === "closed" && task.successorTaskId))
|
|
72
|
+
throw new Error(`Invalid task successor: ${id}`);
|
|
73
|
+
if (task.predecessorTaskId !== undefined && !text(task.predecessorTaskId))
|
|
74
|
+
throw new Error(`Invalid task predecessor: ${id}`);
|
|
75
|
+
for (const path of Object.values(task.artifacts))
|
|
76
|
+
if (path)
|
|
77
|
+
assertTaskArtifactPath(id, path);
|
|
78
|
+
};
|
|
79
|
+
const validateArchiveSummary = (id, item) => {
|
|
80
|
+
if (!idPattern.test(id))
|
|
81
|
+
throw new Error(`Invalid archived task summary: ${id}`);
|
|
82
|
+
if (!item || item.id !== id || !text(item.title))
|
|
83
|
+
throw new Error(`Invalid archived task summary: ${id}`);
|
|
84
|
+
if (!["feature", "bug", "refactor", "infra"].includes(item.type))
|
|
85
|
+
throw new Error(`Invalid archived task summary: ${id}`);
|
|
86
|
+
if (!phases.includes(item.phase) || !gates.includes(item.gate) || item.gate !== phaseGate(item.phase))
|
|
87
|
+
throw new Error(`Invalid archived task summary: ${id}`);
|
|
88
|
+
if (!["done", "closed", "superseded"].includes(item.status) || !["low", "normal", "high", "regulated"].includes(item.risk))
|
|
89
|
+
throw new Error(`Invalid archived task summary: ${id}`);
|
|
90
|
+
if (!Array.isArray(item.areas) || !item.areas.length || item.areas.some((area) => !text(area)))
|
|
91
|
+
throw new Error(`Invalid archived task summary: ${id}`);
|
|
92
|
+
if (item.record !== archiveRecordPath(id) || !/^[a-f0-9]{64}$/.test(item.digest))
|
|
93
|
+
throw new Error(`Invalid archived task summary: ${id}`);
|
|
94
|
+
if (!Number.isInteger(item.lessonCount) || item.lessonCount < 0 || !iso(item.createdAt) || !iso(item.updatedAt))
|
|
95
|
+
throw new Error(`Invalid archived task summary: ${id}`);
|
|
96
|
+
if ((item.status === "superseded") !== Boolean(item.successorTaskId) || (item.status === "closed" && item.successorTaskId) || (item.predecessorTaskId !== undefined && !text(item.predecessorTaskId)))
|
|
97
|
+
throw new Error(`Invalid archived task links: ${id}`);
|
|
98
|
+
};
|
|
99
|
+
export const validateState = (value) => {
|
|
100
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
101
|
+
throw new Error("Workflow state must be an object");
|
|
102
|
+
const state = value;
|
|
103
|
+
if (![1, 2, 3, 4].includes(Number(state.schemaVersion)) || !state.tasks || typeof state.tasks !== "object" || Array.isArray(state.tasks))
|
|
104
|
+
throw new Error("Unsupported workflow state schema");
|
|
105
|
+
if (Number(state.schemaVersion) >= 3 && (!state.archive || typeof state.archive !== "object" || Array.isArray(state.archive)))
|
|
106
|
+
throw new Error("Schema v3+ requires an archive catalog");
|
|
107
|
+
if (Number(state.schemaVersion) < 3 && state.archive !== undefined)
|
|
108
|
+
throw new Error("Legacy workflow state cannot contain an archive catalog");
|
|
109
|
+
if (Number(state.schemaVersion) < 4 && Object.values(state.tasks).some((task) => task?.legacyG2Wait !== undefined))
|
|
110
|
+
throw new Error("Legacy G2 wait markers require schema v4");
|
|
111
|
+
for (const [id, task] of Object.entries(state.tasks))
|
|
112
|
+
validateTask(id, task);
|
|
113
|
+
for (const [id, item] of Object.entries(state.archive ?? {})) {
|
|
114
|
+
if (state.tasks[id])
|
|
115
|
+
throw new Error(`Task exists in active and archive state: ${id}`);
|
|
116
|
+
validateArchiveSummary(id, item);
|
|
117
|
+
}
|
|
118
|
+
const references = { ...(state.archive ?? {}), ...state.tasks };
|
|
119
|
+
for (const [id, item] of Object.entries(references)) {
|
|
120
|
+
if (item.successorTaskId) {
|
|
121
|
+
const successor = references[item.successorTaskId];
|
|
122
|
+
if (!successor || successor.id === id || successor.predecessorTaskId !== id)
|
|
123
|
+
throw new Error(`Invalid successor link: ${id}`);
|
|
124
|
+
}
|
|
125
|
+
if (item.predecessorTaskId) {
|
|
126
|
+
const predecessor = references[item.predecessorTaskId];
|
|
127
|
+
if (!predecessor || predecessor.id === id || predecessor.status !== "superseded" || predecessor.successorTaskId !== id)
|
|
128
|
+
throw new Error(`Invalid predecessor link: ${id}`);
|
|
129
|
+
}
|
|
130
|
+
const seen = new Set([id]);
|
|
131
|
+
let cursor = item;
|
|
132
|
+
while (cursor.successorTaskId) {
|
|
133
|
+
if (seen.has(cursor.successorTaskId))
|
|
134
|
+
throw new Error(`Cyclic successor link: ${id}`);
|
|
135
|
+
seen.add(cursor.successorTaskId);
|
|
136
|
+
const next = references[cursor.successorTaskId];
|
|
137
|
+
if (!next)
|
|
138
|
+
throw new Error(`Invalid successor link: ${id}`);
|
|
139
|
+
cursor = next;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return state;
|
|
143
|
+
};
|