@felixdotgo/aidlc-kit 0.2.0 → 0.3.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 (109) hide show
  1. package/DEVELOPMENT.md +13 -26
  2. package/README.md +35 -39
  3. package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
  4. package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
  5. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  6. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  7. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +21 -5
  8. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +52 -78
  9. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +88 -14
  10. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +20 -5
  12. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +10 -1
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +2 -1
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +3 -2
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +9 -3
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +11 -3
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +78 -17
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +42 -10
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +66 -9
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +12 -6
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +14 -2
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  28. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +11 -0
  29. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  31. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +87 -4
  32. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  33. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  34. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  35. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  36. package/dist/assets/ui/changes.js +83 -0
  37. package/dist/assets/ui/common.js +664 -0
  38. package/dist/assets/ui/dashboard.css +281 -59
  39. package/dist/assets/ui/dashboard.js +77 -0
  40. package/dist/assets/ui/detail.js +159 -0
  41. package/dist/assets/ui/live-log.js +332 -0
  42. package/dist/services/aidlc-coordination/package.json +1 -1
  43. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +11 -0
  44. package/dist/src/cli/argv.js +10 -4
  45. package/dist/src/cli/commands.js +52 -22
  46. package/dist/src/cli/loop-launcher.d.ts +47 -0
  47. package/dist/src/cli/loop-launcher.js +322 -0
  48. package/dist/src/cli.js +3 -0
  49. package/dist/src/installer.js +4 -2
  50. package/dist/src/model/records.d.ts +4 -0
  51. package/dist/src/state/validate.js +5 -0
  52. package/dist/src/task-ui.d.ts +31 -52
  53. package/dist/src/task-ui.js +25 -1562
  54. package/dist/src/ui/assets.d.ts +14 -0
  55. package/dist/src/ui/assets.js +45 -0
  56. package/dist/src/ui/changes.d.ts +83 -0
  57. package/dist/src/ui/changes.js +498 -0
  58. package/dist/src/ui/containers.d.ts +71 -0
  59. package/dist/src/ui/containers.js +214 -0
  60. package/dist/src/ui/daemon.d.ts +75 -0
  61. package/dist/src/ui/daemon.js +232 -0
  62. package/dist/src/ui/data.d.ts +42 -0
  63. package/dist/src/ui/data.js +89 -0
  64. package/dist/src/ui/docs.d.ts +13 -0
  65. package/dist/src/ui/docs.js +39 -0
  66. package/dist/src/ui/files.d.ts +3 -0
  67. package/dist/src/ui/files.js +20 -0
  68. package/dist/src/ui/http.d.ts +14 -0
  69. package/dist/src/ui/http.js +80 -0
  70. package/dist/src/ui/live.d.ts +5 -0
  71. package/dist/src/ui/live.js +43 -0
  72. package/dist/src/ui/markdown.d.ts +2 -0
  73. package/dist/src/ui/markdown.js +127 -0
  74. package/dist/src/ui/options.d.ts +48 -0
  75. package/dist/src/ui/options.js +81 -0
  76. package/dist/src/ui/security.d.ts +34 -0
  77. package/dist/src/ui/security.js +38 -0
  78. package/dist/src/ui/server.d.ts +39 -0
  79. package/dist/src/ui/server.js +347 -0
  80. package/dist/src/ui/sse.d.ts +14 -0
  81. package/dist/src/ui/sse.js +281 -0
  82. package/dist/src/ui/views/board.d.ts +7 -0
  83. package/dist/src/ui/views/board.js +131 -0
  84. package/dist/src/ui/views/changes.d.ts +19 -0
  85. package/dist/src/ui/views/changes.js +154 -0
  86. package/dist/src/ui/views/components.d.ts +56 -0
  87. package/dist/src/ui/views/components.js +113 -0
  88. package/dist/src/ui/views/docs.d.ts +15 -0
  89. package/dist/src/ui/views/docs.js +64 -0
  90. package/dist/src/ui/views/layout.d.ts +44 -0
  91. package/dist/src/ui/views/layout.js +233 -0
  92. package/dist/src/ui/views/task.d.ts +25 -0
  93. package/dist/src/ui/views/task.js +320 -0
  94. package/dist/src/upgrade.js +5 -2
  95. package/dist/src/workflow.d.ts +4 -0
  96. package/dist/src/workflow.js +33 -1
  97. package/docs/README.md +48 -33
  98. package/docs/architecture.md +101 -38
  99. package/docs/command-reference.md +269 -93
  100. package/docs/configuration.md +62 -42
  101. package/docs/coordination-service.md +85 -36
  102. package/docs/design-decisions.md +35 -31
  103. package/docs/development.md +111 -61
  104. package/docs/loop-local-run.md +135 -402
  105. package/docs/loop-runbook.md +84 -105
  106. package/docs/operating-workflow.md +55 -42
  107. package/docs/testing-and-release.md +70 -51
  108. package/docs/troubleshooting.md +165 -69
  109. package/package.json +1 -1
@@ -9,24 +9,67 @@ import { fileURLToPath } from "node:url";
9
9
  import { doctor, doctorData, status, statusData } from "../installer.js";
10
10
  import { applyMcpSetup, planMcpSetup } from "../mcp-setup.js";
11
11
  import { loadProjectConfig, resolveProfiles } from "../profiles.js";
12
- import { openBrowser, readUiTokenFile, startUiServer } from "../task-ui.js";
13
12
  import { assertKnownOptions, DOCTOR_FLAGS, flags, MCP_FLAGS, rootArg, STATUS_FLAGS, UI_FLAGS, value } from "./argv.js";
13
+ import { loopDown, loopEnv, loopOverview, loopStop, loopUp, serveDashboard } from "./loop-launcher.js";
14
14
  import { confirmGeneral, mcpSetupSelections } from "./prompt.js";
15
+ /** `loop leases [--task-id ID] [--root PATH] [--json] [--clear-stale]` and
16
+ * `loop migrate-data [--root PATH] [--json] [--dry-run]`: forwarded to
17
+ * loop-entry.mjs as `<action> [task-id] <root> [switches]`. */
18
+ const loopDiagnosticArgs = (action, args) => {
19
+ const allowedSwitches = action === "leases" ? ["--json", "--clear-stale"] : ["--json", "--dry-run"];
20
+ const allowedValues = action === "leases" ? ["--task-id", "--root"] : ["--root"];
21
+ const parsed = new Map();
22
+ const switches = new Set();
23
+ for (let index = 0; index < args.length; index += 1) {
24
+ const key = args[index] ?? "";
25
+ if (allowedSwitches.includes(key)) {
26
+ if (switches.has(key))
27
+ throw new Error("Invalid or duplicate loop option");
28
+ switches.add(key);
29
+ continue;
30
+ }
31
+ const entry = args[index + 1];
32
+ if (!allowedValues.includes(key) || !entry || entry.startsWith("--") || parsed.has(key))
33
+ throw new Error("Invalid or duplicate loop option");
34
+ parsed.set(key, entry);
35
+ index += 1;
36
+ }
37
+ const root = resolve(parsed.get("--root") ?? ".");
38
+ return { root, argv: action === "leases" ? ["leases", parsed.get("--task-id") ?? "", root, ...switches] : ["migrate-data", root, ...switches] };
39
+ };
15
40
  export const runLoopCommand = async (args) => {
16
41
  const action = args[0] ?? "";
17
- if (!["start", "status", "resume", "intake", "worker-image"].includes(action))
18
- throw new Error("Expected loop start, status, intake, or worker-image");
42
+ if (!["up", "down", "stop", "env", "start", "status", "resume", "intake", "worker-image", "leases", "migrate-data"].includes(action))
43
+ throw new Error("Expected loop up, down, stop, env, start, status, intake, leases, migrate-data, or worker-image");
44
+ if (action === "up")
45
+ return loopUp(args.slice(1));
46
+ if (action === "down")
47
+ return loopDown(args.slice(1));
48
+ if (action === "stop")
49
+ return loopStop(args.slice(1));
50
+ if (action === "env")
51
+ return loopEnv(args.slice(1));
52
+ if (action === "status" && !args.includes("--task-id"))
53
+ return loopOverview(args.slice(1));
54
+ if (action === "leases" || action === "migrate-data") {
55
+ const { root, argv } = loopDiagnosticArgs(action, args.slice(1));
56
+ const child = spawn(process.execPath, [join(root, ".agents", "aidlc", "scripts", "loop-entry.mjs"), ...argv], { stdio: "inherit" });
57
+ const code = await new Promise((resolveExit, rejectExit) => { child.once("error", rejectExit); child.once("exit", (exitCode) => resolveExit(exitCode)); });
58
+ if (code !== 0)
59
+ process.exitCode = code ?? 1;
60
+ return;
61
+ }
19
62
  const parsed = new Map();
20
63
  for (let index = 1; index < args.length; index += 2) {
21
64
  const key = args[index];
22
65
  const entry = args[index + 1];
23
- if (key === undefined || !["--task-id", "--title", "--root", "--mode", "--provider", "--tag"].includes(key) || !entry || entry.startsWith("--") || parsed.has(key))
66
+ if (key === undefined || !["--task-id", "--title", "--root", "--mode", "--provider", "--tag", "--loop-env"].includes(key) || !entry || entry.startsWith("--") || parsed.has(key))
24
67
  throw new Error("Invalid or duplicate loop option");
25
68
  parsed.set(key, entry);
26
69
  }
27
70
  const root = resolve(parsed.get("--root") ?? ".");
28
71
  const script = join(root, ".agents", "aidlc", "scripts", "loop-entry.mjs");
29
- const child = spawn(process.execPath, [script, action, parsed.get("--task-id") ?? "", root, parsed.get("--mode") ?? "", parsed.get("--provider") ?? "", parsed.get("--tag") ?? "", parsed.get("--title") ?? ""], { stdio: "inherit" });
72
+ const child = spawn(process.execPath, [script, action, parsed.get("--task-id") ?? "", root, parsed.get("--mode") ?? "", parsed.get("--provider") ?? "", parsed.get("--tag") ?? "", parsed.get("--title") ?? ""], { stdio: "inherit", env: { ...process.env, ...(parsed.has("--loop-env") ? { AIDLC_ENV_FILE: resolve(parsed.get("--loop-env") ?? "") } : {}) } });
30
73
  // Stay alive while the child runs so its own staged shutdown (stop after the
31
74
  // current turn → cancel the turn → exit) stays reachable. A terminal Ctrl-C
32
75
  // already reaches the child through the process group, so SIGINT is only
@@ -52,26 +95,13 @@ export const runLoopCommand = async (args) => {
52
95
  };
53
96
  export const runUi = async (args) => {
54
97
  assertKnownOptions(args, UI_FLAGS);
55
- const root = resolve(rootArg(args));
98
+ const reviewerTokenFile = value(args, "--reviewer-token-file");
56
99
  const port = Number(value(args, "--port") ?? 4173);
57
100
  if (!Number.isInteger(port) || port < 1 || port > 65535)
58
101
  throw new Error("--port must be an integer between 1 and 65535");
59
- const host = value(args, "--host") ?? "127.0.0.1";
60
- const tokenFile = value(args, "--token-file");
61
- const reviewerFile = value(args, "--reviewer-token-file");
62
- const token = tokenFile ? readUiTokenFile(root, tokenFile, { create: true }) : undefined;
63
- const reviewerToken = reviewerFile ? readUiTokenFile(root, reviewerFile, { create: false }) : undefined;
64
- const { server, port: boundPort, token: sessionToken } = await startUiServer({ root, host, port, ...(token ? { token } : {}), ...(reviewerToken ? { reviewerToken } : {}) });
65
- const base = `http://${host.includes(":") ? `[${host}]` : host}:${boundPort}`;
66
- const loginUrl = `${base}/#token=${encodeURIComponent(sessionToken)}`;
67
- console.log(`Loop (AI-DLC UI) listening on ${base} (Ctrl-C to stop)`);
68
- console.log(`Open (signs this browser in for loop actions and gate approval): ${loginUrl}`);
69
- if (flags(args).has("--open"))
70
- openBrowser(loginUrl);
71
- await new Promise((resolve) => {
72
- const close = () => { server.close(() => resolve()); };
73
- process.once("SIGINT", close);
74
- process.once("SIGTERM", close);
102
+ await serveDashboard({
103
+ root: resolve(rootArg(args)), host: value(args, "--host") ?? "127.0.0.1", port, open: flags(args).has("--open"),
104
+ ...(reviewerTokenFile ? { reviewerTokenFile } : {}),
75
105
  });
76
106
  };
77
107
  const mcpSetupOptions = async (args) => {
@@ -0,0 +1,47 @@
1
+ export interface LauncherArgs {
2
+ root: string;
3
+ values: Map<string, string>;
4
+ switches: Set<string>;
5
+ }
6
+ /** Parses `--flag value` / `--switch` options against the per-action allowlist. */
7
+ export declare const parseLauncherArgs: (args: string[], allowed: string[]) => LauncherArgs;
8
+ export declare const installerCliPath: (root: string) => string;
9
+ export declare const recordInstallerCli: (root: string) => void;
10
+ export declare const uiStatePath: (root: string) => string;
11
+ interface UiRecord {
12
+ pid: number;
13
+ url: string;
14
+ startedAt: string;
15
+ instance?: string;
16
+ }
17
+ /** The recorded dashboard, if any. `running` only means the pid exists; use
18
+ * `dashboardReachable` before trusting or signalling it (the pid may have been reused). */
19
+ export declare const readUiRecord: (root: string) => (UiRecord & {
20
+ running: boolean;
21
+ }) | undefined;
22
+ /** True when the recorded dashboard itself still answers: the URL serves the same random
23
+ * instance id the record holds (another project's dashboard on that port does not match). */
24
+ export declare const dashboardReachable: (record: {
25
+ url: string;
26
+ instance?: string;
27
+ }) => Promise<boolean>;
28
+ export interface ServeOptions {
29
+ root: string;
30
+ host: string;
31
+ port: number;
32
+ open: boolean;
33
+ reviewerTokenFile?: string;
34
+ record?: boolean;
35
+ }
36
+ export declare const isLoopbackHost: (host: string) => boolean;
37
+ /** Printed when the dashboard listens on a non-loopback address: it has no authentication. */
38
+ export declare const EXPOSED_WARNING = "Warning: the dashboard has no authentication; expose it only on a trusted network.";
39
+ /** Serves the dashboard until SIGINT/SIGTERM. `record` writes ui.json so `loop down`/`up` can find it. */
40
+ export declare const serveDashboard: (options: ServeOptions) => Promise<void>;
41
+ export declare const loopUp: (args: string[]) => Promise<void>;
42
+ export declare const loopStop: (args: string[]) => Promise<void>;
43
+ export declare const loopDown: (args: string[]) => Promise<void>;
44
+ export declare const loopEnv: (args: string[]) => void;
45
+ /** `loop status` without a task id: daemon, dashboard, and environment at a glance. */
46
+ export declare const loopOverview: (args: string[]) => Promise<void>;
47
+ export {};
@@ -0,0 +1,322 @@
1
+ /**
2
+ * One-command local loop: `loop up | down | stop | env | status` (no task id).
3
+ *
4
+ * `up` checks the configuration and environment (`loop-entry.mjs env`), starts the
5
+ * daemon detached, then serves the dashboard in the foreground. Ctrl-C closes only
6
+ * the dashboard; the daemon keeps running until `loop down` / `loop stop`. The
7
+ * shipped `.agents/aidlc/scripts/loop.sh|loop.cmd` wrappers forward to these commands.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { randomUUID } from "node:crypto";
11
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { packageVersion } from "../workflow.js";
15
+ import { daemonHealth, daemonStatus, ensureDaemon, pidAlive, stopDaemon } from "../ui/daemon.js";
16
+ import { daemonLogPath, loopDataDir, loopEntryPath, openBrowser, readCredentialFile, scrubbedEnv } from "../ui/options.js";
17
+ import { startUiServer } from "../ui/server.js";
18
+ const SWITCHES = new Set(["--open", "--no-ui", "--now", "--json"]);
19
+ const VALUES = new Set(["--root", "--loop-env", "--port", "--host", "--reviewer-token-file"]);
20
+ /** Parses `--flag value` / `--switch` options against the per-action allowlist. */
21
+ export const parseLauncherArgs = (args, allowed) => {
22
+ const values = new Map();
23
+ const switches = new Set();
24
+ for (let index = 0; index < args.length; index += 1) {
25
+ const key = args[index] ?? "";
26
+ if (!allowed.includes(key))
27
+ throw new Error(`Unknown loop option: ${key}`);
28
+ if (SWITCHES.has(key)) {
29
+ switches.add(key);
30
+ continue;
31
+ }
32
+ const entry = args[index + 1];
33
+ if (!VALUES.has(key) || entry === undefined || entry.startsWith("--") || values.has(key))
34
+ throw new Error(`${key} requires a value`);
35
+ values.set(key, entry);
36
+ index += 1;
37
+ }
38
+ return { root: resolve(values.get("--root") ?? "."), values, switches };
39
+ };
40
+ /** `AIDLC_ENV_FILE` for children when `--loop-env` is given (resolved against the caller's cwd). */
41
+ const envOverlay = (parsed) => {
42
+ const file = parsed.values.get("--loop-env");
43
+ return file ? { AIDLC_ENV_FILE: resolve(file) } : {};
44
+ };
45
+ const readConfig = (root) => {
46
+ try {
47
+ return JSON.parse(readFileSync(join(root, ".agents", "config.json"), "utf8"));
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ };
53
+ /** Fails with actionable guidance unless the project is installed with local loop mode. */
54
+ const requireLocalLoop = (root) => {
55
+ if (!existsSync(loopEntryPath(root)))
56
+ throw new Error(`AI-DLC is not installed in ${root}; run: npx @felixdotgo/aidlc-kit init --loop-mode local`);
57
+ const mode = readConfig(root)?.loop?.mode ?? "off";
58
+ if (mode === "fleet")
59
+ throw new Error("loop up/down manage the local daemon; this project uses fleet mode (see docs/loop-runbook.md#fleet-multi-machine)");
60
+ if (mode !== "local")
61
+ throw new Error([
62
+ "Loop mode is off. Enable it in .agents/config.json:",
63
+ ' "loop": { "mode": "local", "agents": { "lead": "opencode", "workers": { "worker-a": "opencode" } } }',
64
+ "(providers: codex, claude, or opencode), or run: npx @felixdotgo/aidlc-kit init --loop-mode local",
65
+ ].join("\n"));
66
+ };
67
+ /** Runs `loop-entry.mjs env` for `root`; returns its exit status (output inherited or captured). */
68
+ const envReport = (root, overlay, options = {}) => {
69
+ const result = spawnSync(process.execPath, [loopEntryPath(root), "env", root, ...(options.json ? ["--json"] : [])], {
70
+ cwd: root, encoding: "utf8", env: scrubbedEnv(overlay), stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
71
+ });
72
+ if (result.error)
73
+ throw result.error;
74
+ return { status: result.status ?? 1, stdout: result.stdout ?? "" };
75
+ };
76
+ /* ---- Installing CLI record (`.agents/data/loop/cli.json`) ----
77
+ * `init`/`upgrade` record the CLI that wrote `.agents/`, so `loop.sh`/`loop.cmd` run that
78
+ * exact build even when it came from the npx cache or a local `.tgz` (npx caches those by
79
+ * spec, so `npx --no-install @felixdotgo/aidlc-kit` cannot find them). Machine-local data,
80
+ * next to daemon.lock and daemon.log; never part of the managed manifest. */
81
+ export const installerCliPath = (root) => join(loopDataDir(root), "cli.json");
82
+ export const recordInstallerCli = (root) => {
83
+ try {
84
+ mkdirSync(loopDataDir(root), { recursive: true });
85
+ const cli = fileURLToPath(new URL("../cli.js", import.meta.url));
86
+ writeFileSync(installerCliPath(root), `${JSON.stringify({ cli, version: packageVersion(), recordedAt: new Date().toISOString() }, null, 2)}\n`);
87
+ }
88
+ catch (error) {
89
+ console.warn(`warning: could not record the aidlc-kit CLI for the loop launcher (${error instanceof Error ? error.message : String(error)}); install the package in the project to use loop.sh`);
90
+ }
91
+ };
92
+ /* ---- Dashboard process record (`.agents/data/loop/ui.json`) ---- */
93
+ export const uiStatePath = (root) => join(loopDataDir(root), "ui.json");
94
+ /** The recorded dashboard, if any. `running` only means the pid exists; use
95
+ * `dashboardReachable` before trusting or signalling it (the pid may have been reused). */
96
+ export const readUiRecord = (root) => {
97
+ try {
98
+ const record = JSON.parse(readFileSync(uiStatePath(root), "utf8"));
99
+ if (typeof record.pid !== "number" || typeof record.url !== "string")
100
+ return undefined;
101
+ return { pid: record.pid, url: record.url, startedAt: String(record.startedAt ?? ""), ...(typeof record.instance === "string" ? { instance: record.instance } : {}), running: pidAlive(record.pid) };
102
+ }
103
+ catch {
104
+ return undefined;
105
+ }
106
+ };
107
+ /** True when the recorded dashboard itself still answers: the URL serves the same random
108
+ * instance id the record holds (another project's dashboard on that port does not match). */
109
+ export const dashboardReachable = async (record) => {
110
+ if (!record.instance)
111
+ return false;
112
+ try {
113
+ const response = await fetch(`${record.url}/api/instance`, { signal: AbortSignal.timeout(3000) });
114
+ const body = await response.json();
115
+ return response.ok && body.instance === record.instance;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ };
121
+ /** A URL this machine can reach for a wildcard bind (0.0.0.0 / ::), else the bound host. */
122
+ const recordHost = (host) => host === "0.0.0.0" ? "127.0.0.1" : host === "::" ? "::1" : host;
123
+ const validPort = (raw) => {
124
+ const port = Number(raw ?? 4173);
125
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
126
+ throw new Error("--port must be an integer between 1 and 65535");
127
+ return port;
128
+ };
129
+ /** Loopback binds are reachable only from this machine; anything else exposes the dashboard. */
130
+ const LOOPBACK_BINDS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
131
+ export const isLoopbackHost = (host) => LOOPBACK_BINDS.has(host.toLowerCase());
132
+ /** Printed when the dashboard listens on a non-loopback address: it has no authentication. */
133
+ export const EXPOSED_WARNING = "Warning: the dashboard has no authentication; expose it only on a trusted network.";
134
+ /** Serves the dashboard until SIGINT/SIGTERM. `record` writes ui.json so `loop down`/`up` can find it. */
135
+ export const serveDashboard = async (options) => {
136
+ const { root, host } = options;
137
+ const port = validPort(String(options.port));
138
+ const reviewerToken = options.reviewerTokenFile ? readCredentialFile(root, options.reviewerTokenFile, { create: false }) : undefined;
139
+ const instance = randomUUID();
140
+ const { server, port: boundPort } = await startUiServer({ root, host, port, instance, ...(reviewerToken ? { reviewerToken } : {}) });
141
+ const base = `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${boundPort}`;
142
+ console.log(`Loop (AI-DLC UI) listening on ${base} (Ctrl-C to stop)`);
143
+ console.log(`Open: ${base}`);
144
+ if (!isLoopbackHost(host))
145
+ console.log(EXPOSED_WARNING);
146
+ if (options.record) {
147
+ mkdirSync(loopDataDir(root), { recursive: true });
148
+ const local = recordHost(host);
149
+ const url = `http://${local.includes(":") ? `[${local}]` : local}:${boundPort}`;
150
+ writeFileSync(uiStatePath(root), `${JSON.stringify({ pid: process.pid, url, instance, startedAt: new Date().toISOString() })}\n`);
151
+ }
152
+ if (options.open)
153
+ openBrowser(base);
154
+ await new Promise((resolveClose) => {
155
+ const close = () => { server.close(() => resolveClose()); server.closeAllConnections?.(); };
156
+ process.once("SIGINT", close);
157
+ process.once("SIGTERM", close);
158
+ });
159
+ if (options.record && readUiRecord(root)?.pid === process.pid)
160
+ rmSync(uiStatePath(root), { force: true });
161
+ };
162
+ const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
163
+ const logTail = (root, lines = 15) => {
164
+ try {
165
+ return readFileSync(daemonLogPath(root), "utf8").trimEnd().split("\n").slice(-lines).join("\n");
166
+ }
167
+ catch {
168
+ return "";
169
+ }
170
+ };
171
+ /** Env file of this start: `--loop-env`, `AIDLC_ENV_FILE`, or the default. */
172
+ const envFilePath = (root, overlay) => resolve(root, overlay.AIDLC_ENV_FILE ?? process.env.AIDLC_ENV_FILE ?? join(".agents", "loop.env"));
173
+ /** Starts the daemon unless it runs; confirms it survived start-up. Returns false on failure. */
174
+ const startDaemon = async (root, overlay) => {
175
+ const current = daemonStatus(root);
176
+ if (current.running) {
177
+ console.log(`Loop daemon already running (pid ${current.pid}${current.startedAt ? `, started ${current.startedAt}` : ""})`);
178
+ try {
179
+ if (current.startedAt && statSync(envFilePath(root, overlay)).mtimeMs > Date.parse(current.startedAt)) {
180
+ console.log(" note: the env file changed after the daemon started; restart it to apply: loop down, then loop up");
181
+ }
182
+ }
183
+ catch { /* no env file */ }
184
+ return true;
185
+ }
186
+ const started = ensureDaemon(root, overlay);
187
+ // The daemon writes its lock right after the environment check; an early exit means start-up failed.
188
+ for (let waited = 0; waited < 5000; waited += 250) {
189
+ await sleep(250);
190
+ if (daemonStatus(root).running) {
191
+ console.log(`Started the loop daemon (pid ${daemonStatus(root).pid}); log: ${daemonLogPath(root)}`);
192
+ return true;
193
+ }
194
+ if (started.pid !== null && !pidAlive(started.pid))
195
+ break;
196
+ }
197
+ if (daemonStatus(root).running)
198
+ return true;
199
+ if (started.pid !== null && pidAlive(started.pid)) {
200
+ console.log(`Loop daemon is starting (pid ${started.pid}); log: ${daemonLogPath(root)}`);
201
+ return true;
202
+ }
203
+ console.error(`The loop daemon exited during start-up. Last lines of ${daemonLogPath(root)}:`);
204
+ console.error(logTail(root) || " (empty)");
205
+ return false;
206
+ };
207
+ export const loopUp = async (args) => {
208
+ const parsed = parseLauncherArgs(args, ["--root", "--loop-env", "--port", "--host", "--open", "--no-ui", "--reviewer-token-file"]);
209
+ const { root } = parsed;
210
+ requireLocalLoop(root);
211
+ const port = validPort(parsed.values.get("--port"));
212
+ const overlay = envOverlay(parsed);
213
+ if (envReport(root, overlay).status !== 0) {
214
+ console.error("\nFix the errors above, then run loop up again.");
215
+ process.exitCode = 1;
216
+ return;
217
+ }
218
+ console.log("");
219
+ if (!await startDaemon(root, overlay)) {
220
+ process.exitCode = 1;
221
+ return;
222
+ }
223
+ if (parsed.switches.has("--no-ui")) {
224
+ console.log("Dashboard not started (--no-ui). Start it with: aidlc-kit ui");
225
+ return;
226
+ }
227
+ const ui = readUiRecord(root);
228
+ if (ui?.running && ui.pid !== process.pid && await dashboardReachable(ui)) {
229
+ console.log(`Dashboard already running at ${ui.url} (pid ${ui.pid}); open it there, or stop it with loop down.`);
230
+ return;
231
+ }
232
+ // A daemon the dashboard starts later (New task after loop stop) loads the same env file.
233
+ if (overlay.AIDLC_ENV_FILE)
234
+ process.env.AIDLC_ENV_FILE = overlay.AIDLC_ENV_FILE;
235
+ await serveDashboard({
236
+ root, host: parsed.values.get("--host") ?? "127.0.0.1", port, open: parsed.switches.has("--open"), record: true,
237
+ ...(parsed.values.has("--reviewer-token-file") ? { reviewerTokenFile: parsed.values.get("--reviewer-token-file") } : {}),
238
+ });
239
+ console.log("Dashboard stopped. The loop daemon keeps running; stop it with: loop down");
240
+ };
241
+ const reportStop = (result, now) => {
242
+ if (!result.wasRunning) {
243
+ console.log("Loop daemon is not running");
244
+ return;
245
+ }
246
+ if (result.stopped) {
247
+ console.log(`Stopped the loop daemon (pid ${result.pid})`);
248
+ return;
249
+ }
250
+ console.log(now
251
+ ? `Loop daemon (pid ${result.pid}) is still shutting down; check again with: loop status`
252
+ : `Loop daemon (pid ${result.pid}) will stop after its current agent turn. To cancel that turn now: loop stop --now`);
253
+ };
254
+ export const loopStop = async (args) => {
255
+ const parsed = parseLauncherArgs(args, ["--root", "--now"]);
256
+ const now = parsed.switches.has("--now");
257
+ reportStop(await stopDaemon(parsed.root, { now }), now);
258
+ };
259
+ export const loopDown = async (args) => {
260
+ const parsed = parseLauncherArgs(args, ["--root", "--now"]);
261
+ const { root } = parsed;
262
+ const ui = readUiRecord(root);
263
+ // Signal only a dashboard that still answers at its recorded URL: after a crash or reboot
264
+ // the recorded pid may belong to an unrelated process.
265
+ if (ui?.running && ui.pid !== process.pid && await dashboardReachable(ui)) {
266
+ try {
267
+ process.kill(ui.pid, "SIGTERM");
268
+ }
269
+ catch { /* already gone */ }
270
+ for (let waited = 0; waited < 5000 && pidAlive(ui.pid); waited += 200)
271
+ await sleep(200);
272
+ console.log(pidAlive(ui.pid) ? `Dashboard (pid ${ui.pid}) did not stop yet` : `Stopped the dashboard (pid ${ui.pid})`);
273
+ if (!pidAlive(ui.pid))
274
+ rmSync(uiStatePath(root), { force: true });
275
+ }
276
+ else if (ui?.running && ui.pid !== process.pid) {
277
+ // Not provably ours (busy, or the pid was reused): never signal it; keep the record.
278
+ console.log(`Dashboard record (pid ${ui.pid}, ${ui.url}) did not answer as this project's dashboard; not stopping it. Close it with Ctrl-C, or delete ${uiStatePath(root)} if it is gone.`);
279
+ }
280
+ else if (ui)
281
+ rmSync(uiStatePath(root), { force: true });
282
+ const now = parsed.switches.has("--now");
283
+ reportStop(await stopDaemon(root, { now }), now);
284
+ };
285
+ export const loopEnv = (args) => {
286
+ const parsed = parseLauncherArgs(args, ["--root", "--loop-env", "--json"]);
287
+ if (!existsSync(loopEntryPath(parsed.root)))
288
+ throw new Error(`AI-DLC is not installed in ${parsed.root}`);
289
+ const { status } = envReport(parsed.root, envOverlay(parsed), { json: parsed.switches.has("--json") });
290
+ if (status !== 0)
291
+ process.exitCode = status;
292
+ };
293
+ /** `loop status` without a task id: daemon, dashboard, and environment at a glance. */
294
+ export const loopOverview = async (args) => {
295
+ const parsed = parseLauncherArgs(args, ["--root", "--loop-env", "--json"]);
296
+ const { root } = parsed;
297
+ if (!existsSync(loopEntryPath(root)))
298
+ throw new Error(`AI-DLC is not installed in ${root}`);
299
+ const daemon = daemonStatus(root);
300
+ const errors = daemon.running ? daemonHealth(root) : [];
301
+ const record = readUiRecord(root);
302
+ const ui = record ? { ...record, running: record.running && await dashboardReachable(record) } : undefined;
303
+ if (parsed.switches.has("--json")) {
304
+ const env = envReport(root, envOverlay(parsed), { json: true, capture: true });
305
+ let parsedEnv = null;
306
+ try {
307
+ parsedEnv = JSON.parse(env.stdout);
308
+ }
309
+ catch {
310
+ parsedEnv = null;
311
+ }
312
+ console.log(JSON.stringify({ daemon: { ...daemon, errors }, ui: ui ?? null, env: parsedEnv }, null, 2));
313
+ return;
314
+ }
315
+ console.log(daemon.running ? `Daemon : running · pid ${daemon.pid}${daemon.startedAt ? ` · started ${daemon.startedAt}` : ""}` : "Daemon : stopped (start it with: loop up)");
316
+ for (const error of errors)
317
+ console.log(` error : ${error.taskId ? `${error.taskId}: ` : "poll: "}${error.message}`);
318
+ console.log(ui?.running ? `Dashboard : ${ui.url} · pid ${ui.pid}` : "Dashboard : not running (loop up starts it)");
319
+ console.log(`Log : ${daemonLogPath(root)}`);
320
+ console.log("");
321
+ envReport(root, envOverlay(parsed));
322
+ };
package/dist/src/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { resolvedAdapters } from "./adapters.js";
3
3
  import { assertKnownOptions, flags, INIT_FLAGS, rootArg, UNINSTALL_FLAGS, UPGRADE_FLAGS, usage } from "./cli/argv.js";
4
4
  import { runDoctor, runLoopCommand, runMcp, runProfile, runStatus, runUi } from "./cli/commands.js";
5
+ import { recordInstallerCli } from "./cli/loop-launcher.js";
5
6
  import { initOptions } from "./cli/options.js";
6
7
  import { chooseLoop, confirmGeneral, interactive } from "./cli/prompt.js";
7
8
  import { applyPlan, detectedAgents, formatPlan, planInit, planUninstall, selectAgents } from "./installer.js";
@@ -53,6 +54,7 @@ const runInit = async (args) => {
53
54
  applyPlan(options.root, plan);
54
55
  console.log("Installed local AI-DLC workflow assets.");
55
56
  }
57
+ recordInstallerCli(options.root);
56
58
  };
57
59
  const runUninstall = async (args) => {
58
60
  assertKnownOptions(args, UNINSTALL_FLAGS);
@@ -83,6 +85,7 @@ const runUpgrade = async (args) => {
83
85
  throw new Error("Upgrade confirmation did not match the target version");
84
86
  const result = applyUpgrade(root, plan);
85
87
  console.log(`Upgrade complete. Backup: ${result.backup}; changed: ${result.changed}`);
88
+ recordInstallerCli(root);
86
89
  };
87
90
  const runHelp = () => console.log(usage);
88
91
  const runVersion = () => console.log(packageVersion());
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { adapters, findAdapter, resolvedAdapters } from "./adapters.js";
4
4
  import { loadProjectConfig } from "./profiles.js";
@@ -7,7 +7,7 @@ import { planProjectLayoutMigration } from "./project-migration.js";
7
7
  import { legacyManagedMarker, managedBlock, removeManagedBlock } from "./managed-content.js";
8
8
  import { hasOwnedManifest, managedContentHash, planLoopSelection, planManagedWrites, readManifest, readManifestFile, splitInventoryKey } from "./managed-planning.js";
9
9
  import { resolveProjectPathWithoutSymlinks } from "./project-path.js";
10
- import { contentHash, coreWorkflowFiles, initialProjectFiles, manifestSpec, packageVersion } from "./workflow.js";
10
+ import { contentHash, coreWorkflowFiles, initialProjectFiles, isExecutableAsset, manifestSpec, packageVersion } from "./workflow.js";
11
11
  /** Manifest reading lives in the managed-planning core; this export path stays stable for existing importers. */
12
12
  export { readManifest } from "./managed-planning.js";
13
13
  const shortHash = (content) => contentHash(content).slice(0, 12);
@@ -44,6 +44,8 @@ export const applyPlan = (root, plan) => {
44
44
  const target = safeTarget(root, write.path);
45
45
  mkdirSync(dirname(target), { recursive: true });
46
46
  writeFileSync(target, write.content, "utf8");
47
+ if (isExecutableAsset(write.path) && process.platform !== "win32")
48
+ chmodSync(target, 0o755);
47
49
  }
48
50
  };
49
51
  export const formatPlan = (plan) => plan.map((item) => `${item.action.padEnd(8)} ${item.path} (${item.reason}; sha=${shortHash(item.contentEncoding === "base64" ? Buffer.from(item.content, "base64") : item.content)})`).join("\n");
@@ -76,6 +76,10 @@ export interface TaskState {
76
76
  };
77
77
  predecessorTaskId?: string;
78
78
  successorTaskId?: string;
79
+ /** Repository HEAD when the task was created (lowercase hex SHA, 7-64 chars). */
80
+ baseCommit?: string;
81
+ /** Repository HEAD when the task reached done (lowercase hex SHA, 7-64 chars). */
82
+ finalCommit?: string;
79
83
  createdAt: string;
80
84
  updatedAt: string;
81
85
  }
@@ -3,6 +3,8 @@ import { assertTaskArtifactPath, idPattern, iso, text } from "./guards.js";
3
3
  import { readyTaskItems } from "./items.js";
4
4
  import { archiveRecordPath } from "./paths.js";
5
5
  const taskStatuses = ["active", "blocked_on_user", "paused", "done", "closed", "superseded"];
6
+ const commitPattern = /^[0-9a-f]{7,64}$/;
7
+ const commitFields = ["baseCommit", "finalCommit"];
6
8
  export const validateLesson = (taskId, lesson) => {
7
9
  if (!lesson)
8
10
  throw new Error(`Invalid lesson: ${taskId}`);
@@ -72,6 +74,9 @@ export const validateTask = (id, task) => {
72
74
  throw new Error(`Invalid task successor: ${id}`);
73
75
  if (task.predecessorTaskId !== undefined && !text(task.predecessorTaskId))
74
76
  throw new Error(`Invalid task predecessor: ${id}`);
77
+ for (const field of commitFields)
78
+ if (task[field] !== undefined && !(typeof task[field] === "string" && commitPattern.test(task[field])))
79
+ throw new Error(`Invalid task ${field}: ${id}`);
75
80
  for (const path of Object.values(task.artifacts))
76
81
  if (path)
77
82
  assertTaskArtifactPath(id, path);
@@ -1,54 +1,33 @@
1
- import { type IncomingMessage, type ServerResponse, type Server } from "node:http";
2
- export type UiAuthorityMode = "local" | "fleet";
3
- export interface UiOptions {
4
- root: string;
5
- host: string;
6
- port: number;
7
- token?: string;
8
- /** Loop authority the dashboard reads and acts on; defaults to `.agents/config.json` `loop.mode` (local unless fleet). */
9
- mode?: UiAuthorityMode;
10
- /** Fleet reviewer credential held server-side; passed only to the approve child process. */
11
- reviewerToken?: string;
12
- }
13
- export declare const scrubbedEnv: (extra?: Record<string, string>) => NodeJS.ProcessEnv;
14
1
  /**
15
- * Fixed token from a file (`--token-file`, `--reviewer-token-file`) so the browser stays signed in across
16
- * UI restarts. The file must live outside the project (agents work inside it), be a regular non-symlink
17
- * file, and be private to the owner (mode 600/400). With `create`, a missing file is created with a
18
- * random token. The token is never passed to the daemon, lead, or workers.
2
+ * Local web UI for the AI-DLC loop and task status.
3
+ *
4
+ * Read-only over canonical local state (`.agents/data/state/aidlc-state.json`).
5
+ * Loop start/resume are thin facades that spawn the installed `loop-entry.mjs`
6
+ * exactly as `aidlc-kit loop` does; gate approval is a thin facade that spawns
7
+ * the same atomic `state.mjs gate approve` command, and gate feedback spawns the
8
+ * audited `state.mjs task status … --status active --mode audited` cancel (the
9
+ * task stays at its gate; the loop lead revises and re-presents it). This module
10
+ * never mutates lifecycle state itself and never bypasses the fail-closed gate
11
+ * channel.
12
+ *
13
+ * Built on `node:http` only; Bulma CSS, the dashboard theme layer, and the
14
+ * client scripts are vendored static assets (no CDN, no runtime npm dependency).
15
+ * The dashboard has no session credential (it runs on trusted local/LAN
16
+ * networks); mutating actions (loop start/resume, gate feedback, gate approve)
17
+ * are protected by request-shape guards instead: a pinned Host header, a
18
+ * matching Origin, Sec-Fetch-Site, and a custom request header that forces a
19
+ * CORS preflight (see `./ui/security.ts`). Fleet gate approval additionally
20
+ * needs the reviewer credential (`AIDLC_FLEET_REVIEWER_TOKEN` or
21
+ * `--reviewer-token-file`), which reaches only the approve child process.
22
+ *
23
+ * The implementation lives in `./ui/` (see `./ui/server.ts` for the route
24
+ * table); this module is the stable public entry point.
19
25
  */
20
- export declare const readUiTokenFile: (root: string, path: string, { create }: {
21
- create: boolean;
22
- }) => string;
23
- /** Best-effort browser launch for `aidlc-kit ui --open`; failures are ignored (the URL is printed anyway). */
24
- export declare const openBrowser: (url: string) => void;
25
- /** The configured loop authority: `fleet` only when `loop.mode` says so; everything else reads local state. */
26
- export declare const resolveUiMode: (root: string) => UiAuthorityMode;
27
- /** DNS-rebinding and CSRF guard: the Host header must name the bound address (any loopback name when
28
- * bound to loopback; a wildcard bind cannot be pinned and relies on the token and Origin check), and a
29
- * state-changing request that carries an Origin must be same-origin. */
30
- export declare const requestAllowed: (options: Pick<UiOptions, "host">, req: IncomingMessage) => {
31
- ok: true;
32
- } | {
33
- ok: false;
34
- error: string;
35
- };
36
- export interface DaemonStatus {
37
- running: boolean;
38
- pid: number | null;
39
- startedAt: string | null;
40
- }
41
- /** Reads the singleton loop daemon lock written by `loop-daemon.mjs`. */
42
- export declare const daemonStatus: (root: string) => DaemonStatus;
43
- /** Renders a small subset of Markdown to escaped HTML. Exported for tests. */
44
- export declare const markdownToHtml: (markdown: string) => string;
45
- /** On a fresh connection the SSE stream replays at most this many lines. */
46
- export declare const SSE_REPLAY_LINES = 200;
47
- /** Builds the request handler for the UI server (exported for unit tests). */
48
- export declare const createUiHandler: (options: UiOptions) => (req: IncomingMessage, res: ServerResponse) => void;
49
- /** Starts the UI HTTP server and resolves once it is listening. */
50
- export declare const startUiServer: (options: UiOptions) => Promise<{
51
- server: Server;
52
- port: number;
53
- token: string;
54
- }>;
26
+ export { openBrowser, readCredentialFile, resolveUiMode, scrubbedEnv, type UiAuthorityMode, type UiOptions } from "./ui/options.js";
27
+ export { requestAllowed } from "./ui/security.js";
28
+ export { daemonHealth, daemonStatus, stopDaemon, type DaemonError, type DaemonStatus, type StopResult } from "./ui/daemon.js";
29
+ export { markdownToHtml } from "./ui/markdown.js";
30
+ export { NOTIFY_PAGE } from "./ui/views/layout.js";
31
+ export { DONE_COLUMN_LIMIT } from "./ui/views/board.js";
32
+ export { SSE_REPLAY_LINES } from "./ui/sse.js";
33
+ export { createUiHandler, startUiServer } from "./ui/server.js";