@nodii/dev 0.6.28 → 0.6.29

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
@@ -97,6 +97,8 @@ nodii-dev agent heartbeat
97
97
  nodii-dev agents spawn SERVICE [--repo-root DIR]
98
98
  nodii-dev agents cowork-tick [--json]
99
99
  nodii-dev agents cowork-tick --plist [--interval SECONDS]
100
+ nodii-dev watchdog install [--interval SECONDS]
101
+ nodii-dev watchdog sweep
100
102
 
101
103
  nodii-dev issues list [--service ID] [--project KEY] [--status STATUS]
102
104
  [--kind KIND] [--priority PRIORITY] [--claimed true|false]
@@ -312,6 +314,7 @@ nodii-dev lane step KEY --side backend|frontend --start|--done|--block STEP [--r
312
314
  nodii-dev lane evidence KEY --pr|--commit|--figma|--deploy|--dev-check REF
313
315
  nodii-dev lane subagent-quota KEY N
314
316
  nodii-dev lane heartbeat KEY [--note TEXT]
317
+ nodii-dev lane resume KEY
315
318
  nodii-dev lane review KEY --file REVIEW.json
316
319
  nodii-dev lane reviews KEY
317
320
  nodii-dev lane land KEY
@@ -358,8 +361,22 @@ nodii-dev harness install [--include-drafts] [--adopt PATH,...] [--dry-run] [--s
358
361
  nodii-dev harness update [--include-drafts] [--adopt PATH,...] [--dry-run] [--system]
359
362
  nodii-dev harness check [--json] # 0 current, 1 differs from Dev, 2 no harness
360
363
  nodii-dev harness update --check # the same check
364
+ nodii-dev watchdog install [--interval SECONDS] # install/update com.nodii.watchdog in ~/Library/LaunchAgents
365
+ nodii-dev watchdog sweep # launchd entrypoint; normally not run by hand
361
366
  ```
362
367
 
368
+ The per-Mac watchdog runs outside agent sessions. Run `watchdog install` once
369
+ with the machine coordinator's config; it refuses a lane or unassigned agent.
370
+ Every sweep reports three host facts to Dev: watched run processes that exited
371
+ without ending their run, `/tmp/cc-socks/<pid>.sock` sockets whose process is
372
+ gone, and watched worktrees whose HEAD has commits absent from its configured
373
+ remote branch. Each observation is posted to the watched run by the machine
374
+ coordinator; `process_dead` transitions it to explicit `orphan` immediately.
375
+ Run launchers and wrappers persist one owner-only record per process under
376
+ `~/.config/nodii-dev/watchdog/runs/`; the plist itself contains only absolute
377
+ command/config paths, its schedule, and log paths, while OAuth credentials
378
+ remain in the machine keychain.
379
+
363
380
  `harness install` and `harness update` merge the safety hooks into both
364
381
  `.claude/settings.json` and `.codex/hooks.json`; every installed hook invokes
365
382
  only `nodii-dev`. The
@@ -718,6 +735,35 @@ from AGENT-HARNESS-14 is available.
718
735
 
719
736
  ## Build lanes and repository setup
720
737
 
738
+ `coordinator start --machine NAME` registers the machine's one watched
739
+ coordinator run and shows its running lanes, orphaned runs and unclaimed
740
+ results. A live second coordinator is refused; a coordinator whose heartbeat
741
+ watchdog has recorded it orphaned is recorded as the predecessor of the
742
+ takeover.
743
+
744
+ `lane launch KEY` replaces the manual worktree/start/process sequence. It
745
+ requires the issue (whose title supplies both lane title and objective), done
746
+ check, build kind and side, runtime profile and model, subagent quota, and
747
+ repeated `--repo NAME=/SOURCE` plus
748
+ `--branch NAME=BRANCH` pairs. The Hub preflight checks the coordinator waiter,
749
+ target agent and machine, fresh account-capacity telemetry, repository grants,
750
+ lane limit, open issues and (when `--figma` is present) the authenticated Figma
751
+ account. Only after that passes does the CLI create one worktree per repository,
752
+ atomically register the lane and watched run, and launch the runtime with the
753
+ single-line brief `You are lane KEY. Run nodii-dev lane gather.`. Ordinary
754
+ notes use `--notes`; a scope-narrowing note uses `--narrowing-note` and must
755
+ also name the remainder's destination with `--narrowing-remainder`.
756
+ The launched process uses the already-provisioned lowercase identity (for
757
+ example `~/.config/nodii-dev/agents/lane-ah-12.json`), never the coordinator's
758
+ Dev identity.
759
+
760
+ Objective, scope and rule changes are durable `lane amend` records, never chat
761
+ messages. Every CLI call from a launched worktree checks the current watched
762
+ run's acknowledgement version. A pending change is printed and blocks the
763
+ call until `lane gather --delta` serves the current rules and acknowledges the
764
+ newest displayed version. `NODII_DEV_RUN_ID` makes this check per subagent run,
765
+ so one child cannot acknowledge for its siblings.
766
+
721
767
  `lane start` creates and starts a lane. It infers the repository (by short
722
768
  name, as Dev compares it), branch and worktree from git unless overridden, and resolves `--issue` keys to the UUIDs
723
769
  required by Dev. Lane keys are passed directly to every lane and Built
@@ -733,6 +779,17 @@ slot; Dev refuses another registration with `SUBAGENT_QUOTA_REACHED` when the
733
779
  lane is full. A `done`, `handed_off`, or `failed` subagent run no longer counts,
734
780
  so the watchdog marking a killed process failed frees the slot.
735
781
 
782
+ `lane resume` asks Dev to reserve a fresh run for an orphaned lane, then launches
783
+ the allowlisted Codex or Claude runtime as a detached headless process in the
784
+ same worktree. Dev keeps the lane's finished steps intact and returns the next
785
+ unfinished step together with its bounded recovery packet. The CLI sends that
786
+ packet over the new process's stdin (never its argv), saves its exact PID and
787
+ append-only log under `~/.config/nodii-dev/runs/<run-id>/`, and reports a local
788
+ spawn failure back to Dev so the reservation is not left running. Human output
789
+ identifies the replacement run, PID, step and last pushed commit without
790
+ echoing the dead run's log tail; `--json` retains the complete server response.
791
+ Dev refuses a third attempt after two identical deaths.
792
+
736
793
  `setup` installs the Claude Code and Codex workflow harnesses in the current repository.
737
794
  It writes `.claude/skills/nodii-build-workflow/SKILL.md`, merges SessionStart,
738
795
  Stop and tool hooks into `.claude/settings.json` and `.codex/hooks.json`, and maintains one
package/dist/api.js CHANGED
@@ -99,7 +99,8 @@ export const GROUPS = {
99
99
  "approval-kinds": "ApprovalKinds", "notification-registry": "Notification registry", skills: "Skills", "open-questions": "OpenQuestions",
100
100
  decisions: "Decisions", drift: "Drift", requests: "Requests", pipelines: "Pipelines", slices: "Slices", "sql-lookups": "SqlLookups",
101
101
  runners: "Runners", routing: "Routing", usage: "Usage", audit: "AuditLog", reconciliation: "Reconciliation", cron: "Cron", health: "Health",
102
- composite: "Composite", "service-edges": "ServiceEdges", lanes: "Lanes",
102
+ composite: "Composite", "service-edges": "ServiceEdges", lanes: "Lanes", runs: "Runs",
103
+ releases: "Releases", operate: "Operate",
103
104
  swaps: "Swaps", ideas: "Ideas", terminology: "Terminology", credentials: "Credentials",
104
105
  rulebook: "ad-documents", "ad-decisions": "ad-decisions", domains: "ad-domains", standardizations: "standardizations",
105
106
  archive: "archive", harness: "harness", utils: "utils",
@@ -154,6 +155,8 @@ export const ALIASES = {
154
155
  "standardizations:list": "listStandardizations", "standardizations:show": "getStandardization", "standardizations:update": "updateStandardization",
155
156
  "archive:search": "searchPreDevArchive", "archive:resolve": "resolvePreCitation",
156
157
  "harness:show": "generateHarness", "harness:methodology": "getHarnessMethodologyVersion",
158
+ "runs:register": "registerRun", "runs:show": "getRun", "runs:heartbeat": "heartbeatRun", "runs:end": "endRun",
159
+ "runs:results": "listRunResults", "runs:acknowledge": "acknowledgeResult",
157
160
  "terminology:list": "listTerminology", "terminology:show": "getTerminologyTerm", "terminology:create": "createTerminologyTerm", "terminology:update": "updateTerminologyTerm",
158
161
  };
159
162
 
package/dist/cli.js CHANGED
@@ -53,6 +53,7 @@ export { figmaLock, submitMode } from "./figma.js";
53
53
  import { docsCommand, rulebookCommand, rulesCommand } from "./upload.js";
54
54
  import { changeSetCommand, guidelineCommand } from "./guidelines.js";
55
55
  import { designCheck, designCheckText, designRoles, designRolesText } from "./design.js";
56
+ import { watchdogCommand } from "./watchdog.js";
56
57
  import {
57
58
  ALIASES,
58
59
  GROUPS,
@@ -74,6 +75,13 @@ import {
74
75
  nextCommand,
75
76
  peopleCommand,
76
77
  } from "./lanes.js";
78
+ import {
79
+ amendmentGuard,
80
+ amendLaneCommand,
81
+ coordinatorCommand,
82
+ gatherLaneCommand,
83
+ launchLaneCommand,
84
+ } from "./starting.js";
77
85
  import {
78
86
  launchRunSupervisor,
79
87
  showWaitingResults,
@@ -132,6 +140,8 @@ Usage: nodii-dev <command> [options] [--json]
132
140
  # --system stages managed hooks and only prints operator-reviewed sudo commands
133
141
  harness check # 0 current, 1 differs from Dev, 2 not installed
134
142
  harness update --check # the same check
143
+ watchdog install [--interval SECONDS] # install/update the per-Mac launchd liveness sweep (default 60s)
144
+ watchdog sweep # launchd entrypoint: report dead runs, stale sockets, unpushed worktrees
135
145
  docs check|validate|upload <path...> --class ruling|conformance|editorial
136
146
  [--base CODE=VERSION] [--decision FILE] [--pending] [--conformance-ref REF]
137
147
  [--meaning-unchanged REASON] [--wait]
@@ -142,6 +152,17 @@ Usage: nodii-dev <command> [options] [--json]
142
152
  rules report [--section S] [--lane KEY] # delivery totals per document; rules served per lane
143
153
  rules cap TOKENS # operator: set the core token cap (default 3000)
144
154
 
155
+ coordinator start --machine NAME # register this machine's one watched coordinator; refuses a live second
156
+ lane launch <key> --done-check TEXT --kind KIND --side SIDE
157
+ --issue KEY --repo NAME=/SOURCE --branch NAME=BRANCH [--repo ... --branch ...]
158
+ --machine NAME --runtime claude|codex --profile PATH --model MODEL --subagents N
159
+ [--base NAME=REF] [--worktree NAME=PATH] [--owned-path NAME=PATH]
160
+ [--figma FILE[=COMPONENT_SET[,SET]]] [--notes TEXT]
161
+ [--narrowing-note TEXT --narrowing-remainder DESTINATION]
162
+ # authoritative preflight, one worktree per repo, watched launch
163
+ # uses the provisioned agents/lane-<key>.json Dev identity
164
+ lane amend <key> --objective|--scope|--rules TEXT --reason TEXT
165
+ lane gather [<key>] [--delta] # infers a launched worktree; Delta required after an amendment
145
166
  run [--kind build|test_run|subagent|deploy] [--name TEXT] -- <command> [args...]
146
167
  # watched command: registers with NODII_DEV_RUN_ID,
147
168
  # heartbeats, and records exit code + redacted log tail
@@ -151,7 +172,7 @@ Usage: nodii-dev <command> [options] [--json]
151
172
  # --block --reason on a blocked step records its new reason
152
173
  lane evidence <key> --pr|--commit|--figma|--deploy|--dev-check REF
153
174
  lane subagent-quota <key> <n> # coordinator assigns 0-100 subagent slots
154
- lane heartbeat|land|verify|prioritize|deprioritize|stop <key>
175
+ lane heartbeat|resume|land|verify|prioritize|deprioritize|stop <key>
155
176
  lane review <key> --file REVIEW.json # base/head, outcome and clause-cited findings
156
177
  lane reviews <key> # stored review history for the lane
157
178
  lane non-issue <key> --reason TEXT --evidence TEXT --reroute KEY[,KEY]|none
@@ -412,8 +433,12 @@ export async function figmaFileAdd(value, flags, { request = devRequest } = {})
412
433
  }
413
434
 
414
435
  export async function main(argv = process.argv.slice(2)) {
415
- if (argv[0] !== "__run-supervisor")
436
+ // Amendment acknowledgement is a lane-wide backstop, including otherwise
437
+ // local commands such as help and version.
438
+ if (argv[0] !== "__run-supervisor") {
439
+ await amendmentGuard(argv);
416
440
  await showWaitingResults({ acknowledge: argv[0] !== "hook" });
441
+ }
417
442
  if (argv.length === 1 && ["--version", "-v"].includes(argv[0]))
418
443
  return console.log(VERSION);
419
444
  if (argv.length === 1 && ["--help", "-h"].includes(argv[0]))
@@ -523,6 +548,10 @@ export async function main(argv = process.argv.slice(2)) {
523
548
  if (rest.length) throw new CliError("USAGE", `harness ${action} takes no arguments, got: ${rest.join(" ")}. Write a flag value as --include-drafts=false.`, 2);
524
549
  return emitCommand(await harnessCommand(action, f));
525
550
  }
551
+ if (command === "watchdog") {
552
+ if (rest.length) throw new CliError("USAGE", `watchdog ${action ?? ""} takes no arguments, got: ${rest.join(" ")}.`, 2);
553
+ return emitCommand(await watchdogCommand(action, f));
554
+ }
526
555
  if (command === "docs") return emitCommand(await docsCommand(action, rest, f));
527
556
  if (command === "rulebook" && ["list", "show", "diff"].includes(action)) return emitCommand(await rulebookCommand(action, rest, f));
528
557
  if (command === "rules") return emitCommand(await rulesCommand(action, rest, f));
@@ -541,6 +570,10 @@ export async function main(argv = process.argv.slice(2)) {
541
570
  }
542
571
  return;
543
572
  }
573
+ if (command === "coordinator") return emitCommand(await coordinatorCommand(action, f));
574
+ if (command === "lane" && action === "launch") return emitCommand(await launchLaneCommand(rest[0], f));
575
+ if (command === "lane" && action === "amend") return emitCommand(await amendLaneCommand(rest[0], f));
576
+ if (command === "lane" && action === "gather") return emitCommand(await gatherLaneCommand(rest[0], f));
544
577
  if (command === "lane") return emitCommand(await laneCommand(action, rest, f));
545
578
  if (command === "next") return emitCommand(await nextCommand(action, f));
546
579
  if (command === "built") return emitCommand(await builtCommand(action, rest, f));
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.28";
7
+ export const VERSION = "0.6.29";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
@@ -199,7 +199,7 @@ export async function checkLogin() {
199
199
  export const encode = (value) => encodeURIComponent(value);
200
200
  // Switches never take the next token as their value, so
201
201
  // `docs upload --wait rulebook/` keeps rulebook/ as a path.
202
- const SWITCHES = new Set(["json", "wait", "pending", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force", "system"]);
202
+ const SWITCHES = new Set(["json", "wait", "pending", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force", "system", "delta"]);
203
203
  export function parseArgs(argv) {
204
204
  const flags = {};
205
205
  const args = [];
package/dist/doctor.js CHANGED
@@ -19,7 +19,7 @@ export const LANE_PERMISSIONS = [
19
19
  { key: "devops.issues.read", for: "issues list|show" },
20
20
  { key: "devops.issues.write", for: "issues create|update|claim|release|transition" },
21
21
  { key: "devops.lanes.read", for: "lane show|list, next" },
22
- { key: "devops.lanes.write", for: "lane start|step|evidence|heartbeat|review|land|verify" },
22
+ { key: "devops.lanes.write", for: "lane launch|amend|start|step|evidence|heartbeat|review|land|verify" },
23
23
  { key: "devops.agents.read", for: "whoami, reading this agent" },
24
24
  { key: "devops.agents.write", for: "agent register|heartbeat" },
25
25
  { key: "devops.machines.manage", for: "machine show (this agent's machine and coordinator)" },
package/dist/hooks.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CliError, devRequest, readConfig } from "./core.js";
2
2
  import { FOUNDATIONS_ID, figmaLockCheck, outOfScopeText } from "./figma.js";
3
3
  import { execFile } from "node:child_process";
4
- import { realpath } from "node:fs/promises";
4
+ import { readFile, realpath } from "node:fs/promises";
5
5
  import { resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { safetySettingsSnippet } from "./safety.js";
@@ -209,6 +209,15 @@ export async function heartbeatCurrentLane(request = devRequest) {
209
209
  try { branch = (await execFileAsync("git", ["branch", "--show-current"], { encoding: "utf8" })).stdout.trim(); }
210
210
  catch { return { heartbeated: false, reason: "not a git worktree" }; }
211
211
  if (!branch) return { heartbeated: false, reason: "detached HEAD" };
212
+ try {
213
+ const markerPath = (await execFileAsync("git", ["rev-parse", "--path-format=absolute", "--git-path", "nodii-lane.json"], { encoding: "utf8" })).stdout.trim();
214
+ const marker = JSON.parse(await readFile(markerPath, "utf8"));
215
+ const lane = (await request("GET", `/api/v1/lanes/${encodeURIComponent(marker.laneKey)}`)).data;
216
+ if (lane.repositories?.some((repository) => repository.branch === branch)) {
217
+ await request("POST", `/api/v1/lanes/${encodeURIComponent(lane.id)}/heartbeat`, { via: "nodii-dev" });
218
+ return { heartbeated: true, key: lane.key };
219
+ }
220
+ } catch {}
212
221
  const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
213
222
  const lane = lanes.find((x) => x.branch === branch && !["on_dev", "stopped"].includes(x.status));
214
223
  if (!lane) return { heartbeated: false, reason: "no lane for current branch" };
@@ -226,6 +235,23 @@ export async function sessionStartContext(payload, { request = devRequest, confi
226
235
  const rootText = (await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" })).stdout.trim();
227
236
  const root = await realpath(rootText).catch(() => resolve(rootText));
228
237
  const current = await config(false);
238
+ try {
239
+ const markerPath = (await execFileAsync("git", ["rev-parse", "--path-format=absolute", "--git-path", "nodii-lane.json"], { cwd, encoding: "utf8" })).stdout.trim();
240
+ const marker = JSON.parse(await readFile(markerPath, "utf8"));
241
+ const marked = (await request("GET", `/api/v1/lanes/${encodeURIComponent(marker.laneKey)}`)).data;
242
+ const repository = marked.repositories?.find((item) => item.branch === branch && resolve(item.worktree) === root);
243
+ if (marked.agentId === current.agentId && repository && !["on_dev", "stopped"].includes(marked.status)) lane = marked;
244
+ } catch {}
245
+ if (lane) {
246
+ const freeze = lane.freeze ?? {};
247
+ return [
248
+ `Freeze re-grounding for ${lane.key}:`,
249
+ `title: ${lane.title ?? "(untitled)"}`,
250
+ `status: ${lane.status}`,
251
+ `boundaries: ${JSON.stringify(freeze.boundaries ?? {})}`,
252
+ `rules: ${JSON.stringify(freeze.rules ?? freeze.ruleRefs ?? [])}`,
253
+ ].join("\n");
254
+ }
229
255
  const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
230
256
  for (const item of lanes) {
231
257
  if (item.agentId !== current.agentId || item.branch !== branch || ["on_dev", "stopped"].includes(item.status)) continue;
package/dist/lanes.js CHANGED
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
4
  import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./core.js";
5
+ import { launchRecovery } from "./runtime-launcher.js";
5
6
  import { recordReviewReceipt, reviewCommitContext } from "./safety.js";
6
7
 
7
8
  const execFileAsync = promisify(execFile);
@@ -106,14 +107,18 @@ const MAX_COMMITS = 200;
106
107
  /** The lane's branch commits not on origin/main (as last fetched), from its recorded worktree. */
107
108
  export async function branchCommits(lane) {
108
109
  try {
109
- const out = (await execFileAsync("git", ["-C", lane.worktree, "rev-list", `origin/main..${lane.branch}`], { encoding: "utf8" })).stdout.trim();
110
- return out ? out.split("\n") : [];
110
+ const repositories = lane.repositories?.length ? lane.repositories : [{ worktree: lane.worktree, branch: lane.branch, baseRef: "origin/main" }];
111
+ const commits = await Promise.all(repositories.map(async (repository) => {
112
+ const out = (await execFileAsync("git", ["-C", repository.worktree, "rev-list", `${repository.baseRef ?? "origin/main"}..${repository.branch}`], { encoding: "utf8" })).stdout.trim();
113
+ return out ? out.split("\n") : [];
114
+ }));
115
+ return [...new Set(commits.flat())];
111
116
  } catch {
112
- throw new CliError("GIT_REQUIRED", `Cannot read the commits of ${lane.branch} in ${lane.worktree}. Pass --commits SHA[,SHA], or --branch dropped:REASON.`, 2);
117
+ throw new CliError("GIT_REQUIRED", `Cannot read every repository branch for this lane. Pass --commits SHA[,SHA], or --branch dropped:REASON.`, 2);
113
118
  }
114
119
  }
115
120
 
116
- export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt } = {}) {
121
+ export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt, launch = launchRecovery } = {}) {
117
122
  if (action === "list") {
118
123
  const c = flags.mine ? await config() : null;
119
124
  const payload = await request("GET", `/api/v1/lanes${query({ kind: flags.kind, status: flags.status, machine: flags.machine, agent: flags.mine ? c.agentId : flags.agent, "waiting-on": flags["waiting-on"], stuck: flags.stuck, cursor: flags.cursor, limit: flags.limit })}`);
@@ -194,6 +199,40 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
194
199
  );
195
200
  return { payload, human: `${ref}: subagent quota set to ${quota}.` };
196
201
  }
202
+ if (action === "resume") {
203
+ const payload = await request(
204
+ "POST",
205
+ `/api/v1/lanes/${encode(id)}/resume`,
206
+ via,
207
+ );
208
+ let launched;
209
+ try {
210
+ launched = await launch(payload);
211
+ } catch (error) {
212
+ const reason = `local recovery launch failed: ${error instanceof Error ? error.message : String(error)}`.slice(0, 2_000);
213
+ const runId = payload?.data?.run?.id;
214
+ if (runId) {
215
+ await request(
216
+ "POST",
217
+ `/api/v1/lanes/${encode(id)}/resume/${encode(runId)}/launch-failed`,
218
+ { reason, ...via },
219
+ ).catch(() => {});
220
+ }
221
+ throw new CliError("RECOVERY_LAUNCH_FAILED", reason, 1);
222
+ }
223
+ return {
224
+ payload,
225
+ human: (response) => {
226
+ const runId = response.data.run.id;
227
+ const next = response.data.lane.next?.[0];
228
+ const checkpoint = response.data.recovery.lastPushedCommit;
229
+ const continuation = next
230
+ ? `${next.side}/${next.step}`
231
+ : "the lane's next unfinished step";
232
+ return `${ref}: resumed fresh run ${runId} as PID ${launched.pid}; continuing ${continuation} from ${checkpoint}.`;
233
+ },
234
+ };
235
+ }
197
236
  if (action === "land") return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/land`, via), human: `${ref}: recorded on main.` };
198
237
  if (action === "review") {
199
238
  const body = await reviewBody(flags, read);
@@ -269,11 +308,15 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
269
308
  */
270
309
  export async function laneChangedPaths(lane) {
271
310
  try {
272
- const run = async (...args) => (await execFileAsync("git", ["-C", lane.worktree, ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 })).stdout;
273
- const base = (await run("merge-base", "origin/main", "HEAD")).trim();
274
- const changed = (await run("diff", "--name-only", base)).split("\n");
275
- const untracked = (await run("ls-files", "--others", "--exclude-standard")).split("\n");
276
- return [...new Set([...changed, ...untracked].map((x) => x.trim()).filter(Boolean))].slice(0, 5000);
311
+ const repositories = lane.repositories?.length ? lane.repositories : [{ repo: lane.repos?.[0], worktree: lane.worktree, baseRef: "origin/main" }];
312
+ const changed = await Promise.all(repositories.map(async (repository) => {
313
+ const run = async (...args) => (await execFileAsync("git", ["-C", repository.worktree, ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 })).stdout;
314
+ const base = (await run("merge-base", repository.baseRef ?? "origin/main", "HEAD")).trim();
315
+ const tracked = (await run("diff", "--name-only", base)).split("\n");
316
+ const untracked = (await run("ls-files", "--others", "--exclude-standard")).split("\n");
317
+ return [...tracked, ...untracked].map((path) => path.trim()).filter(Boolean);
318
+ }));
319
+ return [...new Set(changed.flat())].slice(0, 5000);
277
320
  } catch {
278
321
  return null;
279
322
  }
@@ -295,11 +338,14 @@ export async function nextCommand(ref, flags, { request = devRequest, changedPat
295
338
  const data = steps.map((step) => ({ side: step.side, step: step.stepKey, label: step.label, state: step.state, command: `nodii-dev lane step ${lane.key} --side ${step.side} --${step.state === "current" ? "done" : "start"} ${step.stepKey}` }));
296
339
  // M05-32: the clauses Dev serves for this step. A failure here never hides the next step.
297
340
  let rules = null;
298
- if (data.length) {
341
+ if (data.length || flags.deltaContext) {
299
342
  const paths = flags.paths ? list(flags.paths) : await changedPaths(lane);
300
- const body = { ...(paths ? { paths } : {}), ...(flags.components ? { components: list(flags.components) } : {}), ...(flags.role ? { role: String(flags.role) } : {}) };
343
+ const body = { ...(paths ? { paths } : {}), ...(flags.components ? { components: list(flags.components) } : {}), ...(flags.role ? { role: String(flags.role) } : {}), ...(flags.deltaContext && typeof flags.deltaContext === "object" ? { delta: flags.deltaContext } : {}) };
301
344
  try { rules = (await request("POST", `/api/v1/lanes/${encode(lane.key)}/rules`, body)).data; }
302
- catch (error) { rules = { error: error.code ?? error.message }; }
345
+ catch (error) {
346
+ if (flags.deltaContext) throw error;
347
+ rules = { error: error.code ?? error.message };
348
+ }
303
349
  }
304
350
  const human = data.length
305
351
  ? [...data.map((x) => `${x.side}: ${x.label}\n ${x.command}`), ...rulesLines(rules, lane.key)].join("\n")