@felixdotgo/aidlc-kit 0.0.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.
Files changed (93) hide show
  1. package/DEVELOPMENT.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +59 -0
  4. package/dist/assets/.agents/aidlc/conventions.md +68 -0
  5. package/dist/assets/.agents/aidlc/orchestrator.md +73 -0
  6. package/dist/assets/.agents/aidlc/phase-build.md +23 -0
  7. package/dist/assets/.agents/aidlc/phase-clarify.md +22 -0
  8. package/dist/assets/.agents/aidlc/phase-index.md +14 -0
  9. package/dist/assets/.agents/aidlc/phase-plan.md +21 -0
  10. package/dist/assets/.agents/aidlc/phase-wrap.md +16 -0
  11. package/dist/assets/.agents/aidlc/profiles/generic/profile.json +9 -0
  12. package/dist/assets/.agents/aidlc/profiles/git-submodules/profile.json +9 -0
  13. package/dist/assets/.agents/aidlc/profiles/single/profile.json +9 -0
  14. package/dist/assets/.agents/aidlc/profiles/workspace/profile.json +12 -0
  15. package/dist/assets/.agents/aidlc/rules/gate-check.md +16 -0
  16. package/dist/assets/.agents/aidlc/rules/stack-runtime.md +3 -0
  17. package/dist/assets/.agents/aidlc/schemas/adapter.schema.json +13 -0
  18. package/dist/assets/.agents/aidlc/schemas/config.schema.json +26 -0
  19. package/dist/assets/.agents/aidlc/schemas/manifest.schema.json +12 -0
  20. package/dist/assets/.agents/aidlc/schemas/profile.schema.json +12 -0
  21. package/dist/assets/.agents/aidlc/schemas/state.schema.json +108 -0
  22. package/dist/assets/.agents/aidlc/scripts/context.mjs +15 -0
  23. package/dist/assets/.agents/aidlc/scripts/gate-check.mjs +16 -0
  24. package/dist/assets/.agents/aidlc/scripts/gate-view.mjs +16 -0
  25. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +238 -0
  26. package/dist/assets/.agents/aidlc/scripts/lib/runtime.mjs +2 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +341 -0
  28. package/dist/assets/.agents/aidlc/scripts/render.mjs +24 -0
  29. package/dist/assets/.agents/aidlc/scripts/state.mjs +222 -0
  30. package/dist/assets/.agents/aidlc/scripts/task-next.mjs +27 -0
  31. package/dist/assets/.agents/aidlc/templates/design.md +15 -0
  32. package/dist/assets/.agents/aidlc/templates/intent.md +17 -0
  33. package/dist/assets/.agents/aidlc/templates/lesson.md +28 -0
  34. package/dist/assets/.agents/aidlc/templates/model-contract.md +41 -0
  35. package/dist/assets/.agents/aidlc/templates/workplan.md +9 -0
  36. package/dist/assets/.agents/skills/aidlc-build/SKILL.md +19 -0
  37. package/dist/assets/.agents/skills/aidlc-clarify/SKILL.md +17 -0
  38. package/dist/assets/.agents/skills/aidlc-index/SKILL.md +17 -0
  39. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +17 -0
  40. package/dist/assets/.agents/skills/aidlc-wrap/SKILL.md +17 -0
  41. package/dist/services/aidlc-coordination/Dockerfile +10 -0
  42. package/dist/services/aidlc-coordination/compose.yaml +25 -0
  43. package/dist/services/aidlc-coordination/package-lock.json +1154 -0
  44. package/dist/services/aidlc-coordination/package.json +20 -0
  45. package/dist/services/aidlc-coordination/src/coordination.mjs +68 -0
  46. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +341 -0
  47. package/dist/services/aidlc-coordination/src/providers.mjs +23 -0
  48. package/dist/services/aidlc-coordination/src/repository.mjs +203 -0
  49. package/dist/services/aidlc-coordination/src/server.mjs +49 -0
  50. package/dist/services/aidlc-coordination/test/repository.test.mjs +211 -0
  51. package/dist/src/adapters.d.ts +7 -0
  52. package/dist/src/adapters.js +86 -0
  53. package/dist/src/cli.d.ts +2 -0
  54. package/dist/src/cli.js +192 -0
  55. package/dist/src/context.d.ts +14 -0
  56. package/dist/src/context.js +90 -0
  57. package/dist/src/gate.d.ts +40 -0
  58. package/dist/src/gate.js +176 -0
  59. package/dist/src/installer.d.ts +11 -0
  60. package/dist/src/installer.js +173 -0
  61. package/dist/src/ledger.d.ts +22 -0
  62. package/dist/src/ledger.js +119 -0
  63. package/dist/src/legacy.d.ts +2 -0
  64. package/dist/src/legacy.js +37 -0
  65. package/dist/src/managed-content.d.ts +3 -0
  66. package/dist/src/managed-content.js +16 -0
  67. package/dist/src/mcp-setup.d.ts +20 -0
  68. package/dist/src/mcp-setup.js +31 -0
  69. package/dist/src/model.d.ts +249 -0
  70. package/dist/src/model.js +1 -0
  71. package/dist/src/profiles.d.ts +28 -0
  72. package/dist/src/profiles.js +216 -0
  73. package/dist/src/project-migration.d.ts +2 -0
  74. package/dist/src/project-migration.js +144 -0
  75. package/dist/src/project-path.d.ts +2 -0
  76. package/dist/src/project-path.js +20 -0
  77. package/dist/src/state.d.ts +110 -0
  78. package/dist/src/state.js +764 -0
  79. package/dist/src/tracker.d.ts +29 -0
  80. package/dist/src/tracker.js +41 -0
  81. package/dist/src/upgrade.d.ts +11 -0
  82. package/dist/src/upgrade.js +270 -0
  83. package/dist/src/workflow.d.ts +7 -0
  84. package/dist/src/workflow.js +46 -0
  85. package/docs/README.md +31 -0
  86. package/docs/architecture.md +72 -0
  87. package/docs/command-reference.md +110 -0
  88. package/docs/configuration.md +72 -0
  89. package/docs/coordination-service.md +42 -0
  90. package/docs/development.md +106 -0
  91. package/docs/operating-workflow.md +87 -0
  92. package/docs/testing-and-release.md +113 -0
  93. package/package.json +53 -0
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@felixdotgo/aidlc-coordination",
3
+ "private": true,
4
+ "type": "module",
5
+ "engines": { "node": ">=20" },
6
+ "scripts": {
7
+ "start": "node src/server.mjs",
8
+ "test": "node --test test/*.test.mjs",
9
+ "lint": "node --check src/repository.mjs && node --check src/coordination.mjs && node --check src/server.mjs"
10
+ },
11
+ "dependencies": {
12
+ "@modelcontextprotocol/express": "^2.0.0",
13
+ "@modelcontextprotocol/node": "^2.0.0",
14
+ "@modelcontextprotocol/server": "^2.0.0",
15
+ "better-sqlite3": "^13.0.3",
16
+ "express": "^5.2.1",
17
+ "pg": "^8.23.0",
18
+ "zod": "^4.4.3"
19
+ }
20
+ }
@@ -0,0 +1,68 @@
1
+ import Database from "better-sqlite3";
2
+ import pg from "pg";
3
+
4
+ const now = () => new Date().toISOString();
5
+ const clone = (value) => structuredClone(value);
6
+ const encode = (value) => JSON.stringify(value);
7
+ const parse = (value) => JSON.parse(value);
8
+ const statuses = new Set(["backlog", "ready", "in_progress", "blocked", "done", "cancelled"]);
9
+ const intents = new Set(["question", "status", "handoff", "decision"]);
10
+ const roles = new Set(["reader", "commenter", "writer", "admin"]);
11
+ const text = (value, limit = 4096) => typeof value === "string" && Boolean(value.trim()) && value.length <= limit;
12
+ const forbidden = /(?:api[_-]?key|authorization|bearer\s+|password|secret)/i;
13
+ const denySensitive = (value, label) => { if (!text(value) || forbidden.test(value)) throw new Error(`${label} is required, bounded, and must not contain credentials`); };
14
+
15
+ export class CoordinationConflict extends Error {
16
+ constructor(current) { super(`expected a newer coordination revision than ${current.revision}`); this.current = current; }
17
+ }
18
+
19
+ export class SqliteCoordinationRepository {
20
+ constructor(filename = process.env.MCP_STATE_SQLITE_PATH ?? "./data/mcp-state.db") {
21
+ this.db = new Database(filename); this.db.pragma("journal_mode = WAL"); this.db.pragma("busy_timeout = 5000");
22
+ this.db.exec(`CREATE TABLE IF NOT EXISTS coordination_workspaces (workspace TEXT PRIMARY KEY, revision INTEGER NOT NULL, board TEXT NOT NULL, updated_at TEXT NOT NULL);
23
+ CREATE TABLE IF NOT EXISTS coordination_events (id INTEGER PRIMARY KEY AUTOINCREMENT, workspace TEXT NOT NULL, task_id TEXT, type TEXT NOT NULL, revision INTEGER NOT NULL, actor TEXT NOT NULL, digest TEXT NOT NULL, recorded_at TEXT NOT NULL);
24
+ CREATE TABLE IF NOT EXISTS coordination_idempotency (workspace TEXT NOT NULL, idempotency_key TEXT NOT NULL, response TEXT NOT NULL, PRIMARY KEY(workspace, idempotency_key));`);
25
+ }
26
+ #ensure(workspace) { this.db.prepare("INSERT OR IGNORE INTO coordination_workspaces VALUES (?, 0, ?, ?)").run(workspace, encode({ tasks: {}, comments: {} }), now()); }
27
+ #current(workspace) { this.#ensure(workspace); const row = this.db.prepare("SELECT * FROM coordination_workspaces WHERE workspace = ?").get(workspace); return { workspace, revision: Number(row.revision), board: parse(row.board), updatedAt: row.updated_at }; }
28
+ async get(workspace, filters = {}) { const current = this.#current(workspace); const tasks = Object.values(current.board.tasks).filter((task) => (!filters.assignee || task.assignees.includes(filters.assignee)) && (!filters.watch || filters.watch.includes(task.id))).slice(0, Math.min(filters.limit ?? 50, 100)); return { ...current, board: { tasks }, cursor: Number(this.db.prepare("SELECT COALESCE(MAX(id), 0) AS id FROM coordination_events WHERE workspace = ?").get(workspace).id) }; }
29
+ async thread(workspace, taskId, cursor = 0, limit = 50) { const current = this.#current(workspace); if (!current.board.tasks[taskId]) throw new Error("task not found"); return { revision: current.revision, task: current.board.tasks[taskId], comments: (current.board.comments[taskId] ?? []).filter((item) => item.id > cursor).slice(0, Math.min(limit, 100)) }; }
30
+ async eventsSince(workspace, cursor = 0, filters = {}) { this.#ensure(workspace); return this.db.prepare("SELECT * FROM coordination_events WHERE workspace = ? AND id > ? ORDER BY id ASC LIMIT 100").all(workspace, cursor).filter((event) => !filters.actor || event.actor === filters.actor || event.type === "mention" || event.type === "claim_expired").map((event) => ({ cursor: event.id, taskId: event.task_id, type: event.type, revision: event.revision, actor: event.actor, timestamp: event.recorded_at, digest: event.digest })); }
31
+ async apply(workspace, expectedRevision, idempotencyKey, actor, role, command) {
32
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 0 || !text(idempotencyKey, 256) || !text(actor, 128) || !roles.has(role)) throw new Error("invalid coordination mutation precondition");
33
+ this.db.exec("BEGIN IMMEDIATE");
34
+ try {
35
+ const cached = this.db.prepare("SELECT response FROM coordination_idempotency WHERE workspace = ? AND idempotency_key = ?").get(workspace, idempotencyKey); if (cached) { this.db.exec("COMMIT"); return parse(cached.response); }
36
+ const current = this.#current(workspace); if (current.revision !== expectedRevision) throw new CoordinationConflict(current);
37
+ const board = clone(current.board); const at = now(); let taskId; let type;
38
+ if (command.type === "upsert_task") {
39
+ if (!roles.has(role) || !["writer", "admin"].includes(role) || !text(command.task?.id, 128) || !text(command.task?.title, 256) || !statuses.has(command.task?.status)) throw new Error("board write is not permitted or invalid");
40
+ taskId = command.task.id; const previous = board.tasks[taskId]; if (previous?.revision !== undefined && command.task.revision !== previous.revision) throw new CoordinationConflict(current);
41
+ board.tasks[taskId] = { id: taskId, title: command.task.title, description: command.task.description ?? "", status: command.task.status, priority: command.task.priority ?? "normal", labels: Array.isArray(command.task.labels) ? command.task.labels.slice(0, 20) : [], assignees: Array.isArray(command.task.assignees) ? command.task.assignees.slice(0, 10) : [], lifecycleTaskId: command.task.lifecycleTaskId, artifactLinks: Array.isArray(command.task.artifactLinks) ? command.task.artifactLinks.slice(0, 10) : [], claim: previous?.claim, revision: (previous?.revision ?? 0) + 1, createdAt: previous?.createdAt ?? at, updatedAt: at }; type = "task";
42
+ } else if (command.type === "add_comment") {
43
+ if (!["commenter", "writer", "admin"].includes(role) || !board.tasks[command.taskId] || !intents.has(command.intent) || !text(command.body, 2000)) throw new Error("comment is not permitted or invalid"); denySensitive(command.body, "comment body"); taskId = command.taskId; const comments = board.comments[taskId] ?? []; const comment = { id: comments.length ? comments.at(-1).id + 1 : 1, author: actor, authorType: command.authorType === "human" ? "human" : "agent", intent: command.intent, mentions: Array.isArray(command.mentions) ? command.mentions.slice(0, 20) : [], body: command.body, createdAt: at }; comments.push(comment); board.comments[taskId] = comments; type = comment.mentions.length ? "mention" : "comment";
44
+ } else if (command.type === "claim") {
45
+ if (!["writer", "admin"].includes(role) || !board.tasks[command.taskId] || !Number.isInteger(command.leaseMs) || command.leaseMs < 60000 || command.leaseMs > 3600000) throw new Error("claim is not permitted or invalid"); taskId = command.taskId; const task = board.tasks[taskId]; if (task.claim && Date.parse(task.claim.expiresAt) > Date.now() && task.claim.actor !== actor) throw new Error("claim is held by another actor"); task.claim = { actor, expiresAt: new Date(Date.now() + command.leaseMs).toISOString() }; task.revision += 1; task.updatedAt = at; type = "claim";
46
+ } else throw new Error("unsupported coordination command");
47
+ const revision = current.revision + 1; const response = { workspace, revision, board: { tasks: Object.values(board.tasks) }, updatedAt: at };
48
+ this.db.prepare("UPDATE coordination_workspaces SET revision = ?, board = ?, updated_at = ? WHERE workspace = ?").run(revision, encode(board), at, workspace);
49
+ this.db.prepare("INSERT INTO coordination_events (workspace, task_id, type, revision, actor, digest, recorded_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(workspace, taskId, type, revision, actor, `${type}:${taskId}:${revision}`, at);
50
+ this.db.prepare("INSERT INTO coordination_idempotency VALUES (?, ?, ?)").run(workspace, idempotencyKey, encode(response)); this.db.exec("COMMIT"); return response;
51
+ } catch (error) { try { this.db.exec("ROLLBACK"); } catch {} throw error; }
52
+ }
53
+ close() { this.db.close(); }
54
+ }
55
+
56
+ // PostgreSQL keeps the same projection contract. Coordination writes lock one
57
+ // workspace row, matching SQLite's BEGIN IMMEDIATE semantics.
58
+ export class PostgresCoordinationRepository {
59
+ constructor(connectionString = process.env.MCP_STATE_POSTGRES_URL) { if (!connectionString) throw new Error("MCP_STATE_POSTGRES_URL is required for PostgreSQL"); this.pool = new pg.Pool({ connectionString }); }
60
+ async #init(client) { await client.query("CREATE TABLE IF NOT EXISTS coordination_workspaces (workspace TEXT PRIMARY KEY, revision BIGINT NOT NULL, board JSONB NOT NULL, updated_at TEXT NOT NULL)"); await client.query("CREATE TABLE IF NOT EXISTS coordination_events (id BIGSERIAL PRIMARY KEY, workspace TEXT NOT NULL, task_id TEXT, type TEXT NOT NULL, revision BIGINT NOT NULL, actor TEXT NOT NULL, digest TEXT NOT NULL, recorded_at TEXT NOT NULL)"); await client.query("CREATE TABLE IF NOT EXISTS coordination_idempotency (workspace TEXT NOT NULL, idempotency_key TEXT NOT NULL, response JSONB NOT NULL, PRIMARY KEY(workspace,idempotency_key))"); }
61
+ async get(workspace, filters = {}) { const c = await this.pool.connect(); try { await this.#init(c); await c.query("INSERT INTO coordination_workspaces VALUES ($1,0,$2,$3) ON CONFLICT DO NOTHING", [workspace,{tasks:{},comments:{}},now()]); const row=(await c.query("SELECT * FROM coordination_workspaces WHERE workspace=$1",[workspace])).rows[0]; return { workspace, revision:Number(row.revision), board:{tasks:Object.values(row.board.tasks).filter((t)=>!filters.assignee||t.assignees.includes(filters.assignee)).slice(0,Math.min(filters.limit??50,100))}, updatedAt:row.updated_at }; } finally { c.release(); } }
62
+ async eventsSince(workspace,cursor=0,filters={}) { const c=await this.pool.connect(); try { await this.#init(c); return (await c.query("SELECT * FROM coordination_events WHERE workspace=$1 AND id>$2 ORDER BY id LIMIT 100",[workspace,cursor])).rows.filter((e)=>!filters.actor||e.actor===filters.actor||e.type==="mention").map((e)=>({cursor:Number(e.id),taskId:e.task_id,type:e.type,revision:Number(e.revision),actor:e.actor,timestamp:e.recorded_at,digest:e.digest})); } finally { c.release(); } }
63
+ async thread(workspace,taskId,cursor=0,limit=50) { const state=await this.get(workspace); const c=await this.pool.connect(); try { const row=(await c.query("SELECT board FROM coordination_workspaces WHERE workspace=$1",[workspace])).rows[0]; if(!row.board.tasks[taskId]) throw new Error("task not found"); return {revision:state.revision,task:row.board.tasks[taskId],comments:(row.board.comments[taskId]??[]).filter((x)=>x.id>cursor).slice(0,Math.min(limit,100))}; } finally { c.release(); } }
64
+ async apply(workspace,expectedRevision,idempotencyKey,actor,role,command) { if(!Number.isInteger(expectedRevision)||!text(idempotencyKey,256)||!text(actor,128)||!roles.has(role)) throw new Error("invalid coordination mutation precondition"); const c=await this.pool.connect(); try { await this.#init(c); await c.query("BEGIN"); await c.query("INSERT INTO coordination_workspaces VALUES ($1,0,$2,$3) ON CONFLICT DO NOTHING",[workspace,{tasks:{},comments:{}},now()]); const cached=(await c.query("SELECT response FROM coordination_idempotency WHERE workspace=$1 AND idempotency_key=$2",[workspace,idempotencyKey])).rows[0]; if(cached){await c.query("COMMIT");return cached.response;} const row=(await c.query("SELECT * FROM coordination_workspaces WHERE workspace=$1 FOR UPDATE",[workspace])).rows[0]; const current={workspace,revision:Number(row.revision),board:row.board}; if(current.revision!==expectedRevision) throw new CoordinationConflict(current); const bridge=new SqliteCoordinationRepository(":memory:"); try { await bridge.apply(workspace,0,"seed","system","admin",{type:"upsert_task",task:{id:"seed",title:"seed",status:"backlog"}}); const seed=await bridge.get(workspace); bridge.db.prepare("UPDATE coordination_workspaces SET revision=?,board=? WHERE workspace=?").run(current.revision,encode(current.board),workspace); const response=await bridge.apply(workspace,expectedRevision,idempotencyKey,actor,role,command); const next=(await bridge.get(workspace)).board; const event=(await bridge.eventsSince(workspace,0)).at(-1); await c.query("UPDATE coordination_workspaces SET revision=$1,board=$2,updated_at=$3 WHERE workspace=$4",[response.revision,next,response.updatedAt,workspace]); await c.query("INSERT INTO coordination_events (workspace,task_id,type,revision,actor,digest,recorded_at) VALUES ($1,$2,$3,$4,$5,$6,$7)",[workspace,event.taskId,event.type,response.revision,actor,event.digest,response.updatedAt]); await c.query("INSERT INTO coordination_idempotency VALUES ($1,$2,$3)",[workspace,idempotencyKey,response]); await c.query("COMMIT"); return response; } finally { bridge.close(); } } catch(error){try{await c.query("ROLLBACK");}catch{} throw error;} finally{c.release();} }
65
+ async close(){await this.pool.end();}
66
+ }
67
+
68
+ export const createCoordinationRepository = () => process.env.MCP_STATE_DATABASE === "postgres" ? new PostgresCoordinationRepository() : new SqliteCoordinationRepository();
@@ -0,0 +1,341 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
4
+
5
+ const phases = ["clarify", "plan", "build", "wrap", "done"];
6
+ const gates = ["none", "G0_confirm", "G1_review", "G2_codereview"];
7
+ const statuses = ["active", "blocked_on_user", "paused", "done", "closed", "superseded"];
8
+ const handoffKinds = ["repair_exhausted", "review_exhausted", "g2_failed", "release_failed", "structural_change", "other"];
9
+ const terminal = (task) => ["done", "closed", "superseded"].includes(task.status);
10
+ const separator = process.platform === "win32" ? "\\" : "/";
11
+ const phaseGate = (phase) => phase === "clarify" ? "G0_confirm" : phase === "plan" ? "G1_review" : phase === "build" ? "G2_codereview" : "none";
12
+ const iso = (value) => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(Date.parse(value)).toISOString() === value;
13
+ const text = (value) => typeof value === "string" && Boolean(value.trim());
14
+ const idPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
15
+ const digest = (content) => createHash("sha256").update(content).digest("hex");
16
+ const archiveRecordPath = (id) => `.agents/data/state/archive/${id}.json`;
17
+ const taskRecordContent = (task) => `${JSON.stringify(task, null, 2)}\n`;
18
+ const assertNoSymlinkPath = (root, relative) => { let cursor = resolve(root); for (const part of normalize(relative).split(/[\\/]/).filter(Boolean)) { cursor = join(cursor, part); if (existsSync(cursor) && lstatSync(cursor).isSymbolicLink()) throw new Error(`State path crosses a symlink: ${relative}`); } };
19
+
20
+ const writeAtomic = (path, content) => {
21
+ mkdirSync(dirname(path), { recursive: true });
22
+ const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
23
+ writeFileSync(temporary, content, "utf8");
24
+ renameSync(temporary, path);
25
+ };
26
+
27
+ const sleep = (milliseconds) => { if (milliseconds > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); };
28
+ const lockRecord = (path) => {
29
+ try {
30
+ const value = JSON.parse(readFileSync(path, "utf8"));
31
+ return typeof value.token === "string" && Number.isInteger(value.pid) && value.pid > 0 && Number.isFinite(value.createdAt) ? value : undefined;
32
+ } catch { return undefined; }
33
+ };
34
+ const processAlive = (pid) => {
35
+ try { process.kill(pid, 0); return true; }
36
+ catch (error) { return error?.code === "EPERM"; }
37
+ };
38
+ const tryCreateLock = (path, record) => {
39
+ try {
40
+ mkdirSync(dirname(path), { recursive: true });
41
+ const descriptor = openSync(path, "wx", 0o600);
42
+ try { writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8"); }
43
+ finally { closeSync(descriptor); }
44
+ return true;
45
+ } catch (error) {
46
+ if (error?.code === "EEXIST") return false;
47
+ throw error;
48
+ }
49
+ };
50
+ const removeOwnedLock = (path, token) => { if (lockRecord(path)?.token === token) unlinkSync(path); };
51
+ const lockExpired = (record, now, staleMs) => Boolean(record && now - record.createdAt >= staleMs && !processAlive(record.pid));
52
+
53
+ const validateLesson = (taskId, lesson) => {
54
+ 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)) throw new Error(`Invalid lesson: ${taskId}`);
55
+ };
56
+
57
+ const validateTask = (id, task) => {
58
+ if (!idPattern.test(id)) throw new Error(`Invalid task id: ${id}`);
59
+ if (!task || task.id !== id || !phases.includes(task.phase) || !gates.includes(task.gate) || task.gate !== phaseGate(task.phase)) throw new Error(`Invalid task state: ${id}`);
60
+ if (!Array.isArray(task.decisions) || !Array.isArray(task.tasks) || !Array.isArray(task.evidence) || (task.lessons !== undefined && !Array.isArray(task.lessons))) throw new Error(`Invalid task collections: ${id}`);
61
+ if (!task.title || !["feature", "bug", "refactor", "infra"].includes(task.type) || !statuses.includes(task.status) || (task.phase === "done") !== (task.status === "done")) throw new Error(`Invalid task fields: ${id}`);
62
+ 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))) throw new Error(`Invalid task language, risk, or areas: ${id}`);
63
+ if (task.decisions.some((item) => !item.id || !item.label || !["unresolved", "approved", "changed", "dropped"].includes(item.status) || (item.status === "changed" && !item.resolution))) throw new Error(`Invalid decisions: ${id}`);
64
+ if (task.tasks.some((item) => !item.id || !item.label || !["todo", "in_progress", "done", "deferred"].includes(item.status))) throw new Error(`Invalid execution tasks: ${id}`);
65
+ 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")))) throw new Error(`Invalid evidence: ${id}`);
66
+ const lessonIds = new Set();
67
+ for (const lesson of task.lessons ?? []) { validateLesson(id, lesson); if (lessonIds.has(lesson.id)) throw new Error(`Duplicate lesson id: ${id}/${lesson.id}`); lessonIds.add(lesson.id); }
68
+ 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))))) throw new Error(`Invalid lesson disposition: ${id}`);
69
+ if (task.handoff && (!handoffKinds.includes(task.handoff.kind) || !text(task.handoff.reason) || !text(task.handoff.source) || !iso(task.handoff.recordedAt) || task.status !== "paused")) throw new Error(`Invalid task handoff: ${id}`);
70
+ 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")) throw new Error(`Invalid legacy G2 wait marker: ${id}`);
71
+ if ((["closed", "superseded"].includes(task.status)) !== Boolean(task.closure)) throw new Error(`Invalid task closure: ${id}`);
72
+ if (task.closure && (!text(task.closure.reason) || !text(task.closure.source) || !iso(task.closure.recordedAt))) throw new Error(`Invalid task closure: ${id}`);
73
+ if ((task.status === "superseded") !== Boolean(task.successorTaskId) || (task.status === "closed" && task.successorTaskId)) throw new Error(`Invalid task successor: ${id}`);
74
+ if (task.predecessorTaskId !== undefined && !text(task.predecessorTaskId)) throw new Error(`Invalid task predecessor: ${id}`);
75
+ for (const path of Object.values(task.artifacts ?? {})) if (path) {
76
+ const cleaned = normalize(path);
77
+ if (isAbsolute(cleaned) || cleaned === ".." || cleaned.startsWith(`..${separator}`) || !cleaned.startsWith(`.agents${separator}data${separator}tasks${separator}`)) throw new Error(`Invalid artifact path for ${id}: ${path}`);
78
+ }
79
+ };
80
+
81
+ const validateArchiveSummary = (id, item) => {
82
+ 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)) throw new Error(`Invalid archived task summary: ${id}`);
83
+ if ((item.status === "superseded") !== Boolean(item.successorTaskId) || (item.status === "closed" && item.successorTaskId) || (item.predecessorTaskId !== undefined && !text(item.predecessorTaskId))) throw new Error(`Invalid archived task links: ${id}`);
84
+ };
85
+
86
+ export const validateState = (value) => {
87
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Workflow state must be an object");
88
+ if (![1, 2, 3, 4].includes(Number(value.schemaVersion)) || !value.tasks || typeof value.tasks !== "object" || Array.isArray(value.tasks)) throw new Error("Unsupported workflow state schema");
89
+ if (Number(value.schemaVersion) >= 3 && (!value.archive || typeof value.archive !== "object" || Array.isArray(value.archive))) throw new Error("Schema v3+ requires an archive catalog");
90
+ if (Number(value.schemaVersion) < 3 && value.archive !== undefined) throw new Error("Legacy workflow state cannot contain an archive catalog");
91
+ if (Number(value.schemaVersion) < 4 && Object.values(value.tasks).some((task) => task?.legacyG2Wait !== undefined)) throw new Error("Legacy G2 wait markers require schema v4");
92
+ for (const [id, task] of Object.entries(value.tasks)) validateTask(id, task);
93
+ for (const [id, item] of Object.entries(value.archive ?? {})) { if (value.tasks[id]) throw new Error(`Task exists in active and archive state: ${id}`); validateArchiveSummary(id, item); }
94
+ const references = { ...(value.archive ?? {}), ...value.tasks };
95
+ for (const [id, item] of Object.entries(references)) {
96
+ if (item.successorTaskId) { const successor = references[item.successorTaskId]; if (!successor || successor.id === id || successor.predecessorTaskId !== id) throw new Error(`Invalid successor link: ${id}`); }
97
+ if (item.predecessorTaskId) { const predecessor = references[item.predecessorTaskId]; if (!predecessor || predecessor.id === id || predecessor.status !== "superseded" || predecessor.successorTaskId !== id) throw new Error(`Invalid predecessor link: ${id}`); }
98
+ const seen = new Set([id]); let cursor = item;
99
+ while (cursor.successorTaskId) { if (seen.has(cursor.successorTaskId)) throw new Error(`Cyclic successor link: ${id}`); seen.add(cursor.successorTaskId); cursor = references[cursor.successorTaskId]; }
100
+ }
101
+ return value;
102
+ };
103
+
104
+ const archivedSummary = (task, content = taskRecordContent(task)) => ({ id: task.id, title: task.title, type: task.type, phase: task.phase, gate: task.gate, status: task.status, risk: task.risk, areas: task.areas, record: archiveRecordPath(task.id), digest: digest(content), lessonCount: task.lessons?.length ?? 0, predecessorTaskId: task.predecessorTaskId, successorTaskId: task.successorTaskId, createdAt: task.createdAt, updatedAt: task.updatedAt });
105
+
106
+ export const statePath = (root) => join(resolve(root), ".agents/data/state/aidlc-state.json");
107
+ export const lessonIndexPath = (root) => join(resolve(root), ".agents/data/lessons/index.json");
108
+ export const memoryRegistryPath = (root) => join(resolve(root), ".agents/data/memory/agentic-memory.json");
109
+ export const stateLockPath = (root) => join(resolve(root), ".agents/data/state/.aidlc-state.lock");
110
+ const stateLockGuardPath = (root) => join(resolve(root), ".agents/data/state/.aidlc-state.lock.guard");
111
+ export const emptyState = () => ({ schemaVersion: 4, tasks: {}, archive: {} });
112
+ export const legacyG2WaitSource = "schema-v4-migration";
113
+ export const emptyMemoryRegistry = () => ({ schemaVersion: 1, entries: [], retired: [] });
114
+
115
+ export const acquireStateLock = (root, options = {}) => {
116
+ const timeoutMs = options.timeoutMs ?? 5_000;
117
+ const retryMs = options.retryMs ?? 25;
118
+ const staleMs = options.staleMs ?? 30_000;
119
+ const lockPath = stateLockPath(root); const guardPath = stateLockGuardPath(root); const token = randomUUID(); const deadline = Date.now() + timeoutMs;
120
+ assertNoSymlinkPath(root, ".agents/data/state/.aidlc-state.lock"); assertNoSymlinkPath(root, ".agents/data/state/.aidlc-state.lock.guard");
121
+ const acquireGuard = () => {
122
+ const guardToken = randomUUID(); const now = Date.now(); const existing = lockRecord(guardPath);
123
+ if (lockExpired(existing, now, staleMs)) removeOwnedLock(guardPath, existing.token);
124
+ return tryCreateLock(guardPath, { token: guardToken, pid: process.pid, createdAt: now }) ? guardToken : undefined;
125
+ };
126
+ while (Date.now() <= deadline) {
127
+ const guardToken = acquireGuard();
128
+ if (!guardToken) { sleep(retryMs); continue; }
129
+ try {
130
+ const now = Date.now(); const existing = lockRecord(lockPath);
131
+ if (lockExpired(existing, now, staleMs)) removeOwnedLock(lockPath, existing.token);
132
+ if (tryCreateLock(lockPath, { token, pid: process.pid, createdAt: now })) return () => {
133
+ const existing = lockRecord(lockPath);
134
+ if (!existing) {
135
+ if (existsSync(lockPath)) throw new Error("Cannot release AI-DLC state lock because its owner record is unreadable");
136
+ return;
137
+ }
138
+ if (existing.token !== token) return;
139
+ try { unlinkSync(lockPath); }
140
+ catch (error) { throw new Error(`Failed to release owned AI-DLC state lock: ${error instanceof Error ? error.message : String(error)}`); }
141
+ };
142
+ } finally { removeOwnedLock(guardPath, guardToken); }
143
+ sleep(retryMs);
144
+ }
145
+ throw new Error(`Timed out waiting for AI-DLC state lock after ${timeoutMs}ms`);
146
+ };
147
+ const memoryPhases = ["clarify", "plan", "build", "wrap", "*"];
148
+ const validateMemoryEntry = (entry) => {
149
+ 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)) throw new Error(`Invalid agentic memory entry: ${entry?.id ?? "unknown"}`);
150
+ };
151
+ export const validateMemoryRegistry = (value) => {
152
+ if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1 || !Array.isArray(value.entries) || !Array.isArray(value.retired)) throw new Error("Unsupported agentic memory registry schema");
153
+ const ids = new Set();
154
+ for (const entry of value.entries) { validateMemoryEntry(entry); if (ids.has(entry.id)) throw new Error(`Duplicate agentic memory entry: ${entry.id}`); ids.add(entry.id); }
155
+ for (const entry of value.retired) { validateMemoryEntry(entry); if (!text(entry.retiredBy) || !text(entry.reason) || !iso(entry.retiredAt) || ids.has(entry.id)) throw new Error(`Invalid retired agentic memory entry: ${entry.id}`); ids.add(entry.id); }
156
+ return value;
157
+ };
158
+ export const loadMemoryRegistry = (root) => { assertNoSymlinkPath(root, ".agents/data/memory/agentic-memory.json"); const path = memoryRegistryPath(root); return existsSync(path) ? validateMemoryRegistry(parseJsonFile(path, "Agentic memory registry")) : emptyMemoryRegistry(); };
159
+ export const saveMemoryRegistry = (root, registry) => { const normalized = validateMemoryRegistry(registry); assertNoSymlinkPath(root, ".agents/data/memory/agentic-memory.json"); writeAtomic(memoryRegistryPath(root), `${JSON.stringify(normalized, null, 2)}\n`); };
160
+ export const selectAgenticMemory = (registry, areas, phase, maxChars) => {
161
+ const scoped = new Set(areas.map((area) => area.toLocaleLowerCase())); let used = 0;
162
+ return registry.entries.filter((entry) => entry.phases.includes("*") || entry.phases.includes(phase)).filter((entry) => entry.areas.includes("*") || entry.areas.some((area) => scoped.has(area.toLocaleLowerCase()))).sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id)).filter((entry) => { const size = `- ${entry.id} — ${entry.summary} — guidance: ${entry.guidance} — source: ${entry.sourceTaskId}/${entry.sourceLessonId}\n`.length; if (used + size > maxChars) return false; used += size; return true; });
163
+ };
164
+ export const promoteAgenticMemory = (root, entry) => {
165
+ validateMemoryEntry(entry); const source = loadTask(root, entry.sourceTaskId, loadState(root));
166
+ if (!source?.lessons?.some((lesson) => lesson.id === entry.sourceLessonId)) throw new Error(`Agentic memory source lesson is missing: ${entry.sourceTaskId}/${entry.sourceLessonId}`);
167
+ const registry = loadMemoryRegistry(root); const current = registry.entries.find((item) => item.id === entry.id);
168
+ if (current) { if (JSON.stringify(current) === JSON.stringify(entry)) return current; throw new Error(`Agentic memory entry already exists with different content: ${entry.id}`); }
169
+ if (registry.retired.some((item) => item.id === entry.id)) throw new Error(`Retired agentic memory id cannot be reused: ${entry.id}`);
170
+ registry.entries.push(entry); registry.entries.sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id)); saveMemoryRegistry(root, registry); return entry;
171
+ };
172
+ export const retireAgenticMemory = (root, id, reason, retiredBy, retiredAt = new Date().toISOString()) => {
173
+ if (!text(reason) || !text(retiredBy) || !iso(retiredAt)) throw new Error("Retiring agentic memory requires a reason, approver, and valid timestamp");
174
+ const registry = loadMemoryRegistry(root); const index = registry.entries.findIndex((entry) => entry.id === id); if (index < 0) throw new Error(`Unknown active agentic memory entry: ${id}`);
175
+ const [entry] = registry.entries.splice(index, 1); registry.retired.push({ ...entry, retiredBy, retiredAt, reason }); registry.retired.sort((left, right) => right.retiredAt.localeCompare(left.retiredAt) || left.id.localeCompare(right.id)); saveMemoryRegistry(root, registry); return entry;
176
+ };
177
+ const parseJsonText = (content, label, recovery = "") => { try { return JSON.parse(content); } catch (error) { throw new Error(`${label} is corrupted or unreadable: ${error instanceof Error ? error.message : String(error)}${recovery ? `; ${recovery}` : ""}`); } };
178
+ const parseJsonFile = (path, label, recovery = "") => parseJsonText(readFileSync(path, "utf8"), label, recovery);
179
+ export const loadState = (root) => { assertNoSymlinkPath(root, ".agents/data/state/aidlc-state.json"); return existsSync(statePath(root)) ? validateState(parseJsonFile(statePath(root), "AI-DLC state file", "restore it from git history or the archive records")) : emptyState(); };
180
+
181
+ export const loadTask = (root, id, state = loadState(root)) => {
182
+ if (state.tasks[id]) return state.tasks[id];
183
+ const summary = state.archive?.[id];
184
+ if (!summary) return undefined;
185
+ const path = join(resolve(root), summary.record);
186
+ assertNoSymlinkPath(root, summary.record);
187
+ if (!existsSync(path)) throw new Error(`Archived task record is missing: ${summary.record}`);
188
+ const content = readFileSync(path, "utf8");
189
+ if (digest(content) !== summary.digest) throw new Error(`Archived task digest mismatch: ${id}`);
190
+ const task = parseJsonText(content, `Archived task record ${id}`);
191
+ validateTask(id, task);
192
+ if (!terminal(task) || task.status !== summary.status || task.updatedAt !== summary.updatedAt || (task.lessons?.length ?? 0) !== summary.lessonCount) throw new Error(`Archived task summary mismatch: ${id}`);
193
+ return task;
194
+ };
195
+
196
+ export const saveState = (root, state) => {
197
+ const normalized = validateState(structuredClone(state));
198
+ const persisted = existsSync(statePath(root)) ? loadState(root) : undefined;
199
+ const archived = [];
200
+ if (normalized.schemaVersion >= 3) {
201
+ normalized.archive ??= {};
202
+ for (const task of Object.values(normalized.tasks)) if (terminal(task)) {
203
+ const content = taskRecordContent(task); const summary = archivedSummary(task, content); const path = join(resolve(root), summary.record);
204
+ assertNoSymlinkPath(root, summary.record);
205
+ if (existsSync(path)) {
206
+ const current = readFileSync(path, "utf8");
207
+ if (digest(current) !== summary.digest) {
208
+ let orphan;
209
+ try { orphan = JSON.parse(current); validateTask(task.id, orphan); }
210
+ catch { throw new Error(`Archived task record conflicts: ${task.id}`); }
211
+ const authority = persisted?.tasks[task.id];
212
+ if (persisted?.archive?.[task.id] || !authority || authority.createdAt !== task.createdAt || !terminal(orphan) || orphan.id !== task.id || orphan.createdAt !== task.createdAt) throw new Error(`Archived task record conflicts: ${task.id}`);
213
+ writeAtomic(path, content);
214
+ }
215
+ } else writeAtomic(path, content);
216
+ normalized.archive[task.id] = summary; delete normalized.tasks[task.id]; archived.push(task.id);
217
+ }
218
+ validateState(normalized);
219
+ }
220
+ assertNoSymlinkPath(root, ".agents/data/state/aidlc-state.json"); writeAtomic(statePath(root), `${JSON.stringify(normalized, null, 2)}\n`);
221
+ state.tasks = normalized.tasks; state.archive = normalized.archive; state.schemaVersion = normalized.schemaVersion;
222
+ return archived;
223
+ };
224
+
225
+ export const stampLegacyG2Waits = (state) => {
226
+ const stamped = [];
227
+ for (const task of Object.values(state.tasks)) {
228
+ if (task.legacyG2Wait !== undefined) continue;
229
+ if (task.phase !== "build" || task.gate !== "G2_codereview" || task.status !== "blocked_on_user") continue;
230
+ if (!task.tasks.every((item) => ["done", "deferred"].includes(item.status)) || !hasVerification(task) || !hasReview(task)) continue;
231
+ task.legacyG2Wait = { migratedAt: task.updatedAt, source: legacyG2WaitSource };
232
+ stamped.push(task.id);
233
+ }
234
+ return stamped;
235
+ };
236
+
237
+ export const migrateState = (root, state = loadState(root)) => {
238
+ if (state.schemaVersion === 4) return { migrated: false, archived: [], stamped: [] };
239
+ const next = { schemaVersion: 4, tasks: {}, archive: {} }; const records = [];
240
+ for (const task of Object.values(state.tasks)) {
241
+ if (!terminal(task)) next.tasks[task.id] = task;
242
+ else { const content = taskRecordContent(task); const summary = archivedSummary(task, content); next.archive[task.id] = summary; records.push({ path: summary.record, content }); }
243
+ }
244
+ for (const item of Object.values(state.archive ?? {})) next.archive[item.id] = item;
245
+ const stamped = stampLegacyG2Waits(next);
246
+ validateState(next);
247
+ for (const record of records) { assertNoSymlinkPath(root, record.path); const path = join(resolve(root), record.path); if (existsSync(path)) { if (readFileSync(path, "utf8") !== record.content) throw new Error(`Archived task record conflicts: ${record.path}`); } else writeAtomic(path, record.content); }
248
+ saveState(root, next); rebuildLessonIndex(root, next); return { migrated: true, archived: Object.keys(next.archive), stamped };
249
+ };
250
+
251
+ export const latestApproval = (task, gate) => { for (let index = task.evidence.length - 1; index >= 0; index -= 1) { const item = task.evidence[index]; if (item.kind === "approval" && item.gate === gate) return item; } return undefined; };
252
+ export const buildBoundary = (task) => { let boundary; for (let index = 0; index < task.evidence.length; index += 1) { const item = task.evidence[index]; if (item.kind === "approval" && item.gate === "G1_review" && item.result === "pass") boundary = index; } return boundary; };
253
+ export const hasApproval = (task, gate) => latestApproval(task, gate)?.result === "pass";
254
+ const currentBuildEvidence = (task) => { const boundary = buildBoundary(task); return boundary === undefined ? [] : task.evidence.slice(boundary + 1); };
255
+ const latestInOrder = (items, predicate) => { for (let index = items.length - 1; index >= 0; index -= 1) if (predicate(items[index])) return items[index]; return undefined; };
256
+ export const hasCurrentBuildApproval = (task, gate) => latestInOrder(currentBuildEvidence(task), (item) => item.kind === "approval" && item.gate === gate)?.result === "pass";
257
+ export const hasAreaVerification = (task, area) => { const evidence = currentBuildEvidence(task).filter((item) => ["test", "lint"].includes(item.kind) && (item.area === area || (!item.area && task.areas.length === 1))); const kinds = [...new Set(evidence.map((item) => item.kind))]; return kinds.length > 0 && kinds.every((kind) => latestInOrder(evidence, (item) => item.kind === kind)?.result === "pass"); };
258
+ export const hasVerification = (task) => task.areas.every((area) => hasAreaVerification(task, area));
259
+ export const hasReview = (task) => latestInOrder(currentBuildEvidence(task), (item) => item.kind === "review")?.result === "pass";
260
+ export const repairBounds = (task) => { const evidence = currentBuildEvidence(task); const verifyFailures = {}; for (const item of evidence) if (["test", "lint"].includes(item.kind) && item.result === "fail") { const area = item.area ?? (task.areas.length === 1 ? task.areas[0] : "unscoped"); verifyFailures[area] = (verifyFailures[area] ?? 0) + 1; } const reviewFailures = evidence.filter((item) => item.kind === "review" && item.result === "fail").length; const exhaustedAreas = Object.keys(verifyFailures).filter((area) => verifyFailures[area] >= 3).sort(); return { verifyFailures, reviewFailures, exhaustedAreas, verifyExhausted: exhaustedAreas.length > 0, reviewExhausted: reviewFailures >= 2 }; };
261
+ export const isLegacyG2Wait = (task) => task.legacyG2Wait !== undefined && task.phase === "build" && task.gate === "G2_codereview" && task.status === "blocked_on_user" && task.tasks.every((item) => ["done", "deferred"].includes(item.status)) && hasVerification(task) && hasReview(task);
262
+
263
+ export const transitionDiagnostics = (task, target) => {
264
+ const diagnostics = []; const expected = phases.indexOf(task.phase) + 1;
265
+ if (target === task.phase || phases.indexOf(target) !== expected) diagnostics.push({ level: "ERROR", code: "STATE_TRANSITION", message: `Cannot transition ${task.phase} → ${target}` });
266
+ if (target === "plan" && !hasApproval(task, "G0_confirm")) diagnostics.push({ level: "ERROR", code: "G0_APPROVAL", message: "G0 approval evidence is required" });
267
+ if (target === "build") { if (!hasApproval(task, "G1_review")) diagnostics.push({ level: "ERROR", code: "G1_APPROVAL", message: "G1 approval evidence is required" }); for (const decision of task.decisions) if (decision.status === "unresolved") diagnostics.push({ level: "ERROR", code: "UNRESOLVED_DECISION", message: `Decision ${decision.id} is unresolved` }); }
268
+ if (target === "wrap") { if (!hasVerification(task)) diagnostics.push({ level: "ERROR", code: "VERIFY_EVIDENCE", message: "Latest current-build verification evidence must pass for every affected area" }); if (!hasReview(task)) diagnostics.push({ level: "ERROR", code: "REVIEW_EVIDENCE", message: "Latest current-build code-review evidence must pass" }); if (!hasCurrentBuildApproval(task, "G2_codereview")) diagnostics.push({ level: "ERROR", code: "G2_APPROVAL", message: "Current-build G2 approval evidence is required" }); }
269
+ if (target === "done" && !task.lessonDisposition) diagnostics.push({ level: "ERROR", code: "LESSON_DISPOSITION", message: "Wrap must record lessons or an explicit no-lesson disposition" });
270
+ return diagnostics;
271
+ };
272
+
273
+ export const transitionTask = (state, id, target) => {
274
+ const task = state.tasks[id]; if (!task) throw new Error(`Unknown active task: ${id}`); if (terminal(task)) throw new Error(`Terminal task cannot transition: ${id}`);
275
+ const errors = transitionDiagnostics(task, target).filter((item) => item.level === "ERROR"); if (errors.length) throw new Error(errors.map((item) => item.message).join("; "));
276
+ task.phase = target; task.gate = phaseGate(target); task.status = target === "done" ? "done" : "active"; delete task.legacyG2Wait; task.updatedAt = new Date().toISOString(); return task;
277
+ };
278
+
279
+ export const nextAction = (task, root) => {
280
+ const rootSuffix = root === undefined ? "" : ` --root ${JSON.stringify(resolve(root))}`;
281
+ const script = (name) => root === undefined ? `node .agents/aidlc/scripts/${name}` : `node ${JSON.stringify(join(resolve(root), ".agents/aidlc/scripts", name))}`;
282
+ const stateCommand = script("state.mjs"); const contextCommand = script("context.mjs");
283
+ if (task.phase === "done") return { classification: "complete", phase: task.phase, gate: task.gate, reason: "Task is complete" };
284
+ if (["closed", "superseded"].includes(task.status)) return { classification: "terminal", phase: task.phase, gate: task.gate, outcome: task.status, successorTaskId: task.successorTaskId, reason: task.closure?.reason ?? "Task ended without successful completion" };
285
+ if (task.handoff) return { classification: "blocked", phase: task.phase, gate: task.gate, reason: task.handoff.reason, actions: [...(task.phase === "build" ? [{ id: "reopen_g1", command: `${stateCommand} task reopen ${task.id} --to plan --reason <reason> --source <explicit-user-request>${rootSuffix}` }] : []), { 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}` }, { id: "close", command: `${stateCommand} task close ${task.id} --reason <reason> --source <explicit-user-request>${rootSuffix}` }] };
286
+ if (task.status === "paused") 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}` }] };
287
+ if (task.phase === "build" && !isLegacyG2Wait(task)) { const bounds = repairBounds(task); if (bounds.verifyExhausted || bounds.reviewExhausted) { const kind = bounds.reviewExhausted ? "review_exhausted" : "repair_exhausted"; 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}` }] }; } }
288
+ if (task.status === "blocked_on_user" && task.gate === "G1_review" && task.decisions.some((decision) => decision.status === "unresolved")) 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" };
289
+ if (task.status === "blocked_on_user" && task.gate === "G2_codereview" && (task.tasks.some((item) => !["done", "deferred"].includes(item.status)) || !hasVerification(task) || !hasReview(task))) {
290
+ const open = task.tasks.filter((item) => item.status === "in_progress" || item.status === "todo");
291
+ const item = open.find((entry) => entry.status === "in_progress") ?? open[0];
292
+ if (item) 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}` };
293
+ 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" };
294
+ }
295
+ if (task.status === "blocked_on_user" && task.gate === "none") 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" };
296
+ if (task.status === "blocked_on_user") 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}` };
297
+ if (task.phase === "build") {
298
+ const open = task.tasks.filter((item) => item.status === "in_progress" || item.status === "todo");
299
+ const item = open.find((entry) => entry.status === "in_progress") ?? open[0];
300
+ if (item) 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` };
301
+ 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" };
302
+ }
303
+ 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` };
304
+ };
305
+
306
+ const audited = (reason, source) => { if (!text(reason) || !text(source)) throw new Error("Lifecycle mutation requires non-empty --reason and --source"); };
307
+ export const handoffTask = (state, id, kind, reason, source, recordedAt = new Date().toISOString()) => { const task = state.tasks[id]; if (!task || terminal(task) || !handoffKinds.includes(kind)) throw new Error(`Task cannot enter handoff: ${id}`); audited(reason, source); if (task.handoff) { if (task.handoff.kind === kind && task.handoff.reason === reason && task.handoff.source === source) return task; throw new Error(`Task already has a different handoff: ${id}`); } task.status = "paused"; task.handoff = { kind, reason, source, recordedAt }; delete task.legacyG2Wait; task.updatedAt = recordedAt; return task; };
308
+ export const closeTask = (state, id, reason, source, recordedAt = new Date().toISOString()) => { const task = state.tasks[id]; if (!task || task.status === "done" || task.status === "superseded") throw new Error(`Task cannot be closed: ${id}`); audited(reason, source); if (task.status === "closed") { if (task.closure?.reason === reason && task.closure.source === source) return task; throw new Error(`Task is already closed with different metadata: ${id}`); } task.status = "closed"; task.closure = { reason, source, recordedAt }; delete task.handoff; delete task.successorTaskId; delete task.legacyG2Wait; task.updatedAt = recordedAt; return task; };
309
+ export const supersedeTask = (state, id, successorId, reason, source, recordedAt = new Date().toISOString()) => { const task = state.tasks[id]; const successor = state.tasks[successorId]; audited(reason, source); if (!task || task.status === "done" || task.status === "closed" || !successor || id === successorId) throw new Error(`Task cannot be superseded: ${id}`); if (task.status === "superseded") { if (task.successorTaskId === successorId && task.closure?.reason === reason && task.closure.source === source) return task; throw new Error(`Task is already superseded with different metadata: ${id}`); } if (successor.phase !== "clarify" || successor.gate !== "G0_confirm" || terminal(successor) || hasApproval(successor, "G0_confirm") || (successor.predecessorTaskId && successor.predecessorTaskId !== id)) throw new Error(`Successor must be a fresh pre-G0 task: ${successorId}`); task.status = "superseded"; task.closure = { reason, source, recordedAt }; task.successorTaskId = successorId; delete task.handoff; delete task.legacyG2Wait; task.updatedAt = recordedAt; successor.predecessorTaskId = id; successor.updatedAt = recordedAt; validateState(state); return task; };
310
+ export const reopenTask = (state, id, target, reason, source, recordedAt = new Date().toISOString()) => { const task = state.tasks[id]; audited(reason, source); if (!task || target !== "plan" || task.phase !== "build" || task.status !== "paused" || !task.handoff) throw new Error(`Task cannot reopen G1: ${id}`); task.evidence.push({ kind: "approval", gate: "G1_review", result: "fail", source, detail: `Reopened G1: ${reason}`, recordedAt }); for (const item of task.tasks) if (item.status !== "deferred") item.status = "todo"; task.phase = "plan"; task.gate = "G1_review"; task.status = "active"; delete task.handoff; task.updatedAt = recordedAt; return task; };
311
+
312
+ export const recordLesson = (task, lesson, recordedAt = new Date().toISOString()) => { if (task.phase !== "wrap" || task.status !== "active") throw new Error(`Lessons can only be recorded during active wrap: ${task.id}`); const current = task.lessons?.find((item) => item.id === lesson.id); const next = { ...lesson, taskId: task.id, recordedAt }; validateLesson(task.id, next); if (current) { const strip = (item) => { const { recordedAt, ...rest } = item; return rest; }; if (JSON.stringify(strip(current)) === JSON.stringify(strip(next))) return current; throw new Error(`Lesson already exists with different content: ${task.id}/${lesson.id}`); } task.lessons ??= []; task.lessons.push(next); task.lessonDisposition = { status: "captured", source: lesson.source, recordedAt }; task.updatedAt = recordedAt; return next; };
313
+ export const recordNoLessons = (task, reason, source, recordedAt = new Date().toISOString()) => { if (task.phase !== "wrap" || task.status !== "active") throw new Error(`Lesson disposition can only be recorded during active wrap: ${task.id}`); audited(reason, source); if (task.lessons?.length) throw new Error(`Task already has captured lessons: ${task.id}`); task.lessonDisposition = { status: "none", reason, source, recordedAt }; task.updatedAt = recordedAt; };
314
+
315
+ const lessonEntry = (lesson) => ({ id: lesson.id, taskId: lesson.taskId, areas: lesson.areas, summary: lesson.summary, prevention: lesson.prevention, source: lesson.source, recordedAt: lesson.recordedAt });
316
+ export const lessonStateDigest = (state) => digest(JSON.stringify({ active: Object.values(state.tasks).flatMap((task) => task.lessons ?? []).sort((a, b) => `${a.taskId}:${a.id}`.localeCompare(`${b.taskId}:${b.id}`)), archive: Object.values(state.archive ?? {}).map((item) => ({ id: item.id, digest: item.digest, lessonCount: item.lessonCount })).sort((a, b) => a.id.localeCompare(b.id)) }));
317
+ 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)) });
318
+ const allLessons = (root, state) => [...Object.values(state.tasks).flatMap((task) => task.lessons ?? []), ...Object.keys(state.archive ?? {}).flatMap((id) => loadTask(root, id, state)?.lessons ?? [])];
319
+ export const rebuildLessonIndex = (root, state = loadState(root)) => { const index = createLessonIndex(allLessons(root, state), lessonStateDigest(state)); assertNoSymlinkPath(root, ".agents/data/lessons/index.json"); writeAtomic(lessonIndexPath(root), `${JSON.stringify(index, null, 2)}\n`); return index; };
320
+ const tokens = (value) => [...new Set(value.toLocaleLowerCase().split(/[^\p{L}\p{N}]+/u).filter((item) => item.length > 1))];
321
+ export const searchLessons = (root, state, query, areas = [], limit = 5) => { const path = lessonIndexPath(root); assertNoSymlinkPath(root, ".agents/data/lessons/index.json"); if (!existsSync(path)) { const hasLessons = Object.values(state.tasks).some((task) => Boolean(task.lessons?.length)) || Object.values(state.archive ?? {}).some((task) => task.lessonCount > 0); if (!hasLessons) return []; throw new Error("Lesson index is missing; run state.mjs lesson rebuild"); } const index = parseJsonFile(path, "Lesson index", "run state.mjs lesson rebuild"); if (index.schemaVersion !== 1 || !Array.isArray(index.lessons) || index.sourceDigest !== lessonStateDigest(state)) throw new Error("Lesson index is stale; run state.mjs lesson rebuild"); const queryTokens = tokens(query); const areaSet = new Set(areas.map((area) => area.toLocaleLowerCase())); return index.lessons.map((item) => { const haystack = new Set(tokens(`${item.summary} ${item.prevention} ${item.taskId} ${item.areas.join(" ")}`)); const areaScore = item.areas.some((area) => areaSet.has(area.toLocaleLowerCase())) ? 100 : 0; const tokenScore = queryTokens.filter((token) => haystack.has(token)).length; return { item, score: tokenScore + areaScore }; }).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); };
322
+
323
+ export const listTaskSummaries = (state, options = {}) => { 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" })); 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" })) : []; const queryTokens = tokens(options.query ?? ""); const statusSet = new Set(options.statuses ?? []); const filtered = [...active, ...archived].filter((item) => (!statusSet.size || statusSet.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)); const cursor = Math.max(0, options.cursor ?? 0); const limit = Math.max(1, Math.min(options.limit ?? 20, 100)); const items = filtered.slice(cursor, cursor + limit); return { items, nextCursor: cursor + items.length < filtered.length ? String(cursor + items.length) : null, total: filtered.length }; };
324
+
325
+ export const renderWorkplan = (task) => { const decisions = task.decisions.length ? task.decisions.map((item) => `- [${item.status === "approved" ? "x" : " "}] ${item.id} — ${item.label}${item.resolution ? ` — ${item.resolution}` : ""}`).join("\n") : "- None"; const tasks = task.tasks.length ? task.tasks.map((item) => `- [${item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "}] ${item.id} — ${item.label}`).join("\n") : "- None"; 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"); return `# Workplan — ${task.title} (\`${task.id}\`)\n\n> Generated from canonical JSON state.\n\n## Lifecycle\n${lifecycle}\n\n## 🧩 Decisions (Gate G1 — approve before build)\n${decisions}\n\n## 🧩 Tasks (Gate G2 — build execution)\n${tasks}\n`; };
326
+ export const renderViews = (root, state = loadState(root), selected = Object.values(state.tasks)) => { for (const task of selected) { if (!task.artifacts.workplan) continue; assertNoSymlinkPath(root, task.artifacts.workplan); writeAtomic(join(resolve(root), task.artifacts.workplan), renderWorkplan(task)); } };
327
+
328
+ const safeArtifactTarget = (root, path) => { assertNoSymlinkPath(root, path); return join(resolve(root), path); };
329
+ const artifactContent = (root, path) => { if (!path) return undefined; try { const target = safeArtifactTarget(root, path); return existsSync(target) ? readFileSync(target, "utf8") : undefined; } catch { return undefined; } };
330
+ const artifactDiagnostics = (root, task, names) => names.flatMap((name) => { const path = task.artifacts[name]; if (!path) return [{ level: "ERROR", code: "ARTIFACT_REFERENCE", message: `${name} artifact is not referenced` }]; try { return existsSync(safeArtifactTarget(root, path)) ? [] : [{ level: "ERROR", code: "ARTIFACT_MISSING", message: `${path} does not exist` }]; } catch (error) { return [{ level: "ERROR", code: "ARTIFACT_UNSAFE", message: error instanceof Error ? error.message : String(error) }]; } });
331
+ export const checkGate = (root, state, taskId, gate) => { const task = state.tasks[taskId]; if (!task) return [{ level: "ERROR", code: "TASK_UNKNOWN", message: `Unknown active task: ${taskId}` }]; const diagnostics = []; if (task.gate !== gate) diagnostics.push({ level: "ERROR", code: "GATE_STATE", message: `Task is at ${task.gate}, not ${gate}` }); if (["closed", "superseded"].includes(task.status)) diagnostics.push({ level: "ERROR", code: "TASK_TERMINAL", message: `Terminal task cannot approve ${gate}` }); if (task.handoff) diagnostics.push({ level: "ERROR", code: "TASK_HANDOFF", message: `Task has unresolved handoff: ${task.handoff.reason}` }); if (task.id !== taskId) diagnostics.push({ level: "ERROR", code: "TASK_ID", message: "Task key and id differ" }); if (!task.title.trim() || !task.areas.length) diagnostics.push({ level: "ERROR", code: "TASK_FIELDS", message: "Task title and affected areas are required" }); if (gate === "G0_confirm") { diagnostics.push(...artifactDiagnostics(root, task, ["intent"])); const content = artifactContent(root, task.artifacts.intent); if (content !== undefined) for (const heading of ["## 📋 Problem", "## 🗺️ Affected areas", "## 💭 Assumptions", "## ❓ Open questions", "## 🎯 Scope"]) if (!content.includes(heading)) diagnostics.push({ level: "ERROR", code: "INTENT_HEADING", message: `Intent is missing ${heading}` }); } if (gate === "G1_review") { diagnostics.push(...artifactDiagnostics(root, task, ["intent", "design", "workplan"])); diagnostics.push(...transitionDiagnostics(task, "build").filter((item) => item.code === "UNRESOLVED_DECISION")); const content = artifactContent(root, task.artifacts.design); if (content !== undefined) for (const heading of ["## 🧩 Solution per affected area", "## 📌 Spec traceability", "## 🔗 Cross-service contracts", "## ⚠️ Risks / edge cases"]) if (!content.includes(heading)) diagnostics.push({ level: "ERROR", code: "DESIGN_HEADING", message: `Design is missing ${heading}` }); } if (gate === "G2_codereview") { diagnostics.push(...artifactDiagnostics(root, task, ["intent", "design", "workplan"])); if (task.tasks.some((item) => !["done", "deferred"].includes(item.status))) diagnostics.push({ level: "ERROR", code: "TASKS_OPEN", message: "Build tasks remain open" }); for (const area of task.areas) if (!hasAreaVerification(task, area)) diagnostics.push({ level: "ERROR", code: "VERIFY_EVIDENCE", message: `Latest post-G1 verification evidence must pass for affected area: ${area}` }); if (!hasReview(task)) diagnostics.push({ level: "ERROR", code: "REVIEW_EVIDENCE", message: "Latest post-G1 code-review evidence must pass" }); const bounds = repairBounds(task); if (!isLegacyG2Wait(task) && (bounds.verifyExhausted || bounds.reviewExhausted)) diagnostics.push({ level: "ERROR", code: "REPAIR_BOUND", message: "Repair bound exhausted; record a durable handoff instead of presenting G2" }); } if (!diagnostics.length) diagnostics.push({ level: "INFO", code: "GATE_OK", message: `${gate} checks passed for ${taskId}` }); return diagnostics; };
332
+ const gateTarget = { G0_confirm: "plan", G1_review: "build", G2_codereview: "wrap" };
333
+ export const approveAndAdvance = (root, state, taskId, gate, source, recordedAt = new Date().toISOString()) => { const task = state.tasks[taskId]; if (!task) throw new Error(`Unknown active task: ${taskId}`); if (["closed", "superseded"].includes(task.status) || task.handoff) throw new Error(`Terminal or handed-off task cannot approve ${gate}`); const target = gateTarget[gate]; if (!target) throw new Error(`Gate ${gate} cannot be approved`); if (task.phase === target) return { task, nextAction: nextAction(task, root), idempotent: true }; if (task.gate !== gate || task.status !== "blocked_on_user") throw new Error(`Task must be blocked_on_user at ${gate}`); const errors = checkGate(root, state, taskId, gate).filter((item) => item.level === "ERROR"); if (errors.length) throw new Error(errors.map((item) => item.message).join("; ")); task.evidence.push({ kind: "approval", gate, result: "pass", source, recordedAt }); transitionTask(state, taskId, target); return { task, nextAction: nextAction(task, root), idempotent: false }; };
334
+ export const formatDiagnostics = (diagnostics) => diagnostics.map((item) => `${item.level} ${item.code}: ${item.message}`).join("\n");
335
+
336
+ const oneLine = (value) => value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").replace(/ACTION\s+REQUIRED/gi, "ACTION-REQUIRED").trim();
337
+ const markdownText = (value) => oneLine(value).replace(/([\\`*_{}\[\]()<>#+.!|>~])/g, "\\$1");
338
+ const gateMeta = { G0_confirm: { icon: "🟢", title: "GATE G0 · CONFIRM INTENT", action: "Reply `ok` to approve intent, or state changes." }, G1_review: { icon: "🔵", title: "GATE G1 · REVIEW PLAN", action: "Reply `approve` to start build, or state decision changes." }, G2_codereview: { icon: "🟣", title: "GATE G2 · REVIEW CODE", action: "Approve to wrap, or point out what to fix." } };
339
+ export const gateView = (task, diagnostics) => { if (task.gate === "none") throw new Error(`Task has no human gate: ${task.id}`); const errors = diagnostics.filter((item) => item.level === "ERROR"); if (errors.length) throw new Error(errors.map((item) => item.message).join("; ")); const meta = gateMeta[task.gate]; const counts = (items) => items.reduce((result, item) => ({ ...result, [item.status]: (result[item.status] ?? 0) + 1 }), {}); return { schemaVersion: 1, gate: task.gate, icon: meta.icon, title: meta.title, action: meta.action, task: { id: task.id, title: oneLine(task.title), status: task.status, risk: task.risk, areas: task.areas }, artifacts: Object.entries(task.artifacts).flatMap(([name, path]) => path ? [{ name: oneLine(name), path: oneLine(path) }] : []), decisions: counts(task.decisions), execution: counts(task.tasks), evidence: task.evidence.filter((item) => ["test", "lint", "review"].includes(item.kind)).slice(-5).map((item) => ({ kind: item.kind, result: item.result, source: oneLine(item.source) })), diagnostics: diagnostics.filter((item) => item.level !== "INFO").map((item) => ({ level: item.level, code: item.code, message: oneLine(item.message) })) }; };
340
+ const markdownLinkTarget = (value) => encodeURI(value).replace(/[()#?]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
341
+ export const formatGateView = (task, diagnostics, format = "markdown") => { const view = gateView(task, diagnostics); if (format === "json") return `${JSON.stringify(view, null, 2)}\n`; const artifacts = view.artifacts.length ? view.artifacts.map((item) => format === "markdown" ? `- **${markdownText(item.name)}:** [${markdownText(item.path)}](${markdownLinkTarget(item.path)})` : `- ${item.name}: ${item.path}`).join("\n") : "- none"; const decisions = Object.entries(view.decisions).map(([status, count]) => `${count} ${status}`).join(" · ") || "none"; const execution = Object.entries(view.execution).map(([status, count]) => `${count} ${status}`).join(" · ") || "none"; const evidence = view.evidence.length ? view.evidence.map((item) => `- ${item.kind}: ${item.result} — ${format === "markdown" ? markdownText(item.source) : item.source}`).join("\n") : "- none yet"; const warnings = view.diagnostics.length ? `\n\n${view.diagnostics.map((item) => `- ${item.level} ${item.code}: ${format === "markdown" ? markdownText(item.message) : item.message}`).join("\n")}` : ""; if (format === "plain") return [`[IMPORTANT] ${view.icon} ${view.title}`, `Task: ${view.task.id} — ${view.task.title}`, `Status: ${view.task.status} · Risk: ${view.task.risk} · Areas: ${view.task.areas.join(", ")}`, "", "Review artifacts", artifacts, "", `Decisions: ${decisions}`, `Execution: ${execution}`, "Evidence", evidence, warnings, "", `ACTION REQUIRED -> ${oneLine(view.action)}`].join("\n").replace(/\n{3,}/g, "\n\n") + "\n"; return ["> [!IMPORTANT]", `> ${view.icon} **${view.title}**`, `> **Task:** \`${view.task.id}\` — ${markdownText(view.task.title)}`, `> **Status:** \`${view.task.status}\` · **Risk:** \`${view.task.risk}\` · **Areas:** ${view.task.areas.map((area) => `\`${markdownText(area)}\``).join(", ")}`, "", "### Review artifacts", artifacts, "", `**Decisions:** ${decisions} `, `**Execution:** ${execution}`, "", "### Verification evidence", evidence, warnings, "", `> **ACTION REQUIRED →** ${view.action}`].join("\n").replace(/\n{3,}/g, "\n\n") + "\n"; };
@@ -0,0 +1,23 @@
1
+ const required = (value, message) => { if (!value) throw new Error(message); return value; };
2
+ const providerRequest = (provider, reference, operation, payload = {}) => {
3
+ if (provider === "jira") {
4
+ const baseUrl = required(process.env.JIRA_BASE_URL, "JIRA_BASE_URL is required"); const token = required(process.env.JIRA_TOKEN, "JIRA_TOKEN is required");
5
+ return { url: `${baseUrl.replace(/\/$/, "")}/rest/api/3/issue/${encodeURIComponent(reference)}${operation === "comment" ? "/comment" : ""}`, method: operation === "get" ? "GET" : "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: operation === "comment" ? { body: payload.comment } : payload };
6
+ }
7
+ if (provider === "trello") {
8
+ const key = required(process.env.TRELLO_KEY, "TRELLO_KEY is required"); const token = required(process.env.TRELLO_TOKEN, "TRELLO_TOKEN is required");
9
+ return { url: `https://api.trello.com/1/cards/${encodeURIComponent(reference)}?key=${encodeURIComponent(key)}&token=${encodeURIComponent(token)}`, method: operation === "get" ? "GET" : "PUT", headers: { "Content-Type": "application/json" }, body: payload };
10
+ }
11
+ if (provider === "github-issues") {
12
+ const token = required(process.env.GITHUB_TOKEN, "GITHUB_TOKEN is required"); const [owner, repo, number] = reference.split("/"); if (!owner || !repo || !number) throw new Error("GitHub reference must be owner/repo/issue-number");
13
+ return { url: `https://api.github.com/repos/${owner}/${repo}/issues/${number}${operation === "comment" ? "/comments" : ""}`, method: operation === "get" ? "GET" : operation === "comment" ? "POST" : "PATCH", headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "Content-Type": "application/json" }, body: operation === "comment" ? { body: payload.comment } : payload };
14
+ }
15
+ throw new Error(`Unsupported work-item provider: ${provider}`);
16
+ };
17
+
18
+ /** Return a credential-free, auditable preflight for an external operation. */
19
+ export const providerPreflight = (provider, reference, operation, payload = {}) => {
20
+ const request = providerRequest(provider, reference, operation, payload);
21
+ const credentialEnv = provider === "jira" ? ["JIRA_BASE_URL", "JIRA_TOKEN"] : provider === "trello" ? ["TRELLO_KEY", "TRELLO_TOKEN"] : ["GITHUB_TOKEN"];
22
+ return { provider, reference, operation, ready: true, credentialEnv, target: new URL(request.url).origin, payloadFields: Object.keys(payload).sort() };
23
+ };