@felixdotgo/aidlc-kit 0.0.1 → 0.1.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.
Files changed (92) hide show
  1. package/DEVELOPMENT.md +1 -1
  2. package/README.md +5 -4
  3. package/dist/assets/.agents/aidlc/conventions.md +8 -8
  4. package/dist/assets/.agents/aidlc/orchestrator.md +15 -10
  5. package/dist/assets/.agents/aidlc/phase-build.md +7 -7
  6. package/dist/assets/.agents/aidlc/phase-clarify.md +3 -3
  7. package/dist/assets/.agents/aidlc/phase-plan.md +6 -6
  8. package/dist/assets/.agents/aidlc/phase-wrap.md +1 -1
  9. package/dist/assets/.agents/aidlc/scripts/assignment.mjs +36 -0
  10. package/dist/assets/.agents/aidlc/scripts/context.mjs +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/gate-check.mjs +2 -0
  12. package/dist/assets/.agents/aidlc/scripts/gate-view.mjs +2 -0
  13. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +181 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/authority.mjs +3 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +8 -1
  16. package/dist/assets/.agents/aidlc/scripts/lib/feature-store.mjs +70 -0
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +215 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +183 -0
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +100 -0
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +58 -0
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +314 -0
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +279 -0
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +51 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-artifacts.mjs +63 -0
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-broker.mjs +53 -0
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-local-broker.mjs +44 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-sandbox.mjs +88 -0
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +89 -0
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-gate-receipt.mjs +32 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +161 -0
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-planner.mjs +13 -0
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +254 -0
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +131 -0
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +55 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +101 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +32 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +65 -0
  38. package/dist/assets/.agents/aidlc/scripts/lib/remote-state-client.mjs +112 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +39 -4
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +90 -0
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +58 -0
  42. package/dist/assets/.agents/aidlc/scripts/loop.mjs +36 -0
  43. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +309 -0
  44. package/dist/assets/.agents/aidlc/scripts/render.mjs +2 -0
  45. package/dist/assets/.agents/aidlc/scripts/state.mjs +67 -11
  46. package/dist/assets/.agents/aidlc/scripts/task-next.mjs +2 -0
  47. package/dist/assets/.agents/aidlc/templates/model-contract.md +3 -3
  48. package/dist/assets/.agents/aidlc/templates/workplan.md +2 -2
  49. package/dist/assets/.agents/skills/aidlc-build/SKILL.md +2 -2
  50. package/dist/assets/.agents/skills/aidlc-clarify/SKILL.md +2 -2
  51. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +2 -2
  52. package/dist/assets/.agents/skills/aidlc-wrap/SKILL.md +1 -1
  53. package/dist/services/aidlc-coordination/.env.fleet.example +11 -0
  54. package/dist/services/aidlc-coordination/compose.yaml +23 -2
  55. package/dist/services/aidlc-coordination/package-lock.json +1 -0
  56. package/dist/services/aidlc-coordination/package.json +2 -1
  57. package/dist/services/aidlc-coordination/src/coordination.mjs +1 -1
  58. package/dist/services/aidlc-coordination/src/fleet-client.mjs +36 -0
  59. package/dist/services/aidlc-coordination/src/fleet-run.mjs +18 -0
  60. package/dist/services/aidlc-coordination/src/fleet.mjs +233 -0
  61. package/dist/services/aidlc-coordination/src/git-gateway.mjs +50 -0
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +39 -4
  63. package/dist/services/aidlc-coordination/src/repository.mjs +167 -4
  64. package/dist/services/aidlc-coordination/src/server.mjs +53 -9
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +351 -0
  66. package/dist/services/aidlc-coordination/test/git-gateway.test.mjs +75 -0
  67. package/dist/services/aidlc-coordination/test/supervisor-fencing.test.mjs +59 -0
  68. package/dist/src/adapters.js +25 -5
  69. package/dist/src/cli.js +66 -6
  70. package/dist/src/gate.js +3 -3
  71. package/dist/src/installer.d.ts +5 -1
  72. package/dist/src/installer.js +114 -30
  73. package/dist/src/managed-content.js +1 -1
  74. package/dist/src/model.d.ts +15 -0
  75. package/dist/src/model.js +1 -1
  76. package/dist/src/profiles.d.ts +3 -1
  77. package/dist/src/profiles.js +26 -3
  78. package/dist/src/state.d.ts +7 -0
  79. package/dist/src/state.js +54 -3
  80. package/dist/src/upgrade.d.ts +2 -2
  81. package/dist/src/upgrade.js +39 -4
  82. package/dist/src/workflow.d.ts +1 -0
  83. package/dist/src/workflow.js +2 -1
  84. package/docs/README.md +2 -2
  85. package/docs/architecture.md +6 -1
  86. package/docs/command-reference.md +63 -5
  87. package/docs/configuration.md +20 -1
  88. package/docs/coordination-service.md +54 -1
  89. package/docs/development.md +1 -1
  90. package/docs/operating-workflow.md +50 -14
  91. package/docs/testing-and-release.md +47 -4
  92. package/package.json +2 -2
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, sep } from 'node:path';
4
+
5
+ const excluded = (path) => path.split('/').includes('.git') || path === '.agents/data' || path.startsWith('.agents/data/');
6
+ const scopePath = (path) => typeof path === 'string' && path && !isAbsolute(path) && !path.includes('\\') && !path.split('/').some((part) => !part || part === '.' || part === '..' || part === '.git' || part === '.agents');
7
+ const within = (path, scopes) => scopes.some((scope) => path === scope || path.startsWith(`${scope}/`));
8
+ const digest = (path) => createHash('sha256').update(readFile(path)).digest('hex');
9
+ import { readFileSync as readFile } from 'node:fs';
10
+
11
+ // Reject links and special files rather than allowing a worker to make a host
12
+ // copy operation follow an external path or alias a file outside its scope.
13
+ function inventory(root, skipExcluded) {
14
+ const files = new Map();
15
+ function visit(dir, prefix = '') {
16
+ for (const name of readdirSync(dir)) {
17
+ const path = prefix ? `${prefix}/${name}` : name;
18
+ if (excluded(path)) {
19
+ if (skipExcluded) continue;
20
+ throw new Error(`Worker created a protected path: ${path}`);
21
+ }
22
+ const full = join(dir, name), info = lstatSync(full);
23
+ if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile()) || (info.isFile() && info.nlink !== 1)) throw new Error(`Unsafe worker file: ${path}`);
24
+ if (info.isDirectory()) visit(full, path);
25
+ else files.set(path, { digest: digest(full), executable: Boolean(info.mode & 0o111) });
26
+ }
27
+ }
28
+ visit(root);
29
+ return files;
30
+ }
31
+
32
+ /** Create a source-only snapshot. The caller supplies a new empty directory. */
33
+ export function createWorkerSnapshot(source, destination, trackedPaths) {
34
+ const src = realpathSync(source), dst = realpathSync(destination);
35
+ if (src === dst || dst.startsWith(`${src}${sep}`) || src.startsWith(`${dst}${sep}`)) throw new Error('Worker snapshot must be separate from source');
36
+ if (readdirSync(dst).length) throw new Error('Worker snapshot destination must be empty');
37
+ if (!Array.isArray(trackedPaths) || !trackedPaths.length || trackedPaths.some((path) => typeof path !== 'string' || !path || isAbsolute(path) || path.includes('\\') || path.split('/').some((part) => !part || part === '.' || part === '..') || excluded(path))) throw new Error('An explicit safe tracked-file manifest is required');
38
+ const sourceFiles = inventory(src, true), baseline = new Map();
39
+ for (const path of new Set(trackedPaths)) {
40
+ const entry = sourceFiles.get(path);
41
+ if (!entry) throw new Error(`Tracked worker source is missing or unsafe: ${path}`);
42
+ baseline.set(path, entry);
43
+ }
44
+ for (const [path] of baseline) {
45
+ const target = join(dst, path);
46
+ mkdirSync(dirname(target), { recursive: true });
47
+ copyFileSync(join(src, path), target);
48
+ }
49
+ return baseline;
50
+ }
51
+
52
+ /** Validate the complete stopped-worker snapshot before host integration. */
53
+ export function inspectWorkerSnapshot(destination, baseline, scopes) {
54
+ if (!(baseline instanceof Map) || !Array.isArray(scopes) || !scopes.length || !scopes.every(scopePath)) throw new Error('Invalid snapshot baseline or scopes');
55
+ const root = realpathSync(destination), current = inventory(root, false);
56
+ const changed = [];
57
+ for (const path of new Set([...baseline.keys(), ...current.keys()])) {
58
+ const before = baseline.get(path), after = current.get(path);
59
+ if (!before || !after || before.digest !== after.digest || before.executable !== after.executable) {
60
+ if (!scopePath(path) || !within(path, scopes)) throw new Error(`Worker changed a path outside its scope: ${path}`);
61
+ changed.push(path);
62
+ }
63
+ }
64
+ return changed.sort();
65
+ }
@@ -0,0 +1,112 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ const parseMessage = (body, contentType, id) => {
4
+ if (contentType.includes("text/event-stream")) {
5
+ for (const block of body.split(/\r?\n\r?\n/)) {
6
+ const data = block.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("\n");
7
+ if (!data || data === "[DONE]") continue;
8
+ try { const message = JSON.parse(data); if (message.id === id) return message; } catch { /* keep reading */ }
9
+ }
10
+ return undefined;
11
+ }
12
+ const message = JSON.parse(body);
13
+ return Array.isArray(message) ? message.find((entry) => entry.id === id) : message;
14
+ };
15
+
16
+ export const remoteConfig = (env = process.env) => {
17
+ const url = env.AIDLC_FLEET_URL;
18
+ const workspace = env.AIDLC_FLEET_WORKSPACE;
19
+ const token = env.AIDLC_FLEET_TOKEN;
20
+ const projectRoot = env.AIDLC_FLEET_PROJECT_ROOT;
21
+ const leadEpoch = Number(env.AIDLC_FLEET_LEAD_EPOCH);
22
+ if (!url || !/^https?:\/\//.test(url) || !workspace || !token || !projectRoot?.startsWith("/") || !Number.isSafeInteger(leadEpoch) || leadEpoch < 1) {
23
+ throw new Error("Remote state requires AIDLC_FLEET_URL, WORKSPACE, TOKEN, absolute PROJECT_ROOT, and positive LEAD_EPOCH");
24
+ }
25
+ return { url, workspace, token, projectRoot, leadEpoch, leadOwnerToken: env.AIDLC_FLEET_LEAD_OWNER_TOKEN, integrationBranch: env.AIDLC_FLEET_INTEGRATION_BRANCH, gitRemote: env.AIDLC_FLEET_GIT_REMOTE ?? "origin" };
26
+ };
27
+
28
+ export const featureConfig = (env = process.env) => {
29
+ const url = env.AIDLC_FLEET_URL;
30
+ const workspace = env.AIDLC_FLEET_WORKSPACE;
31
+ const token = env.AIDLC_FLEET_TOKEN;
32
+ const projectRoot = env.AIDLC_FLEET_PROJECT_ROOT;
33
+ if (!url || !/^https?:\/\//.test(url) || !workspace || !token || !projectRoot?.startsWith("/")) {
34
+ throw new Error("Remote feature commands require AIDLC_FLEET_URL, WORKSPACE, TOKEN, and absolute PROJECT_ROOT");
35
+ }
36
+ return { url, workspace, token, projectRoot };
37
+ };
38
+
39
+ export const reviewerConfig = (env = process.env) => {
40
+ const url = env.AIDLC_FLEET_URL;
41
+ const workspace = env.AIDLC_FLEET_WORKSPACE;
42
+ const token = env.AIDLC_FLEET_REVIEWER_TOKEN;
43
+ const projectRoot = env.AIDLC_FLEET_PROJECT_ROOT;
44
+ if (!url || !/^https?:\/\//.test(url) || !workspace || !token || !projectRoot?.startsWith("/")) {
45
+ throw new Error("Remote review requires AIDLC_FLEET_URL, WORKSPACE, REVIEWER_TOKEN, and absolute PROJECT_ROOT");
46
+ }
47
+ return { url, workspace, token, projectRoot, integrationBranch: env.AIDLC_FLEET_INTEGRATION_BRANCH, gitRemote: env.AIDLC_FLEET_GIT_REMOTE ?? "origin" };
48
+ };
49
+
50
+ export const callRemoteTool = async (config, name, args = {}, fetchImpl = fetch) => {
51
+ const id = randomUUID();
52
+ let response;
53
+ try {
54
+ response = await fetchImpl(config.url, {
55
+ method: "POST",
56
+ headers: { Authorization: `Bearer ${config.token}`, Accept: "application/json, text/event-stream", "Content-Type": "application/json", "MCP-Protocol-Version": "2025-06-18" },
57
+ body: JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args } }),
58
+ signal: AbortSignal.timeout(20000)
59
+ });
60
+ } catch (error) { throw new Error(`Remote coordination unavailable: ${error.message}`); }
61
+ const body = await response.text();
62
+ if (!response.ok) throw new Error(`Remote coordination HTTP ${response.status}: ${body.slice(0, 300)}`);
63
+ let message;
64
+ try { message = parseMessage(body, response.headers.get("content-type") ?? "", id); }
65
+ catch { throw new Error("Remote coordination returned invalid JSON"); }
66
+ if (!message || message.id !== id) throw new Error("Remote coordination returned no matching result");
67
+ if (message.error) throw new Error(`Remote coordination RPC ${message.error.code}: ${message.error.message}`);
68
+ const tool = message.result;
69
+ if (!Array.isArray(tool?.content) || tool.content[0]?.type !== "text") throw new Error("Remote coordination returned no JSON text");
70
+ let value;
71
+ try { value = JSON.parse(tool.content[0].text); } catch { throw new Error("Remote coordination returned invalid tool JSON"); }
72
+ if (tool.isError) {
73
+ const error = new Error(value.message ?? value.code ?? "Remote coordination rejected operation");
74
+ error.code = value.code; error.current = value.current;
75
+ throw error;
76
+ }
77
+ return value;
78
+ };
79
+
80
+ export const createRemoteStateClient = (config, call = callRemoteTool) => ({
81
+ get: () => call(config, "state_get", { workspace: config.workspace }),
82
+ task: (taskId) => call(config, "task_get", { workspace: config.workspace, taskId, clientProjectRoot: config.projectRoot }),
83
+ apply: (expectedRevision, taskId, task) => call(config, "state_apply", {
84
+ workspace: config.workspace, expectedRevision, idempotencyKey: randomUUID(), clientProjectRoot: config.projectRoot,
85
+ leadEpoch: config.leadEpoch, ...(config.leadOwnerToken ? { leadOwnerToken: config.leadOwnerToken } : {}), command: { type: "upsertTask", taskId, task }
86
+ }),
87
+ snapshot: (expectedRevision, taskId, gate, reviewText, integrationCommit) => call(config, "gate_snapshot_put", {
88
+ workspace: config.workspace, expectedRevision, idempotencyKey: randomUUID(), taskId,
89
+ leadEpoch: config.leadEpoch, ...(config.leadOwnerToken ? { leadOwnerToken: config.leadOwnerToken } : {}), gate, reviewText, ...(integrationCommit ? { integrationCommit } : {})
90
+ }),
91
+ featureGet: (featureId) => call(config, "feature_get", { workspace: config.workspace, featureId }),
92
+ featureReady: (featureId) => call(config, "feature_ready", { workspace: config.workspace, featureId }),
93
+ featureApply: (expectedRevision, feature) => call(config, "feature_apply", {
94
+ workspace: config.workspace, expectedRevision, idempotencyKey: randomUUID(), feature
95
+ }),
96
+ fleetGet: () => call(config, "fleet_get", { workspace: config.workspace }),
97
+ runClaim: (expectedRevision, taskId, leaseMs) => call(config, "run_claim", {
98
+ workspace: config.workspace, expectedRevision, idempotencyKey: randomUUID(), taskId, leaseMs
99
+ }),
100
+ runRelease: (expectedRevision, taskId, epoch) => call(config, "run_release", {
101
+ workspace: config.workspace, expectedRevision, idempotencyKey: randomUUID(), taskId, epoch
102
+ })
103
+ });
104
+
105
+ export const createRemoteReviewerClient = (config, call = callRemoteTool) => ({
106
+ get: () => call(config, "state_get", { workspace: config.workspace }),
107
+ inbox: () => call(config, "gate_inbox_get", { workspace: config.workspace }),
108
+ approve: (expectedRevision, taskId, snapshotId, source, integrationCommit) => call(config, "gate_approve", {
109
+ workspace: config.workspace, expectedRevision, idempotencyKey: randomUUID(), taskId, snapshotId,
110
+ source, clientProjectRoot: config.projectRoot, ...(integrationCommit ? { integrationCommit } : {})
111
+ })
112
+ });
@@ -61,7 +61,9 @@ const validateTask = (id, task) => {
61
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
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
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.featureId !== undefined && !idPattern.test(task.featureId)) throw new Error(`Invalid feature id: ${id}`);
64
65
  if (task.tasks.some((item) => !item.id || !item.label || !["todo", "in_progress", "done", "deferred"].includes(item.status))) throw new Error(`Invalid execution tasks: ${id}`);
66
+ readyTaskItems(task);
65
67
  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
68
  const lessonIds = new Set();
67
69
  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); }
@@ -276,6 +278,35 @@ export const transitionTask = (state, id, target) => {
276
278
  task.phase = target; task.gate = phaseGate(target); task.status = target === "done" ? "done" : "active"; delete task.legacyG2Wait; task.updatedAt = new Date().toISOString(); return task;
277
279
  };
278
280
 
281
+ export const readyTaskItems = (task) => {
282
+ const byId = new Map(task.tasks.map((item) => [item.id, item]));
283
+ if (byId.size !== task.tasks.length) throw new Error(`Duplicate execution item: ${task.id}`);
284
+ const visiting = new Set(); const visited = new Set();
285
+ const visit = (id) => {
286
+ if (visiting.has(id)) throw new Error(`Cyclic execution dependency: ${task.id}/${id}`);
287
+ if (visited.has(id)) return;
288
+ visiting.add(id);
289
+ for (const dep of byId.get(id)?.dependsOn ?? []) {
290
+ if (dep === id || !byId.has(dep)) throw new Error(`Unknown execution dependency: ${task.id}/${id}/${dep}`);
291
+ visit(dep);
292
+ }
293
+ visiting.delete(id); visited.add(id);
294
+ };
295
+ for (const item of task.tasks) {
296
+ if (item.dependsOn !== undefined && (!Array.isArray(item.dependsOn) || new Set(item.dependsOn).size !== item.dependsOn.length || item.dependsOn.some((dep) => typeof dep !== "string" || !dep))) throw new Error(`Invalid execution dependencies: ${task.id}/${item.id}`);
297
+ if (item.ownedPaths !== undefined && (!Array.isArray(item.ownedPaths) || new Set(item.ownedPaths).size !== item.ownedPaths.length || item.ownedPaths.some((path) => typeof path !== "string" || !path || isAbsolute(path) || path === ".." || path.startsWith("../") || path.includes("\\") || path.split("/").includes("..")))) throw new Error(`Invalid owned paths: ${task.id}/${item.id}`);
298
+ visit(item.id);
299
+ }
300
+ const ready = []; const blocked = [];
301
+ for (const item of task.tasks) {
302
+ const waitingOn = (item.dependsOn ?? []).filter((dep) => byId.get(dep)?.status !== "done");
303
+ if (item.status === "done" && waitingOn.length) throw new Error(`Completed item has incomplete prerequisites: ${task.id}/${item.id}`);
304
+ if (item.status !== "todo" && item.status !== "in_progress") continue;
305
+ if (waitingOn.length) blocked.push({ id: item.id, waitingOn }); else ready.push(item.id);
306
+ }
307
+ return { ready, blocked };
308
+ };
309
+
279
310
  export const nextAction = (task, root) => {
280
311
  const rootSuffix = root === undefined ? "" : ` --root ${JSON.stringify(resolve(root))}`;
281
312
  const script = (name) => root === undefined ? `node .agents/aidlc/scripts/${name}` : `node ${JSON.stringify(join(resolve(root), ".agents/aidlc/scripts", name))}`;
@@ -288,16 +319,20 @@ export const nextAction = (task, root) => {
288
319
  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
320
  if (task.status === "blocked_on_user" && task.gate === "G2_codereview" && (task.tasks.some((item) => !["done", "deferred"].includes(item.status)) || !hasVerification(task) || !hasReview(task))) {
290
321
  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];
322
+ const ready = new Set(readyTaskItems(task).ready);
323
+ const item = open.find((entry) => entry.status === "in_progress" && ready.has(entry.id)) ?? open.find((entry) => ready.has(entry.id));
292
324
  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}` };
325
+ if (open.length) return { classification: "blocked", phase: task.phase, gate: task.gate, reason: `No dependency-ready build item: ${readyTaskItems(task).blocked.map((entry) => `${entry.id} waits for ${entry.waitingOn.join(",")}`).join("; ")}` };
293
326
  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
327
  }
295
328
  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
329
  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
330
  if (task.phase === "build") {
298
331
  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];
332
+ const ready = new Set(readyTaskItems(task).ready);
333
+ const item = open.find((entry) => entry.status === "in_progress" && ready.has(entry.id)) ?? open.find((entry) => ready.has(entry.id));
300
334
  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` };
335
+ if (open.length) return { classification: "blocked", phase: task.phase, gate: task.gate, reason: `No dependency-ready build item: ${readyTaskItems(task).blocked.map((entry) => `${entry.id} waits for ${entry.waitingOn.join(",")}`).join("; ")}` };
301
336
  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
337
  }
303
338
  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` };
@@ -322,7 +357,7 @@ export const searchLessons = (root, state, query, areas = [], limit = 5) => { co
322
357
 
323
358
  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
359
 
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`; };
360
+ 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 (Approve plan — before implementation)\n${decisions}\n\n## 🧩 Tasks (Review implementation — work items)\n${tasks}\n`; };
326
361
  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
362
 
328
363
  const safeArtifactTarget = (root, path) => { assertNoSymlinkPath(root, path); return join(resolve(root), path); };
@@ -335,7 +370,7 @@ export const formatDiagnostics = (diagnostics) => diagnostics.map((item) => `${i
335
370
 
336
371
  const oneLine = (value) => value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").replace(/ACTION\s+REQUIRED/gi, "ACTION-REQUIRED").trim();
337
372
  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." } };
373
+ const gateMeta = { G0_confirm: { icon: "🟢", title: "Review step · Confirm requirements", action: "Reply `ok` to confirm requirements, or state changes." }, G1_review: { icon: "🔵", title: "Review step · Approve plan", action: "Reply `approve` to approve the plan and start implementation, or state decision changes." }, G2_codereview: { icon: "🟣", title: "Review step · Review implementation", action: "Approve the implementation to complete the task, or point out what to fix." } };
339
374
  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
375
  const markdownLinkTarget = (value) => encodeURI(value).replace(/[()#?]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
341
376
  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,90 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { resolve, join } from "node:path";
6
+ import { createInterface } from "node:readline/promises";
7
+ import { stdin, stdout } from "node:process";
8
+ import { requireLocalAuthority } from "./lib/authority.mjs";
9
+ import { isLoopProvider } from "./lib/loop-drivers.mjs";
10
+ import { prepareLoopIntake } from "./lib/loop-intake.mjs";
11
+ import * as localSupervisor from "./lib/loop-supervisor-run-local.mjs";
12
+ import { loadTask } from "./lib/store.mjs";
13
+
14
+ const [action, taskArg, rootArg, modeArg] = process.argv.slice(2);
15
+ const root = resolve(rootArg || ".");
16
+ const readRequirement = async () => {
17
+ if (stdin.isTTY) { const rl = createInterface({ input: stdin, output: stdout }); try { return await rl.question("Requirement: "); } finally { rl.close(); } }
18
+ const chunks = []; for await (const chunk of stdin) { chunks.push(chunk); if (chunks.reduce((n, c) => n + c.length, 0) > 8192) throw new Error("Requirement exceeds 8192 bytes"); } return Buffer.concat(chunks).toString("utf8");
19
+ };
20
+ try {
21
+ if (!["start", "resume", "status"].includes(action)) throw new Error("Expected loop start, resume, or status");
22
+ const config = JSON.parse(readFileSync(join(root, ".agents", "config.json"), "utf8"));
23
+ const mode = modeArg || config.loop?.mode || "off";
24
+ if (!["local", "fleet"].includes(mode)) throw new Error("Loop mode is off; configure an enabled loop mode first");
25
+ if (mode !== config.loop?.mode) throw new Error("Requested mode does not match configured authority");
26
+ const agent = config.loop?.agents?.lead;
27
+ const workers = config.loop?.agents?.workers ?? {};
28
+ if (!isLoopProvider(agent) || !Object.keys(workers).length || Object.entries(workers).some(([, value]) => !isLoopProvider(value))) throw new Error("Loop requires a configured lead and worker providers");
29
+ const taskId = taskArg || (action === "start" ? `loop-${randomUUID()}` : "");
30
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId)) throw new Error("Valid task ID required");
31
+ if (mode === "fleet") {
32
+ const { callRemoteTool } = await import("./lib/remote-state-client.mjs");
33
+ const { createFleetRemoteRepository } = await import("./lib/loop-fleet-remote.mjs");
34
+ const { runFleetCli } = await import("./lib/loop-fleet-cli.mjs");
35
+ const { readIntegrationCommit } = await import("./remote-state.mjs");
36
+ const requiredEnv = (name) => { const value = process.env[name]; if (typeof value !== "string" || !value.trim()) throw new Error(`Fleet loop requires ${name} for the configured remote authority`); return value; };
37
+ const url = requiredEnv("AIDLC_FLEET_URL"), workspace = requiredEnv("AIDLC_FLEET_WORKSPACE"), token = requiredEnv("AIDLC_FLEET_TOKEN");
38
+ const integrationBranch = requiredEnv("AIDLC_FLEET_INTEGRATION_BRANCH");
39
+ if (process.env.AIDLC_FLEET_PROJECT_ROOT && resolve(process.env.AIDLC_FLEET_PROJECT_ROOT) !== root) throw new Error("AIDLC_FLEET_PROJECT_ROOT does not match the loop root; refusing to run");
40
+ if (process.env.AIDLC_FLEET_WORKER_IDS && process.env.AIDLC_FLEET_WORKER_IDS.split(",").map((value) => value.trim()).filter(Boolean).join(",") !== Object.keys(workers).join(",")) throw new Error("AIDLC_FLEET_WORKER_IDS does not match the configured loop workers");
41
+ if (process.env.AIDLC_FLEET_WORKER_PROVIDERS && process.env.AIDLC_FLEET_WORKER_PROVIDERS !== JSON.stringify(workers)) throw new Error("AIDLC_FLEET_WORKER_PROVIDERS does not match the configured loop workers");
42
+ const env = { ...process.env, AIDLC_FLEET_PROJECT_ROOT: root, AIDLC_FLEET_WORKER_IDS: Object.keys(workers).join(","), AIDLC_FLEET_WORKER_PROVIDERS: JSON.stringify(workers), AIDLC_FLEET_AGENT: agent };
43
+ const remoteState = join(root, ".agents/aidlc/scripts/remote-state.mjs");
44
+ if (!existsSync(remoteState)) throw new Error(`Remote lifecycle CLI is missing: ${remoteState}`);
45
+ const callTool = ({ url: serviceUrl, token: serviceToken, name, args }) => callRemoteTool({ url: serviceUrl, token: serviceToken }, name, args);
46
+ const repository = createFleetRemoteRepository({ callTool, url, token, workspace, root });
47
+ if (action === "status") {
48
+ let snapshot = null;
49
+ try { snapshot = await repository.readTask({ taskId }); } catch (error) { if (!String(error?.message ?? error).includes("task not found")) throw error; }
50
+ const checkpoint = await repository.readGateCheckpoint({ taskId });
51
+ console.log(JSON.stringify({ taskId, authority: "fleet", task: snapshot?.task ?? null, nextAction: snapshot?.nextAction ?? null, checkpoint }, null, 2));
52
+ } else {
53
+ if (action === "resume") {
54
+ const recordPath = join(root, ".agents/data/tasks", taskId, "loop-intake.json");
55
+ if (!existsSync(recordPath)) throw new Error("Loop record missing; use start");
56
+ const record = JSON.parse(readFileSync(recordPath, "utf8"));
57
+ if (record.taskId !== taskId || record.authority !== "fleet") throw new Error("Loop record authority mismatch; use the recorded authority and task");
58
+ }
59
+ await callRemoteTool({ url, token }, "fleet_get", { workspace });
60
+ await readIntegrationCommit({ projectRoot: root, integrationBranch, gitRemote: env.AIDLC_FLEET_GIT_REMOTE ?? "origin" });
61
+ if (action === "start") {
62
+ const requirement = await readRequirement();
63
+ const readTask = async (id) => { try { return (await callRemoteTool({ url, token }, "task_get", { workspace, taskId: id, clientProjectRoot: root })).task; } catch { return null; } };
64
+ const current = await callRemoteTool({ url, token }, "state_get", { workspace });
65
+ const actionable = Object.values(current?.state?.tasks ?? {}).filter((task) => task.id !== taskId && task.status === "active" && !task.handoff);
66
+ if (actionable.length) throw new Error(`Task create is blocked while other work is actionable: ${actionable.map((task) => task.id).join(", ")}`);
67
+ await prepareLoopIntake({ root, taskId, requirement, authority: "fleet", readTask, createTask: async ({ taskId: id, title }) => {
68
+ const claim = await repository.claimLead({ taskId: id, leaseMs: 60000 });
69
+ try {
70
+ execFileSync(process.execPath, [remoteState, "task", "create", id, "--title", title], { cwd: root, encoding: "utf8", env: { ...env, AIDLC_FLEET_LEAD_EPOCH: String(claim.epoch), AIDLC_FLEET_LEAD_OWNER_TOKEN: repository.leadOwnerToken() } });
71
+ } finally { await repository.releaseLead({ taskId: id, epoch: claim.epoch }); }
72
+ } });
73
+ }
74
+ await runFleetCli(["supervisor", taskId], { callTool, env, out: (line) => console.log(line) });
75
+ }
76
+ } else {
77
+ requireLocalAuthority();
78
+ // The factory contract guarantees that the dispatcher is actually wired into
79
+ // the supervisor, not merely present in the package. No host fallback.
80
+ if (typeof localSupervisor.createIsolatedLocalSupervisor !== "function") throw new Error("Isolated local worker dispatcher is not integrated; loop execution is unavailable");
81
+ const supervisor = await localSupervisor.createIsolatedLocalSupervisor({ root, taskId, agent, workers: config.loop.agents.workers });
82
+ if (action === "status") {
83
+ console.log(JSON.stringify(await supervisor.status(), null, 2));
84
+ } else if (action === "start") {
85
+ const requirement = await readRequirement();
86
+ await prepareLoopIntake({ root, taskId, requirement, authority: "local", readTask: async (id) => loadTask(root, id), createTask: async ({ taskId: id, title }) => { execFileSync(process.execPath, [join(root, ".agents/aidlc/scripts/state.mjs"), "task", "create", id, "--title", title, "--root", root], { stdio: ["ignore", "pipe", "pipe"] }); } });
87
+ console.log(JSON.stringify(await supervisor.start(), null, 2));
88
+ } else console.log(JSON.stringify(await supervisor.resume(), null, 2));
89
+ }
90
+ } catch (error) { console.error(`loop: ${error.message}`); process.exitCode = 1; }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // This host-only terminal is deliberately not available to lead/worker containers.
3
+ import { createHash } from "node:crypto";
4
+ import { execFileSync } from "node:child_process";
5
+ import { lstatSync } from "node:fs";
6
+ import { join, resolve } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { stdin, stdout } from "node:process";
9
+ import { acquireStateLock, approveAndAdvance, checkGate, loadState, saveState } from "./lib/store.mjs";
10
+ import { formatGateView } from "./lib/runtime.mjs";
11
+ import { requireLocalAuthority } from "./lib/authority.mjs";
12
+ import { leadArtifactDigest } from "./lib/loop-lead-artifacts.mjs";
13
+ import { readLocalGateCheckpoint, writeLocalGateReceipt } from "./lib/loop-local-gate-receipt.mjs";
14
+
15
+ const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
16
+ function snapshot(root, taskId, state) {
17
+ const task = state.tasks[taskId];
18
+ if (!task || task.status !== "blocked_on_user") throw new Error("Task is not waiting at a human gate");
19
+ const pending = join(root, ".agents", "data", "tasks", taskId, ".lead-artifact-journal.json");
20
+ try { lstatSync(pending); throw new Error("Pending artifact journal requires supervised recovery before review"); }
21
+ catch (error) { if (error.code !== "ENOENT") throw error; }
22
+ const checks = checkGate(root, state, taskId, task.gate);
23
+ if (checks.some((entry) => entry.level === "ERROR")) throw new Error("Gate checks failed; repair before approval");
24
+ const artifacts = Object.fromEntries(["intent", "design", "workplan"].map((name) => [name, leadArtifactDigest({ root, taskId, task, name })]));
25
+ let commit = null;
26
+ if (task.gate === "G2_codereview") {
27
+ commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", timeout: 30000 }).trim();
28
+ const status = execFileSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", timeout: 30000 });
29
+ if (status.split("\0").filter(Boolean).some((entry) => !entry.slice(3).startsWith(".agents/data/"))) throw new Error("G2 review requires a clean integration checkout outside workflow data");
30
+ }
31
+ const checkpoint = readLocalGateCheckpoint(root, task);
32
+ return { taskId, phase: task.phase, gate: task.gate, taskHash: digest(task), checkpointHash: digest(checkpoint), artifacts, commit, view: formatGateView(task, checks, "plain") };
33
+ }
34
+ async function main() {
35
+ if (!stdin.isTTY || !stdout.isTTY) throw new Error("Gate approval requires the trusted host terminal");
36
+ const [taskId, ...args] = process.argv.slice(2);
37
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId ?? "") || (args.length && (args.length !== 2 || args[0] !== "--root" || !args[1]))) throw new Error("Usage: loop-gate.mjs <task-id> [--root <path>]");
38
+ const root = resolve(args[1] ?? "."); requireLocalAuthority();
39
+ let release = acquireStateLock(root), review;
40
+ try { review = snapshot(root, taskId, loadState(root)); } finally { release(); }
41
+ console.log(review.view.trimEnd());
42
+ console.log(`Review identity: ${JSON.stringify({ taskId, phase: review.phase, gate: review.gate, taskHash: review.taskHash, artifacts: review.artifacts, integrationCommit: review.commit })}`);
43
+ const rl = createInterface({ input: stdin, output: stdout });
44
+ let answer; try { answer = await rl.question("Type approve to approve this exact review: "); } finally { rl.close(); }
45
+ if (answer.trim() !== "approve") throw new Error("Review was not approved");
46
+ release = acquireStateLock(root);
47
+ try {
48
+ const state = loadState(root), fresh = snapshot(root, taskId, state);
49
+ if (digest({ ...fresh, view: undefined }) !== digest({ ...review, view: undefined })) throw new Error("Review changed while waiting for approval; inspect the new review");
50
+ const checkpoint = readLocalGateCheckpoint(root, state.tasks[taskId]);
51
+ const reviewHash = digest({ ...review, view: undefined });
52
+ const outcome = approveAndAdvance(root, state, taskId, review.gate, `Trusted host terminal: approve (${reviewHash})`);
53
+ saveState(root, state);
54
+ writeLocalGateReceipt(root, checkpoint, outcome.task, reviewHash);
55
+ console.log(`Approved ${review.gate}; next action: ${JSON.stringify(outcome.nextAction)}`);
56
+ } finally { release(); }
57
+ }
58
+ main().catch((error) => { console.error(`loop-gate: ${error.message}`); process.exitCode = 1; });
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from "node:path";
3
+ import { loopStatus, runLoop } from "./lib/loop-runtime.mjs";
4
+ import { LOOP_PROVIDERS, runAgentTurn } from "./lib/loop-drivers.mjs";
5
+ import { requireLocalAuthority } from "./lib/authority.mjs";
6
+
7
+ function parse(argv) {
8
+ const [command, taskId, ...tail] = argv;
9
+ const providers = LOOP_PROVIDERS.join("|");
10
+ if (!["run", "resume", "status"].includes(command) || !taskId) throw new Error(`Usage: loop.mjs run <task-id> --agent ${providers} [--max-turns N] [--turn-timeout-ms N] [--max-stall-turns N] [--root PATH] | resume <task-id> [--max-turns N] [--turn-timeout-ms N] [--max-stall-turns N] [--root PATH] | status <task-id> [--root PATH]`);
11
+ const options = {};
12
+ const supported = new Set(["--root", "--agent", "--max-turns", "--turn-timeout-ms", "--max-stall-turns"]);
13
+ for (let i = 0; i < tail.length; i += 2) {
14
+ const name = tail[i], value = tail[i + 1];
15
+ if (!supported.has(name) || !value || value.startsWith("--") || options[name] !== undefined) throw new Error(`Invalid option: ${name}`);
16
+ options[name] = value;
17
+ }
18
+ if (command === "status" && Object.keys(options).some((key) => key !== "--root")) throw new Error("status only accepts --root");
19
+ if (command === "run" && !LOOP_PROVIDERS.includes(options["--agent"])) throw new Error(`run requires --agent ${LOOP_PROVIDERS.join("|")}`);
20
+ const limits = {};
21
+ if (options["--max-turns"] !== undefined) limits.maxTurns = Number(options["--max-turns"]);
22
+ if (options["--turn-timeout-ms"] !== undefined) limits.turnTimeoutMs = Number(options["--turn-timeout-ms"]);
23
+ if (options["--max-stall-turns"] !== undefined) limits.maxStallTurns = Number(options["--max-stall-turns"]);
24
+ return { command, taskId, root: resolve(options["--root"] ?? process.cwd()), agent: options["--agent"], limits };
25
+ }
26
+
27
+ try {
28
+ requireLocalAuthority();
29
+ const options = parse(process.argv.slice(2));
30
+ const result = options.command === "status" ? loopStatus(options.root, options.taskId) : await runLoop({ root: options.root, taskId: options.taskId, agent: options.agent, mode: options.command, limits: options.limits, driver: runAgentTurn, preflight: true, onEvent: (event) => { if (event?.type === "diagnostic") console.error(event.message); if (event?.type === "resume" || event?.type === "context_changed") console.error(JSON.stringify(event)); } });
31
+ console.log(JSON.stringify({ ok: true, result }, null, 2));
32
+ if (result.status && !["complete", "gate"].includes(result.status)) process.exitCode = 2;
33
+ } catch (error) {
34
+ console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
35
+ process.exitCode = 1;
36
+ }