@nodii/dev 0.6.39 → 0.6.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -417,6 +417,9 @@ without ending their run, `/tmp/cc-socks/<pid>.sock` sockets whose process is
417
417
  gone, and watched worktrees whose HEAD has commits absent from its configured
418
418
  remote branch. Each observation is posted to the watched run by the machine
419
419
  coordinator; `process_dead` transitions it to explicit `orphan` immediately.
420
+ A sweep leaves a dead lane runtime alone while its lane supervisor still lives
421
+ (the supervisor reports the exit itself) and skips it once the supervisor has
422
+ reported.
420
423
  Run launchers and wrappers persist one owner-only record per process under
421
424
  `~/.config/nodii-dev/watchdog/runs/`; the plist itself contains only absolute
422
425
  command/config paths, its schedule, and log paths, while OAuth credentials
@@ -916,6 +919,22 @@ The launched process uses the already-provisioned lowercase identity (for
916
919
  example `~/.config/nodii-dev/agents/lane-ah-12.json`), never the coordinator's
917
920
  Dev identity.
918
921
 
922
+ A launched or resumed runtime runs under a detached lane supervisor (an
923
+ internal `nodii-dev __lane-supervisor` process), which sees its exit code and
924
+ reports it to Dev as the lane's identity (NODII-DEV-CLI-1). It first reads the
925
+ lane's status. A lane whose done check is recorded (its `dev_check` step done,
926
+ or the lane `on_dev`, `non_issue` or `stopped`) has finished. On it, exit 0 with a clean worktree ends the run
927
+ `done`, exit 0 with uncommitted or untracked files ends it `handed_off` and names
928
+ them, and any other exit ends it `failed`. None of these counts as a death. A
929
+ process that stops before the done check is recorded is unfinished, whatever
930
+ its exit code (a lane that exits 0 after Gather included). It is reported as a
931
+ `process-exit` with its code, fingerprint (`lane-unfinished-exit-0`,
932
+ `lane-exit-N` or `lane-signal-S`) and redacted log tail. Dev orphans that run,
933
+ so `lane resume` relaunches it with `recoveryOf`. A lane status Dev can't serve
934
+ counts as unfinished. Every report carries `exit_code`, `worktree` and
935
+ `lane_status` evidence. `process.pid` still holds the runtime's own PID, and the
936
+ supervisor appends its report line to `process.log`.
937
+
919
938
  A lane's Figma scope is held by published key only (MW8-20): `--figma
920
939
  FILE_KEY` for a whole file, or `--figma FILE_KEY=COMPONENT_SET_KEY[,KEY]` for
921
940
  its component sets. A node id is refused. `--route /app/...` (repeatable)
@@ -938,7 +957,13 @@ messages. Every CLI call from a launched worktree checks the current watched
938
957
  run's acknowledgement version. A pending change is printed and blocks the
939
958
  call until `lane gather --delta` serves the current rules and acknowledges the
940
959
  newest displayed version. `NODII_DEV_RUN_ID` makes this check per subagent run,
941
- so one child cannot acknowledge for its siblings.
960
+ so one child cannot acknowledge for its siblings. Help and version
961
+ (`--help`, `-h`, `help`, `--version`) skip the check, so a blocked lane can
962
+ still read how to unblock itself. With the agent picker, `lane gather --delta`
963
+ first prints a Trigger shortlist and acknowledges nothing; its `--json` names
964
+ the open amendment and carries the record command as `data.next.command`
965
+ (`--picker agent --shortlist ID --documents CODE[,CODE] --delta`). Running that
966
+ records the pick and acknowledges the amendment (AGENT-HARNESS-14).
942
967
 
943
968
  Gather also persists the lane's bounded Freeze: the objective and done check,
944
969
  strict repository/Figma boundaries, the current pinned clauses, and the lane's
package/dist/cli.js CHANGED
@@ -95,6 +95,7 @@ import {
95
95
  showWaitingResults,
96
96
  superviseRun,
97
97
  } from "./runs.js";
98
+ import { superviseLane } from "./lane-supervisor.js";
98
99
  import { awsCli, superviseRemoteRun } from "./remote.js";
99
100
 
100
101
  const HELP = `nodii-dev ${VERSION}
@@ -199,7 +200,9 @@ Usage: nodii-dev <command> [options] [--json]
199
200
  lane gather [<key>] --picker agent [--shortlist ID --documents CODE[,CODE]]
200
201
  # the lane agent picks instead of local Gemma: alone it prints the
201
202
  # recorded trigger shortlist; with --shortlist/--documents it records
202
- # the pick (picker: agent) and the Clause map
203
+ # the pick (picker: agent) and the Clause map; --json carries that
204
+ # record command as data.next.command, and with --delta it also
205
+ # acknowledges a pending amendment (help stays readable meanwhile)
203
206
  lane gather show [<key>] [--gather ID] # the lane's Clause map (the Freeze references it, not its clauses)
204
207
  run [--kind build|test_run|subagent|deploy] [--name TEXT] [--artifact PATH] -- <command> [args...]
205
208
  # watched command: registers with NODII_DEV_RUN_ID,
@@ -496,12 +499,17 @@ export async function figmaFileAdd(value, flags, { request = devRequest } = {})
496
499
  }
497
500
  }
498
501
 
502
+ // Help and version only print text, so they stay readable while an amendment
503
+ // blocks the lane: a blocked lane needs them to unblock itself (AGENT-HARNESS-14).
504
+ const readsHelp = (argv) => argv[0] === "help" || argv.some((token) => ["--help", "-h"].includes(token)) || (argv.length === 1 && ["--version", "-v"].includes(argv[0]));
505
+
499
506
  export async function main(argv = process.argv.slice(2)) {
500
- // Amendment acknowledgement is a lane-wide backstop, including otherwise
501
- // local commands such as help and version.
507
+ // A lane's own exit reporter runs after its runtime ends (NODII-DEV-CLI-1).
508
+ if (argv[0] === "__lane-supervisor") return superviseLane();
509
+ // Amendment acknowledgement is a lane-wide backstop for every other command.
502
510
  // A nudge hook is fail-open and writes only its own JSON: it cannot run a
503
511
  // Delta gather, and a waiting-results banner would corrupt its output.
504
- if (argv[0] !== "__run-supervisor" && !(argv[0] === "hook" && argv[1]?.startsWith("nudge-"))) {
512
+ if (argv[0] !== "__run-supervisor" && !readsHelp(argv) && !(argv[0] === "hook" && argv[1]?.startsWith("nudge-"))) {
505
513
  await amendmentGuard(argv);
506
514
  await showWaitingResults({ acknowledge: argv[0] !== "hook" });
507
515
  }
package/dist/core.js CHANGED
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
- export const VERSION = "0.6.39";
7
+ export const VERSION = "0.6.41";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
package/dist/freeze.js CHANGED
@@ -21,6 +21,7 @@ export function shouldReground(argv) {
21
21
  "-h",
22
22
  "help",
23
23
  "hook",
24
+ "__lane-supervisor",
24
25
  "login",
25
26
  "logout",
26
27
  ].includes(command),
@@ -109,6 +110,7 @@ export const GATHER_LOOKUP_IDS = ["figma-guidelines"];
109
110
  /** Build the stored contract; Delta Gather keeps lane-authored sections. */
110
111
  export function freezeFromGather({
111
112
  gather,
113
+ ask,
112
114
  boundaries,
113
115
  issue,
114
116
  laneNotes,
@@ -131,7 +133,8 @@ export function freezeFromGather({
131
133
  );
132
134
  return {
133
135
  objective: {
134
- ask: gather.objective,
136
+ // Dev's Lane contract names the ask; without one, Gather's objective.
137
+ ask: ask ?? gather.objective,
135
138
  issue: issue === undefined ? (current?.objective?.issue ?? null) : issue,
136
139
  laneNotes:
137
140
  laneNotes === undefined
@@ -0,0 +1,222 @@
1
+ import { execFile as execFileCallback, spawn as nodeSpawn } from "node:child_process";
2
+ import { open } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { CliError, devRequest, encode } from "./core.js";
6
+ import { redactRunText } from "./runs.js";
7
+ import { saveWatchedRun, watchdogRunDir } from "./watchdog.js";
8
+
9
+ /**
10
+ * NODII-DEV-CLI-1: a launched or resumed lane's runtime is the child of this
11
+ * supervisor, so its exit code is known. The supervisor reports it to Dev
12
+ * with the worktree's clean/dirty state and the lane's status. A lane whose
13
+ * done check is recorded (its dev_check step done, or the lane on_dev,
14
+ * non_issue or stopped) has finished: its run
15
+ * ends, never as a death. A process that stops before that is unfinished,
16
+ * whatever its exit code: it is reported as a process exit, which Dev orphans
17
+ * so `lane resume` relaunches it (hub-58 scope add). Before this, the
18
+ * watchdog could only see a vanished PID and counted every exit as
19
+ * process_dead.
20
+ *
21
+ * Launcher protocol (IPC): launch -> spawned {pid} -> start {prompt} ->
22
+ * started; or abort, when the launcher could not record the runtime's PID.
23
+ */
24
+
25
+ const execFile = promisify(execFileCallback);
26
+ const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
27
+ const LOG_TAIL_BYTES = 8 * 1024;
28
+ // Dev's terminal lane statuses: verified on dev (lane verify), or closed.
29
+ const FINISHED_LANE = new Set(["on_dev", "non_issue", "stopped"]);
30
+
31
+ /** Each worktree's uncommitted and untracked paths; null when git cannot read it. */
32
+ export async function worktreeState(worktrees, { execute = execFile } = {}) {
33
+ const dirty = [];
34
+ const unreadable = [];
35
+ for (const worktree of worktrees) {
36
+ try {
37
+ const { stdout } = await execute("git", ["-C", worktree, "status", "--porcelain"], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
38
+ for (const line of stdout.split("\n").filter(Boolean)) dirty.push(`${worktree}: ${line.trim()}`);
39
+ } catch {
40
+ unreadable.push(worktree);
41
+ }
42
+ }
43
+ return { state: unreadable.length ? "unknown" : dirty.length ? "dirty" : "clean", dirty, unreadable };
44
+ }
45
+
46
+ async function readTail(path, bytes = LOG_TAIL_BYTES) {
47
+ if (!path) return "";
48
+ const handle = await open(path, "r");
49
+ try {
50
+ const { size } = await handle.stat();
51
+ const length = Math.min(size, bytes);
52
+ const buffer = Buffer.alloc(length);
53
+ await handle.read(buffer, 0, length, size - length);
54
+ return buffer.toString("utf8");
55
+ } finally {
56
+ await handle.close();
57
+ }
58
+ }
59
+
60
+ const describeWorktree = ({ state, dirty, unreadable }) => [
61
+ ...dirty.slice(0, 50),
62
+ ...(dirty.length > 50 ? [`... ${dirty.length - 50} more`] : []),
63
+ ...unreadable.map((worktree) => `${worktree}: git status failed`),
64
+ ].join("\n") || state;
65
+
66
+ async function send(request, path, body, attempts = 3) {
67
+ for (let attempt = 1; ; attempt++) {
68
+ try {
69
+ return await request("POST", path, body);
70
+ } catch (error) {
71
+ // Dev's answer is final; only a transport failure is retried.
72
+ if (error instanceof CliError || attempt >= attempts) throw error;
73
+ await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The lane's Dev status and whether its done check is recorded: a dev_check
80
+ * step done, or a terminal lane. Unreadable reads as unfinished.
81
+ */
82
+ async function laneProgress(request, laneKey) {
83
+ try {
84
+ const lane = (await request("GET", `/api/v1/lanes/${encode(laneKey)}`)).data;
85
+ const devChecked = (lane?.steps ?? []).some((step) => step.stepKey === "dev_check" && step.state === "done");
86
+ return { status: lane?.status ?? null, finished: devChecked || FINISHED_LANE.has(lane?.status) };
87
+ } catch {
88
+ return { status: null, finished: false };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Report one lane process exit. On a finished lane, exit 0 with a clean
94
+ * worktree ends the run done, exit 0 with uncommitted work hands it off
95
+ * naming that work, and any other exit ends it failed. On an unfinished lane
96
+ * every exit, exit 0 included, is a process exit: Dev orphans the run and
97
+ * routes it for recovery (`lane resume`).
98
+ */
99
+ export async function reportLaneExit({ runId, laneKey, exitCode, signal, worktrees, logPath }, {
100
+ request = devRequest,
101
+ inspect = worktreeState,
102
+ tail = readTail,
103
+ markTerminal = markWatchedTerminal,
104
+ } = {}) {
105
+ const worktree = await inspect(worktrees);
106
+ const { status, finished } = await laneProgress(request, laneKey);
107
+ const clean = exitCode === 0 && !signal;
108
+ const exitRef = signal ? `signal ${signal}` : String(exitCode ?? -1);
109
+ const exited = signal ? `was ended by ${signal}` : `exited with code ${exitCode ?? -1}`;
110
+ const evidence = [
111
+ { kind: "exit_code", ref: exitRef },
112
+ { kind: "worktree", ref: worktree.state, note: describeWorktree(worktree) },
113
+ { kind: "lane_status", ref: status ?? "unknown" },
114
+ ];
115
+ let outcome;
116
+ let body;
117
+ if (finished && clean) {
118
+ outcome = worktree.state === "clean" ? "done" : "handed_off";
119
+ body = outcome === "done"
120
+ ? { status: "done", evidence }
121
+ : { status: "handed_off", handoff: `The lane process exited 0 and left its worktree ${worktree.state}:\n${describeWorktree(worktree)}`, evidence };
122
+ } else if (finished) {
123
+ // A finished lane has nothing to resume, so a failed exit is not a death.
124
+ outcome = "failed";
125
+ body = { status: "failed", reason: `The lane process ${exited} after lane ${laneKey} reached ${status}.`, evidence };
126
+ } else {
127
+ outcome = "process_exit";
128
+ const logTail = redactRunText(await tail(logPath).catch(() => "")).trim();
129
+ body = {
130
+ reason: `The lane process ${exited} before lane ${laneKey}'s done check was recorded (lane ${status ?? "status unknown"}), so the run is unfinished; its worktree is ${worktree.state}.`,
131
+ deathFingerprint: signal ? `lane-signal-${signal}` : clean ? "lane-unfinished-exit-0" : `lane-exit-${exitCode ?? -1}`,
132
+ exitCode: Number.isInteger(exitCode) && exitCode >= 0 && exitCode <= 255 ? exitCode : -1,
133
+ ...(logTail ? { logTail: logTail.slice(-50_000) } : {}),
134
+ };
135
+ }
136
+ try {
137
+ await send(request, `/api/v1/runs/${encode(runId)}/${outcome === "process_exit" ? "process-exit" : "end"}`, body);
138
+ } catch (error) {
139
+ // The agent may have ended its own run already; that is not a failure.
140
+ if (error?.code !== "RUN_ALREADY_TERMINAL") throw error;
141
+ outcome = "already_terminal";
142
+ }
143
+ await markTerminal(runId).catch(() => {});
144
+ return { outcome, exitCode: exitCode ?? null, signal: signal ?? null, worktree: worktree.state, laneStatus: status };
145
+ }
146
+
147
+ /** The sweep skips a run whose exit was reported (it would otherwise report process_dead). */
148
+ export async function markWatchedTerminal(runId, { home } = {}) {
149
+ const registryPath = join(watchdogRunDir(home), `${runId}.json`);
150
+ const handle = await open(registryPath, "r");
151
+ let record;
152
+ try { record = JSON.parse(await handle.readFile("utf8")); }
153
+ finally { await handle.close(); }
154
+ if (record.terminalSeenAt || record.orphanReportedAt) return;
155
+ await saveWatchedRun({ ...record, terminalSeenAt: new Date().toISOString(), registryPath });
156
+ }
157
+
158
+ /** The supervisor process: spawn the runtime, hand its PID back, report its exit. */
159
+ export async function superviseLane({
160
+ channel = process,
161
+ spawn = nodeSpawn,
162
+ report = reportLaneExit,
163
+ exit = (code) => process.exit(code),
164
+ } = {}) {
165
+ if (typeof channel.send !== "function")
166
+ throw new CliError("USAGE", "__lane-supervisor is started by lane launch and lane resume only.", 2);
167
+ const launch = await new Promise((resolve) => channel.once("message", resolve));
168
+ if (launch?.type !== "launch") return exit(2);
169
+ const { invocation, runId, laneKey, worktrees, logPath } = launch;
170
+ // The launcher may already be gone; a reply it cannot read is dropped.
171
+ const tell = (message) => {
172
+ try { if (channel.connected !== false) channel.send(message); } catch {}
173
+ };
174
+ let child;
175
+ try {
176
+ child = spawn(invocation.command, invocation.args, { cwd: invocation.cwd, env: invocation.env, shell: false, stdio: ["pipe", "inherit", "inherit"] });
177
+ } catch (error) {
178
+ tell({ type: "error", message: error.message });
179
+ return exit(1);
180
+ }
181
+ let state = "spawning";
182
+ const closed = new Promise((resolve) => child.once("close", (exitCode, signal) => resolve({ exitCode, signal })));
183
+ child.once("error", (error) => {
184
+ if (state !== "spawning") return;
185
+ state = "failed";
186
+ tell({ type: "error", message: error.message });
187
+ });
188
+ child.once("spawn", () => {
189
+ if (state !== "spawning") return;
190
+ state = "waiting";
191
+ tell({ type: "spawned", pid: child.pid });
192
+ });
193
+ channel.on("message", (message) => {
194
+ if (message?.type === "start" && state === "waiting") {
195
+ state = "running";
196
+ child.stdin.end(message.prompt);
197
+ tell({ type: "started" });
198
+ } else if (message?.type === "abort" && state !== "running") {
199
+ state = "aborted";
200
+ child.kill("SIGTERM");
201
+ }
202
+ });
203
+ // A launcher that went away before start never handed over a prompt.
204
+ channel.once("disconnect", () => {
205
+ if (state === "waiting" || state === "spawning") {
206
+ state = "aborted";
207
+ child.kill("SIGTERM");
208
+ }
209
+ });
210
+ // A signal sent to the supervisor ends the runtime too, and is reported.
211
+ for (const name of SIGNALS) process.on(name, () => child.kill(name));
212
+ const { exitCode, signal } = await closed;
213
+ if (state !== "running") return exit(state === "aborted" ? 0 : 1);
214
+ try {
215
+ const result = await report({ runId, laneKey, exitCode, signal, worktrees, logPath });
216
+ process.stderr.write(`nodii-dev: lane process exit reported to Dev (${result.outcome}, exit ${signal ?? exitCode}, lane ${result.laneStatus ?? "status unknown"}, worktree ${result.worktree}).\n`);
217
+ return exit(0);
218
+ } catch (error) {
219
+ process.stderr.write(`nodii-dev: could not report the lane process exit to Dev (${error?.code ?? error?.message ?? error}); the watchdog reports it as process_dead.\n`);
220
+ return exit(1);
221
+ }
222
+ }
package/dist/lanes.js CHANGED
@@ -98,7 +98,31 @@ const agentFollowUp = (ref, shortlistId, flags) => [
98
98
  .filter((flag) => typeof flags[flag] === "string")
99
99
  .map((flag) => `--${flag} ${shellQuote(flags[flag])}`),
100
100
  ].join(" ");
101
+ // The record step, in --json too: an agent reads the payload, not the text
102
+ // (AGENT-HARNESS-14). With --delta it also acknowledges a pending amendment.
103
+ const agentNext = (ref, shortlistId, triggers, flags) => ({
104
+ command: agentFollowUp(ref, shortlistId, flags),
105
+ fill: "--documents: the codes of every document that applies, from triggers; forced ones are added for you.",
106
+ forced: triggers.filter((trigger) => trigger.forcedBy?.length).map((trigger) => trigger.code),
107
+ acknowledgesAmendment: Boolean(flags.delta),
108
+ });
109
+ // Dev's Lane contract (hub-58): objective, issue body, the coordinator's lane
110
+ // notes, done check and boundaries, printed before anything else.
111
+ const contractLines = (data) => (data.laneContract ? ["Lane contract", data.laneContract, ""] : []);
112
+ async function laneContract(id, request, options) {
113
+ try {
114
+ return (await request("GET", `/api/v1/lanes/${encode(id)}/contract`, undefined, options)).data?.text ?? null;
115
+ } catch (error) {
116
+ // A Dev from before the Lane contract route: say so, never hide it.
117
+ if (error?.code === "HTTP_404" || error?.code === "NOT_FOUND") return "(this Dev does not serve the Lane contract yet; read the lane with `nodii-dev lane show`)";
118
+ throw error;
119
+ }
120
+ }
121
+ // Gather never ends on a bare "finished" (hub-58): the next step is the task.
122
+ const nextStepText = (ref, doneCheck) =>
123
+ `Gather is finished. Next: Task. Work the objective until ${doneCheck} is met, then run \`nodii-dev lane land ${ref}\` (or the lane's finish command). Don't stop before that.`;
101
124
  const agentShortlistText = (ref, data, flags) => [
125
+ ...contractLines(data),
102
126
  `Trigger shortlist ${data.shortlistId} · ${JSON.stringify(data.objective)}`,
103
127
  "You are Gather's picker (picker: agent). Nothing is recorded yet.",
104
128
  ...data.triggers.map((trigger) => ` ${trigger.code}\t${trigger.title ?? ""}: ${trigger.sentence ?? ""}${trigger.forcedBy?.length ? ` [forced by ${trigger.forcedBy.join(", ")}]` : ""}`),
@@ -387,8 +411,10 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
387
411
  : await request("POST", "/api/v1/rules/shortlist", shortlistBody, { signal });
388
412
  const shortlistId = shortlist.data?.shortlistId;
389
413
  const triggers = shortlist.data?.triggers ?? [];
390
- if (byAgent && !byAgent.shortlist)
391
- return { payload: { data: { recorded: false, picker: "agent", shortlistId, objective, triggers } }, human: (p) => agentShortlistText(ref, p.data, flags) };
414
+ if (byAgent && !byAgent.shortlist) {
415
+ const contract = await laneContract(id, request, { signal });
416
+ return { payload: { data: { recorded: false, picker: "agent", shortlistId, objective, triggers, laneContract: contract, next: agentNext(ref, shortlistId, triggers, flags) } }, human: (p) => agentShortlistText(ref, p.data, flags) };
417
+ }
392
418
  // Only the local model pick takes the per-lane pick lock; an agent pick
393
419
  // records a choice already made (AGENT-HARNESS-19 + GATHER-NA).
394
420
  const pickInput = {
@@ -409,7 +435,8 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
409
435
  // No picker on this Mac: the agent picks from the same shortlist (hub-58).
410
436
  const fallback = agentFallbackReason(error);
411
437
  if (!fallback) throw error;
412
- return { payload: { data: { recorded: false, picker: "agent", fallback, shortlistId, objective, triggers } }, human: (p) => `picker: agent (${p.data.fallback})\n${agentShortlistText(ref, p.data, flags)}` };
438
+ const contract = await laneContract(id, request, { signal });
439
+ return { payload: { data: { recorded: false, picker: "agent", fallback, shortlistId, objective, triggers, laneContract: contract, next: agentNext(ref, shortlistId, triggers, flags) } }, human: (p) => `picker: agent (${p.data.fallback})\n${agentShortlistText(ref, p.data, flags)}` };
413
440
  }
414
441
  }
415
442
  const payload = await request("POST", `/api/v1/lanes/${encode(id)}/gather`, {
@@ -421,6 +448,7 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
421
448
  ...(sectionLimit !== undefined ? { sectionLimitPerDocument: sectionLimit } : {}),
422
449
  shortlistId,
423
450
  picker: byAgent ? "agent" : picked.model ?? PICKER_MODEL,
451
+ doneCheck,
424
452
  }, { signal });
425
453
  const figma = figmaScope.map((file) => ({
426
454
  fileKey: file.fileKey,
@@ -434,7 +462,19 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
434
462
  lane.launch?.narrowingRemainder && `Narrowing remainder: ${lane.launch.narrowingRemainder}`,
435
463
  ].filter(Boolean);
436
464
  const freezeRequest = (method, path, body, options = {}) => request(method, path, body, { ...options, signal });
437
- await storeFreeze(id, {
465
+ // Dev's Lane contract is what Gather printed; the Freeze stores it word for word.
466
+ const contract = payload.data?.contract;
467
+ const contractRepos = contract?.boundaries?.repos;
468
+ const fromContract = Boolean(contractRepos?.length && contractRepos.every((repository) => repository.repo && repository.branch && repository.worktree));
469
+ await storeFreeze(id, fromContract ? {
470
+ gather: payload.data,
471
+ ask: contract.objective.ask,
472
+ boundaries: contract.boundaries,
473
+ issue: contract.objective.issue,
474
+ laneNotes: contract.objective.laneNotes,
475
+ doneCheck: contract.objective.doneCheck ?? doneCheck,
476
+ lookups: lookup ? [lookup] : [],
477
+ } : {
438
478
  gather: payload.data,
439
479
  boundaries: { repos: repositories, ...(declaredFigma ? { figma } : {}) },
440
480
  issue,
@@ -453,7 +493,8 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
453
493
  // The pick's model and latency ride along in --json (morning check records them).
454
494
  const result = { ...payload, picker: { model: picked.model ?? null, latency: picked.latency ?? null, ...(picked.timing ? { timing: picked.timing, stats: picked.stats } : {}) } };
455
495
  const timingLine = picked.timing ? `\n${pickTimingLine(picked.timing, picked.stats)}` : "";
456
- return { payload: result, human: (p) => `${gatherText(p, objective, reasons)}${scopeNotice}${timingLine}` };
496
+ const doneText = contract?.objective?.doneCheck ?? doneCheck;
497
+ return { payload: result, human: (p) => `${gatherText(p, objective, reasons)}${scopeNotice}${timingLine}\n\n${nextStepText(ref, doneText)}` };
457
498
  } catch (error) {
458
499
  if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather's Dev requests exceeded their ${Math.round(deadline.ms / 1000)}-second deadline (the local pick runs on its own budget); nothing was retried.`, 1);
459
500
  throw error;
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import * as fsPromises from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { configPath } from "./core.js";
6
7
  import { recordWatchedRun } from "./watchdog.js";
7
8
 
@@ -141,12 +142,23 @@ export function recoveryStateRoot(path = configPath()) {
141
142
  return join(nodiiDir, "runs");
142
143
  }
143
144
 
144
- async function launchDetached({ invocation, prompt, runId, laneKey, devConfigPath }, {
145
+ // The supervisor is this package's own entrypoint (src/ in a checkout, dist/ when packed).
146
+ const SUPERVISOR_ENTRY = fileURLToPath(new URL("nodii-dev.js", import.meta.url));
147
+
148
+ /**
149
+ * Spawn the runtime under a detached lane supervisor (NODII-DEV-CLI-1): the
150
+ * supervisor is the runtime's parent, so it sees the exit code and reports it
151
+ * to Dev. The recorded PID stays the runtime's own; the prompt is sent only
152
+ * after that PID is saved and watched.
153
+ */
154
+ async function launchDetached({ invocation, prompt, runId, laneKey, devConfigPath, addDirs = [] }, {
145
155
  spawnImpl,
146
156
  fs,
147
157
  stateRoot,
148
158
  watch,
149
159
  terminate,
160
+ execPath = process.execPath,
161
+ supervisorEntry = SUPERVISOR_ENTRY,
150
162
  }) {
151
163
  absolutePath(stateRoot, "stateRoot");
152
164
  const runDir = join(stateRoot, runId);
@@ -162,63 +174,83 @@ async function launchDetached({ invocation, prompt, runId, laneKey, devConfigPat
162
174
  throw error;
163
175
  }
164
176
 
165
- let child;
177
+ let supervisor;
166
178
  try {
167
- child = spawnImpl(invocation.command, invocation.args, {
179
+ supervisor = spawnImpl(execPath, [supervisorEntry, "__lane-supervisor"], {
168
180
  cwd: invocation.cwd,
169
181
  env: invocation.env,
170
182
  shell: false,
171
183
  detached: true,
172
- stdio: ["pipe", log.fd, log.fd],
184
+ stdio: ["ignore", log.fd, log.fd, "ipc"],
173
185
  });
174
186
  } catch (error) {
175
187
  await closeQuietly(log);
176
188
  throw error;
177
189
  }
178
190
 
179
- return new Promise((resolveLaunch, rejectLaunch) => {
180
- let settled = false;
181
- child.once("error", (error) => {
182
- if (settled) return;
183
- settled = true;
184
- closeQuietly(log).finally(() => rejectLaunch(error));
185
- });
186
- child.once("spawn", () => {
187
- if (settled) return;
188
- settled = true;
189
- (async () => {
190
- if (!Number.isInteger(child.pid) || child.pid <= 0)
191
- throw new Error("runtime process spawned without a PID");
192
- await fs.writeFile(pidPath, `${child.pid}\n`, { mode: 0o600, flag: "w" });
193
- try {
194
- await watch({ runId, pid: child.pid, configPath: devConfigPath, worktree: invocation.cwd });
195
- } catch (error) {
196
- // This exact child has already been recorded in its private PID file.
197
- // Do not leave an untracked runtime alive when registry persistence fails.
198
- await terminate(child);
199
- throw error;
200
- }
201
- if (!child.stdin || typeof child.stdin.end !== "function")
202
- throw new Error("runtime process has no stdin pipe");
203
- child.stdin.end(prompt);
204
- await closeQuietly(log);
205
- child.unref();
206
- resolveLaunch({
207
- runId,
208
- laneKey,
209
- pid: child.pid,
210
- logPath,
211
- pidPath,
212
- command: invocation.command,
213
- args: invocation.args,
214
- cwd: invocation.cwd,
215
- });
216
- })().catch(async (error) => {
217
- await closeQuietly(log);
218
- rejectLaunch(error);
191
+ // One reply from the supervisor, or its early end as an error.
192
+ const reply = (type) => new Promise((resolveReply, rejectReply) => {
193
+ const onMessage = (message) => {
194
+ if (message?.type === "error") return settle(() => rejectReply(new Error(message.message)));
195
+ if (message?.type === type) settle(() => resolveReply(message));
196
+ };
197
+ const onExit = (code, signal) => settle(() => rejectReply(new Error(`lane supervisor exited before ${type} (${signal ?? code})`)));
198
+ const onError = (error) => settle(() => rejectReply(error));
199
+ const settle = (done) => {
200
+ supervisor.off("message", onMessage);
201
+ supervisor.off("exit", onExit);
202
+ supervisor.off("error", onError);
203
+ done();
204
+ };
205
+ supervisor.on("message", onMessage);
206
+ supervisor.once("exit", onExit);
207
+ supervisor.once("error", onError);
208
+ });
209
+
210
+ try {
211
+ await new Promise((resolveSpawn, rejectSpawn) => {
212
+ supervisor.once("error", rejectSpawn);
213
+ supervisor.once("spawn", () => {
214
+ supervisor.off("error", rejectSpawn);
215
+ resolveSpawn();
219
216
  });
220
217
  });
221
- });
218
+ const spawned = reply("spawned");
219
+ supervisor.send({ type: "launch", invocation, runId, laneKey, worktrees: [invocation.cwd, ...addDirs], logPath });
220
+ const { pid } = await spawned;
221
+ if (!Number.isInteger(pid) || pid <= 0)
222
+ throw new Error("runtime process spawned without a PID");
223
+ await fs.writeFile(pidPath, `${pid}\n`, { mode: 0o600, flag: "w" });
224
+ try {
225
+ await watch({ runId, pid, supervisorPid: supervisor.pid, configPath: devConfigPath, worktree: invocation.cwd });
226
+ } catch (error) {
227
+ // This exact runtime has already been recorded in its private PID file.
228
+ // Do not leave an untracked runtime alive when registry persistence fails.
229
+ try { supervisor.send({ type: "abort" }); } catch {}
230
+ await terminate(supervisor);
231
+ throw error;
232
+ }
233
+ const started = reply("started");
234
+ supervisor.send({ type: "start", prompt });
235
+ await started;
236
+ supervisor.disconnect();
237
+ await closeQuietly(log);
238
+ supervisor.unref();
239
+ return {
240
+ runId,
241
+ laneKey,
242
+ pid,
243
+ supervisorPid: supervisor.pid,
244
+ logPath,
245
+ pidPath,
246
+ command: invocation.command,
247
+ args: invocation.args,
248
+ cwd: invocation.cwd,
249
+ };
250
+ } catch (error) {
251
+ await closeQuietly(log);
252
+ throw error;
253
+ }
222
254
  }
223
255
 
224
256
  const terminateChild = async (child) => {
@@ -234,6 +266,7 @@ export async function launchRuntime(options, {
234
266
  env = process.env,
235
267
  watch = recordWatchedRun,
236
268
  terminate = terminateChild,
269
+ supervisorEntry,
237
270
  } = {}) {
238
271
  const launch = validateLaunch(options);
239
272
  const invocation = buildInvocation(launch, { env, home });
@@ -243,6 +276,7 @@ export async function launchRuntime(options, {
243
276
  stateRoot: stateRoot ?? recoveryStateRoot(launch.devConfigPath),
244
277
  watch,
245
278
  terminate,
279
+ ...(supervisorEntry ? { supervisorEntry } : {}),
246
280
  });
247
281
  }
248
282
 
@@ -260,17 +294,19 @@ export async function launchRecovery(payload, {
260
294
  env = process.env,
261
295
  watch = recordWatchedRun,
262
296
  terminate = terminateChild,
297
+ supervisorEntry,
263
298
  } = {}) {
264
299
  const data = dataFrom(payload);
265
- const { runId, laneKey } = validateData(data);
300
+ const { runId, laneKey, addDirs } = validateData(data);
266
301
  const devConfigPath = absolutePath(data?.launch?.devConfigPath, "launch.devConfigPath");
267
302
  const prompt = buildRecoveryPrompt(data);
268
303
  const invocation = buildRecoveryInvocation(data, { env, home });
269
- return launchDetached({ invocation, prompt, runId, laneKey, devConfigPath }, {
304
+ return launchDetached({ invocation, prompt, runId, laneKey, devConfigPath, addDirs }, {
270
305
  spawnImpl,
271
306
  fs,
272
307
  stateRoot: stateRoot ?? recoveryStateRoot(devConfigPath),
273
308
  watch,
274
309
  terminate,
310
+ ...(supervisorEntry ? { supervisorEntry } : {}),
275
311
  });
276
312
  }
package/dist/starting.js CHANGED
@@ -538,7 +538,8 @@ export async function amendmentGuard(argv, { request = devRequest, marker = curr
538
538
  const pending = await request("GET", `/api/v1/lanes/${encode(lane.laneKey)}/amendments${query({ runId: lane.runId })}`);
539
539
  if (!pending.data.length) return lane;
540
540
  const detail = pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n");
541
- throw new CliError("LANE_AMENDMENT_ACK_REQUIRED", `Lane ${lane.laneKey} changed:\n${detail}\nDelta gather required: run nodii-dev lane gather ${lane.laneKey} --delta.`, 1, { amendments: pending.data });
541
+ const next = `nodii-dev lane gather ${lane.laneKey} --delta`;
542
+ throw new CliError("LANE_AMENDMENT_ACK_REQUIRED", `Lane ${lane.laneKey} changed:\n${detail}\nDelta gather required: run ${next}. If it prints a Trigger shortlist (picker: agent), run the record command it prints (data.next.command in --json): that records your pick and acknowledges the amendment. Help stays readable: nodii-dev --help.`, 1, { amendments: pending.data, next });
542
543
  }
543
544
 
544
545
  // Gather's human output is a renderer of its payload; Delta gather prints the
@@ -569,8 +570,15 @@ export async function gatherLaneCommand(key, flags, { request = devRequest, mark
569
570
  }
570
571
  const version = Math.max(...pending.data.map((item) => item.version));
571
572
  const gathered = await withSignal(gather(key, flags, { request: timedRequest, deadline }), signal);
572
- // An agent picker's shortlist step records nothing, so it acknowledges nothing.
573
- if (gathered.payload?.data?.recorded === false) return gathered;
573
+ // An agent picker's shortlist step records nothing, so it acknowledges
574
+ // nothing: it names the amendment still open and the command that closes it.
575
+ if (gathered.payload?.data?.recorded === false) {
576
+ const open = `Amendment version ${version} is not acknowledged yet: run the record command above.`;
577
+ return {
578
+ payload: { ...gathered.payload, amendments: pending.data, acknowledgedVersion: null },
579
+ human: (p) => `${typeof gathered.human === "function" ? gathered.human(p) : gathered.human}\n${open}`,
580
+ };
581
+ }
574
582
  const acknowledgement = await withSignal(acknowledge(key, {
575
583
  ...flags,
576
584
  deltaContext: { runId: lane.runId, amendmentVersion: version },
package/dist/watchdog.js CHANGED
@@ -17,6 +17,8 @@ function validateRecord(record) {
17
17
  if (!record || typeof record !== "object") throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run record must be an object.", 2);
18
18
  if (!RUN_ID.test(record.runId ?? "")) throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run record needs its Dev run UUID.", 2);
19
19
  if (!Number.isSafeInteger(record.pid) || record.pid < 1) throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run record needs a positive process id.", 2);
20
+ if (record.supervisorPid !== undefined && (!Number.isSafeInteger(record.supervisorPid) || record.supervisorPid < 1))
21
+ throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run supervisor id must be a positive process id.", 2);
20
22
  if (record.configPath !== undefined && (typeof record.configPath !== "string" || !isAbsolute(record.configPath)))
21
23
  throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run config path must be absolute.", 2);
22
24
  if (record.worktree !== undefined && (typeof record.worktree !== "string" || !isAbsolute(record.worktree)))
@@ -38,6 +40,7 @@ async function atomicWrite(path, contents, mode = 0o600) {
38
40
  export async function recordWatchedRun(input, { home = homedir(), now = () => new Date(), processStart = processStartedAt } = {}) {
39
41
  const record = validateRecord({ ...input, version: 1, recordedAt: now().toISOString() });
40
42
  if (!record.processStartedAt) record.processStartedAt = await processStart(record.pid).catch(() => null);
43
+ if (record.supervisorPid && !record.supervisorStartedAt) record.supervisorStartedAt = await processStart(record.supervisorPid).catch(() => null);
41
44
  const dir = watchdogRunDir(home);
42
45
  await mkdir(dir, { recursive: true, mode: 0o700 });
43
46
  await chmod(dir, 0o700);
@@ -140,6 +143,8 @@ export async function collectWatchdogFacts(records, {
140
143
  } = {}) {
141
144
  const deadRuns = [];
142
145
  for (const record of records) {
146
+ // A live lane supervisor is reporting this exit itself (NODII-DEV-CLI-1).
147
+ if (record.supervisorPid && await isPidAlive(record.supervisorPid, record.supervisorStartedAt)) continue;
143
148
  if (!(await isPidAlive(record.pid, record.processStartedAt)))
144
149
  deadRuns.push({ runId: record.runId, pid: record.pid, ...(record.worktree ? { worktree: record.worktree } : {}) });
145
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.39",
3
+ "version": "0.6.41",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {