@nodii/dev 0.6.43 → 0.6.45

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
@@ -324,7 +324,8 @@ nodii-dev hook claude-figma-post # reads Claude Code hook JSON on stdin
324
324
  nodii-dev hook safety-pre # silent safety decision; blocks with exit 2
325
325
  nodii-dev hook safety-post # silently records resources created by successful commands
326
326
  nodii-dev hook nudge-pre # guideline nudges before a tool call; never blocks
327
- nodii-dev hook nudge-stop # guideline nudges when a lane session stops; asks once
327
+ nodii-dev hook nudge-stop # holds a lane session's stop until its done check is recorded;
328
+ # guideline nudges when a lane session stops; asks once
328
329
  nodii-dev figma checked CS-n --unused-properties --stale-placeholders --dialog-body
329
330
  nodii-dev hook print-settings # prints managed-settings JSON; writes nothing
330
331
  ```
@@ -356,10 +357,16 @@ nodii-dev lane start KEY --title TEXT --kind dev_fix|new_build|docs_change|uat_f
356
357
  nodii-dev lane step KEY --side backend|frontend --start|--done|--block STEP [--reason TEXT]
357
358
  # --reason is kept as a lane note; on a step already blocked,
358
359
  # --block --reason records the new reason (Dev issue 125 moves it onto the step)
360
+ nodii-dev lane step KEY --side backend|frontend --done STEP --review REVIEW.json
361
+ # build_test, adopt_test and local_test record their review (as lane review)
362
+ # first; a refused review leaves the step open
363
+ nodii-dev lane review KEY --not-required --decision DEC-nnnn|--question ID|--quote TEXT --at ISO-TIME
364
+ # a NOT-REQUIRED record for the current diff, citing the ruling it relies on
359
365
  nodii-dev lane evidence KEY --pr|--commit|--figma|--deploy|--dev-check REF
360
366
  nodii-dev lane done-check KEY --evidence TEXT|URL
361
367
  # the lane's done check is met (NODII-DEV-CLI-4); a launched
362
- # run that exits after it ends finished, not orphaned
368
+ # run that exits after it ends finished, not orphaned; on a
369
+ # lane already on_dev (land → verify first) it is a no-op
363
370
  nodii-dev lane subagent-quota KEY N
364
371
  nodii-dev lane heartbeat KEY [--note TEXT]
365
372
  nodii-dev lane resume KEY
@@ -505,7 +512,15 @@ covers `dialog-content`). Edits are judged against the file they produce, so
505
512
  an Edit or `apply_patch` that only changes an attribute is read in full.
506
513
 
507
514
  The Stop nudge asks the session to continue once (`decision: block`, skipped
508
- when `stop_hook_active`); Codex passes it through its managed dispatcher. A
515
+ when `stop_hook_active`); Codex passes it through its managed dispatcher.
516
+ Before that, in a lane session (a `lane-*.json` identity), the same hook holds
517
+ the stop while the lane's done check is not recorded and the lane is not
518
+ BLOCKED (NODII-DEV-CLI-8), even after an earlier block: it reads the lane from
519
+ Dev and answers `Lane KEY: done check not recorded. Continue the Task: STEP.`
520
+ Recording the done check (`lane done-check`), a blocked step (`lane step
521
+ --block`) or a heartbeat note that starts with "blocked" lets the stop
522
+ through, and so does a lane Dev can't serve. One session is held at most
523
+ `NODII_DEV_STOP_BLOCK_LIMIT` times in a row (default 5), as a cost backstop. A
509
524
  nudge hook skips the amendment guard and the waiting-results banner, since
510
525
  either would corrupt its JSON; it cannot run a Delta gather.
511
526
 
@@ -939,6 +954,19 @@ counts as unfinished. Every report carries `exit_code`, `worktree` and
939
954
  `lane_status` evidence. `process.pid` still holds the runtime's own PID, and the
940
955
  supervisor appends its report line to `process.log`.
941
956
 
957
+ Before it reports an unfinished exit, the supervisor continues the same session
958
+ (NODII-DEV-CLI-9) when the lane is not BLOCKED and the run log names the
959
+ session (Claude's `session_id`, Codex's `thread_id`). It runs `claude ...
960
+ --resume SESSION`, or `codex exec resume ... SESSION -`, with the same model
961
+ and permission mode, and sends `Lane KEY: done check not recorded. Continue the
962
+ Task: STEP.` on stdin. It does this up to `NODII_DEV_LANE_CONTINUATIONS` times
963
+ (default 3; 0 turns it off). Each continuation is a run heartbeat, and
964
+ `process.pid` and the watchdog record follow the new PID. A killed runtime
965
+ (a signal) is never continued. After the last continuation, the exit is
966
+ reported as above and its reason counts the continuations. A supervisor runs
967
+ the CLI version that started it, so a fix to this rule reaches a lane only
968
+ when it is launched or resumed on that version.
969
+
942
970
  A lane's Figma scope is held by published key only (MW8-20): `--figma
943
971
  FILE_KEY` for a whole file, or `--figma FILE_KEY=COMPONENT_SET_KEY[,KEY]` for
944
972
  its component sets. A node id is refused. `--route /app/...` (repeatable)
@@ -1064,6 +1092,22 @@ commit in `base`, exact reviewed commit in `head`,
1064
1092
  `rules`), `severity`, `summary`, `detail`, `disposition`, optional `path` and
1065
1093
  `line`, and a required `clauseId` for rules findings. `lane reviews` reads the
1066
1094
  append-only history, including whether each cited clause was a missed trigger.
1095
+ Dev stores a review while the lane is `building` (the build step's review,
1096
+ MW1-68) as well as `built` or `review`. `lane step KEY --done STEP --review
1097
+ REVIEW.json` records the same file the same way, then marks the step done;
1098
+ `nodii-dev next` prints `--review REVIEW.json` on the `build_test`,
1099
+ `adopt_test` and `local_test` done command.
1100
+
1101
+ Dev completes those three steps only when the lane's latest review record is
1102
+ CLEAN or NOT-REQUIRED (hub-58, 30 Oct); otherwise `lane step --done` gets
1103
+ `REVIEW_NOT_RECORDED` or `REVIEW_NOT_CLEAN`. `lane review KEY --not-required`
1104
+ stores a NOT-REQUIRED record for the current base and head, citing one ruling
1105
+ Dev checks: `--decision` (a locked decision, `DEC-nnnn` or a pre-dev id),
1106
+ `--question` (an answered Dev question id), or `--quote TEXT --at ISO-TIME`
1107
+ (found verbatim in a Dev inbox answer within ten minutes of that time). Free
1108
+ text is not a citation; Dev refuses an unknown one with
1109
+ `REVIEW_RULING_NOT_FOUND` or `REVIEW_RULING_SUPERSEDED`. It writes the same
1110
+ local push receipt as a review.
1067
1111
 
1068
1112
  Heartbeat notes are optional and limited by Dev to 500 characters.
1069
1113
  Prioritize and deprioritize require a 1–500 character reason, which the CLI
package/dist/cli.js CHANGED
@@ -216,6 +216,8 @@ Usage: nodii-dev <command> [options] [--json]
216
216
  lane start <key> --title TEXT --kind KIND --side SIDE [--repo REPO] [--issue ISSUE]
217
217
  lane step <key> --side SIDE --start|--done|--block <step> [--reason TEXT]
218
218
  # --block --reason on a blocked step records its new reason
219
+ lane step <key> --side SIDE --done <step> --review REVIEW.json
220
+ # records the review (as lane review) first, then marks the step done
219
221
  lane step <key> --side SIDE --not-applicable gather --reason-code pre_harness_lane [--note TEXT]
220
222
  # coordinator only: Gather never applied (pre-Gather lane); never "done"
221
223
  lane evidence <key> --pr|--commit|--figma|--deploy|--dev-check REF
@@ -223,6 +225,8 @@ Usage: nodii-dev <command> [options] [--json]
223
225
  lane subagent-quota <key> <n> # coordinator assigns 0-100 subagent slots
224
226
  lane heartbeat|resume|land|verify|prioritize|deprioritize|stop <key>
225
227
  lane review <key> --file REVIEW.json # base/head, outcome and clause-cited findings
228
+ lane review <key> --not-required --decision DEC-nnnn|--question ID|--quote TEXT --at ISO-TIME
229
+ # no review needed for this diff: cites the ruling Dev validates (hub-58)
226
230
  lane reviews <key> # stored review history for the lane
227
231
  lane freeze add <key> --section SECTION --file ITEM.json --reason TEXT
228
232
  lane freeze drop <key> --section SECTION --item ID --reason TEXT
@@ -1050,9 +1054,10 @@ export async function main(argv = process.argv.slice(2)) {
1050
1054
  await recordHookFire(payload).catch(() => {});
1051
1055
  const result = await safetyPreHook(payload);
1052
1056
  const nudges = result.nudges ?? [];
1053
- if (nudges.length && hookRuntime(payload) === "codex")
1057
+ // Both runtimes show the model a PreToolUse nudge only as additionalContext;
1058
+ // Claude keeps plain stdout of an exit-0 hook out of the model's context.
1059
+ if (nudges.length)
1054
1060
  console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: nudges.join("\n") } }));
1055
- else for (const nudge of nudges) console.log(nudge);
1056
1061
  return;
1057
1062
  } catch (error) {
1058
1063
  // Only an evaluated safety rule may deny. Discovery, parsing, git,
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.43";
7
+ export const VERSION = "0.6.45";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
@@ -222,7 +222,7 @@ export async function checkLogin() {
222
222
  export const encode = (value) => encodeURIComponent(value);
223
223
  // Switches never take the next token as their value, so
224
224
  // `docs upload --wait rulebook/` keeps rulebook/ as a path.
225
- 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", "probe", "no-probe"]);
225
+ 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", "probe", "no-probe", "not-required"]);
226
226
  export function parseArgs(argv) {
227
227
  const flags = {};
228
228
  const args = [];
@@ -1,10 +1,10 @@
1
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";
2
+ import { open, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { CliError, devRequest, encode } from "./core.js";
6
6
  import { redactRunText } from "./runs.js";
7
- import { saveWatchedRun, watchdogRunDir } from "./watchdog.js";
7
+ import { processStartedAt, saveWatchedRun, watchdogRunDir } from "./watchdog.js";
8
8
 
9
9
  /**
10
10
  * NODII-DEV-CLI-1: a launched or resumed lane's runtime is the child of this
@@ -18,6 +18,14 @@ import { saveWatchedRun, watchdogRunDir } from "./watchdog.js";
18
18
  * watchdog could only see a vanished PID and counted every exit as
19
19
  * process_dead.
20
20
  *
21
+ * NODII-DEV-CLI-9: an exit before the done check, with the lane not
22
+ * BLOCKED, first continues the SAME session (`claude --resume`, `codex exec
23
+ * resume`) with "Lane <key>: done check not recorded. Continue the Task:
24
+ * <step>.", up to NODII_DEV_LANE_CONTINUATIONS times (3). Each continuation
25
+ * is a run heartbeat. Only then is the exit reported, so a lane that stops
26
+ * early keeps its context instead of becoming an orphan that `lane resume`
27
+ * restarts fresh.
28
+ *
21
29
  * Launcher protocol (IPC): launch -> spawned {pid} -> start {prompt} ->
22
30
  * started; or abort, when the launcher could not record the runtime's PID.
23
31
  */
@@ -76,15 +84,37 @@ async function send(request, path, body, attempts = 3) {
76
84
  }
77
85
 
78
86
  /**
79
- * The lane's Dev status and whether its done check is recorded: a recorded
80
- * done check, a dev_check step done, or a terminal lane. Unreadable reads as
81
- * unfinished.
87
+ * Whether a lane's done check is recorded: `lane done-check` (NODII-DEV-CLI-4),
88
+ * a dev_check step done, or a terminal lane.
89
+ */
90
+ export const doneCheckRecorded = (lane) =>
91
+ Boolean(lane?.doneCheckRecorded)
92
+ || (lane?.steps ?? []).some((step) => step.stepKey === "dev_check" && step.state === "done")
93
+ || FINISHED_LANE.has(lane?.status);
94
+
95
+ /** A lane is BLOCKED by a blocked step (`lane step --block`) or a latest heartbeat note that says so. */
96
+ export function laneBlocked(lane) {
97
+ if ((lane?.steps ?? []).some((step) => step.state === "blocked")) return true;
98
+ const heartbeat = (lane?.events ?? []).filter((event) => event.type === "heartbeat").at(-1);
99
+ return /^\s*blocked\b/i.test(String(heartbeat?.metadata?.note ?? ""));
100
+ }
101
+
102
+ /** The step a lane that stopped early goes back to (hub-58, NODII-DEV-CLI-8/9). */
103
+ export function continueTaskMessage(laneKey, lane) {
104
+ const next = (lane?.next ?? []).map((step) => `${step.side}/${step.step}`);
105
+ const current = [lane?.currentBackendStep && `backend/${lane.currentBackendStep}`, lane?.currentFrontendStep && `frontend/${lane.currentFrontendStep}`].filter(Boolean);
106
+ const step = (next.length ? next : current).join(" and ") || "the lane's objective";
107
+ return `Lane ${laneKey}: done check not recorded. Continue the Task: ${step}.`;
108
+ }
109
+
110
+ /**
111
+ * The lane's Dev status and whether its done check is recorded. Unreadable
112
+ * reads as unfinished.
82
113
  */
83
114
  async function laneProgress(request, laneKey) {
84
115
  try {
85
116
  const lane = (await request("GET", `/api/v1/lanes/${encode(laneKey)}`)).data;
86
- const devChecked = (lane?.steps ?? []).some((step) => step.stepKey === "dev_check" && step.state === "done");
87
- return { status: lane?.status ?? null, finished: Boolean(lane?.doneCheckRecorded) || devChecked || FINISHED_LANE.has(lane?.status) };
117
+ return { status: lane?.status ?? null, finished: doneCheckRecorded(lane) };
88
118
  } catch {
89
119
  return { status: null, finished: false };
90
120
  }
@@ -97,7 +127,7 @@ async function laneProgress(request, laneKey) {
97
127
  * every exit, exit 0 included, is a process exit: Dev orphans the run and
98
128
  * routes it for recovery (`lane resume`).
99
129
  */
100
- export async function reportLaneExit({ runId, laneKey, exitCode, signal, worktrees, logPath }, {
130
+ export async function reportLaneExit({ runId, laneKey, exitCode, signal, worktrees, logPath, continuations = 0 }, {
101
131
  request = devRequest,
102
132
  inspect = worktreeState,
103
133
  tail = readTail,
@@ -128,7 +158,7 @@ export async function reportLaneExit({ runId, laneKey, exitCode, signal, worktre
128
158
  outcome = "process_exit";
129
159
  const logTail = redactRunText(await tail(logPath).catch(() => "")).trim();
130
160
  body = {
131
- 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}.`,
161
+ 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}.${continuations ? ` It was continued in the same session ${continuations} time${continuations === 1 ? "" : "s"} first (NODII-DEV-CLI-9).` : ""}`,
132
162
  deathFingerprint: signal ? `lane-signal-${signal}` : clean ? "lane-unfinished-exit-0" : `lane-exit-${exitCode ?? -1}`,
133
163
  exitCode: Number.isInteger(exitCode) && exitCode >= 0 && exitCode <= 255 ? exitCode : -1,
134
164
  ...(logTail ? { logTail: logTail.slice(-50_000) } : {}),
@@ -156,12 +186,60 @@ export async function markWatchedTerminal(runId, { home } = {}) {
156
186
  await saveWatchedRun({ ...record, terminalSeenAt: new Date().toISOString(), registryPath });
157
187
  }
158
188
 
189
+ const SESSION_ID = { claude: /"session_id":"([0-9a-f-]{36})"/g, codex: /"thread_id":"([0-9a-f-]{36})"/g };
190
+ const LOG_SCAN_BYTES = 256 * 1024;
191
+
192
+ /** The runtime's session id, from the stream-json / --json lines it wrote to the run log. */
193
+ export async function sessionIdFromLog(logPath, runtime) {
194
+ const pattern = SESSION_ID[runtime];
195
+ if (!pattern || !logPath) return null;
196
+ const handle = await open(logPath, "r");
197
+ try {
198
+ const { size } = await handle.stat();
199
+ // Codex names its thread once, at the start; Claude repeats its session id, so the end has the latest.
200
+ const ranges = [[0, Math.min(size, LOG_SCAN_BYTES)], [Math.max(0, size - LOG_SCAN_BYTES), Math.min(size, LOG_SCAN_BYTES)]];
201
+ let found = null;
202
+ for (const [position, length] of ranges) {
203
+ const buffer = Buffer.alloc(length);
204
+ await handle.read(buffer, 0, length, position);
205
+ for (const match of buffer.toString("utf8").matchAll(pattern)) found = match[1];
206
+ }
207
+ return found;
208
+ } finally {
209
+ await handle.close();
210
+ }
211
+ }
212
+
213
+ /** The same session, resumed headless with the same model and permission mode. */
214
+ export function continuationInvocation(invocation, sessionId) {
215
+ if (invocation.command === "claude") return { ...invocation, args: [...invocation.args, "--resume", sessionId] };
216
+ const model = invocation.args[invocation.args.indexOf("--model") + 1];
217
+ return { ...invocation, args: ["exec", "resume", "--json", "--dangerously-bypass-approvals-and-sandbox", "--model", model, sessionId, "-"] };
218
+ }
219
+
220
+ /** The watched run follows the continued runtime's PID (the live supervisor still covers it). */
221
+ export async function rewatchRun(runId, pid, { home } = {}) {
222
+ const registryPath = join(watchdogRunDir(home), `${runId}.json`);
223
+ const record = JSON.parse(await readFile(registryPath, "utf8"));
224
+ if (record.terminalSeenAt || record.orphanReportedAt) return;
225
+ await saveWatchedRun({ ...record, pid, processStartedAt: await processStartedAt(pid).catch(() => null), registryPath });
226
+ }
227
+
228
+ const continuationLimit = (env) => {
229
+ const value = Number(env.NODII_DEV_LANE_CONTINUATIONS ?? 3);
230
+ return Number.isInteger(value) && value >= 0 ? value : 3;
231
+ };
232
+
159
233
  /** The supervisor process: spawn the runtime, hand its PID back, report its exit. */
234
+
160
235
  export async function superviseLane({
161
236
  channel = process,
162
237
  spawn = nodeSpawn,
163
238
  report = reportLaneExit,
164
239
  exit = (code) => process.exit(code),
240
+ request = devRequest,
241
+ rewatch = rewatchRun,
242
+ env = process.env,
165
243
  } = {}) {
166
244
  if (typeof channel.send !== "function")
167
245
  throw new CliError("USAGE", "__lane-supervisor is started by lane launch and lane resume only.", 2);
@@ -209,11 +287,46 @@ export async function superviseLane({
209
287
  }
210
288
  });
211
289
  // A signal sent to the supervisor ends the runtime too, and is reported.
212
- for (const name of SIGNALS) process.on(name, () => child.kill(name));
213
- const { exitCode, signal } = await closed;
290
+ let stopping = false;
291
+ for (const name of SIGNALS) process.on(name, () => { stopping = true; child.kill(name); });
292
+ let { exitCode, signal } = await closed;
214
293
  if (state !== "running") return exit(state === "aborted" ? 0 : 1);
294
+ const limit = continuationLimit(env);
295
+ let continued = 0;
296
+ // NODII-DEV-CLI-9: an early stop continues the same session before it is reported.
297
+ while (!stopping && !signal && continued < limit) {
298
+ const sessionId = await sessionIdFromLog(logPath, invocation.command).catch(() => null);
299
+ if (!sessionId) break;
300
+ let lane;
301
+ try { lane = (await request("GET", `/api/v1/lanes/${encode(laneKey)}`)).data; } catch { break; }
302
+ if (doneCheckRecorded(lane) || laneBlocked(lane)) break;
303
+ const message = continueTaskMessage(laneKey, lane);
304
+ continued += 1;
305
+ await request("POST", `/api/v1/runs/${encode(runId)}/heartbeat`, {
306
+ note: `continuation ${continued}/${limit} of session ${sessionId} after exit ${exitCode}: ${message}`.slice(0, 500),
307
+ }).catch(() => {});
308
+ const next = continuationInvocation(invocation, sessionId);
309
+ try {
310
+ child = spawn(next.command, next.args, { cwd: next.cwd, env: next.env, shell: false, stdio: ["pipe", "inherit", "inherit"] });
311
+ } catch {
312
+ break;
313
+ }
314
+ const ended = new Promise((resolve) => {
315
+ child.once("close", (code, sig) => resolve({ exitCode: code, signal: sig }));
316
+ child.once("error", () => resolve({ exitCode: -1, signal: null }));
317
+ });
318
+ child.once("spawn", () => {
319
+ writeFile(join(dirname(logPath), "process.pid"), `${child.pid}\n`, { mode: 0o600 }).catch(() => {});
320
+ rewatch(runId, child.pid).catch(() => {});
321
+ });
322
+ // A runtime that exits before reading its prompt must not end the supervisor (EPIPE).
323
+ child.stdin?.on("error", () => {});
324
+ child.stdin?.end(`${message}\n`);
325
+ process.stderr.write(`nodii-dev: ${message} (same-session continuation ${continued}/${limit})\n`);
326
+ ({ exitCode, signal } = await ended);
327
+ }
215
328
  try {
216
- const result = await report({ runId, laneKey, exitCode, signal, worktrees, logPath });
329
+ const result = await report({ runId, laneKey, exitCode, signal, worktrees, logPath, continuations: continued });
217
330
  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`);
218
331
  return exit(0);
219
332
  } catch (error) {
package/dist/lanes.js CHANGED
@@ -6,6 +6,7 @@ import { launchRecovery } from "./runtime-launcher.js";
6
6
  import { recordReviewReceipt, reviewCommitContext } from "./safety.js";
7
7
  import { GATHER_TIMEOUT_MS, PICKER_FIRST_PICK_TIMEOUT_MS, PICKER_MODEL, agentFallbackReason, gatherDeadline, gatherText, pickDocuments, pickTimingLine, pickTimingStats, readPickTimings, recordPickTiming, warmPicker, withPickLock } from "./gather.js";
8
8
  import { storeGatherFreeze } from "./freeze.js";
9
+ import { doneCheckRecorded } from "./lane-supervisor.js";
9
10
  import { SWAP_BOUNDARIES, componentsForScope, guidelineLookup, laneFigmaScope, loadDsCatalog } from "./figma-scope.js";
10
11
 
11
12
  const execFileAsync = promisify(execFile);
@@ -13,6 +14,8 @@ const via = { via: "nodii-dev" };
13
14
  const GATHER_ACTIVE = new Set(["queued", "building"]);
14
15
  // heartbeatLane's note limit.
15
16
  const BLOCK_NOTE_MAX = 500;
17
+ // MW1-68, MW1-73, MW1-79: steps done only with a CLEAN review, recorded with --review.
18
+ const REVIEW_STEPS = new Set(["build_test", "adopt_test", "local_test"]);
16
19
 
17
20
  const need = (value, usage) => {
18
21
  if (!value) throw new CliError("USAGE", usage, 2);
@@ -157,6 +160,22 @@ export async function reviewBody(flags, read = (path) => readFile(path, "utf8"))
157
160
  return { ...body, ...via };
158
161
  }
159
162
 
163
+ /**
164
+ * hub-58 (30 Oct): a NOT-REQUIRED record cites exactly one ruling Dev can
165
+ * check: --decision ID, --question ID, or --quote TEXT with --at ISO-TIME.
166
+ */
167
+ export function notRequiredCitation(flags) {
168
+ const given = ["decision", "question", "quote"].filter((x) => flags[x] !== undefined);
169
+ const usage = "--not-required cites one ruling: --decision DEC-nnnn, --question QUESTION_ID, or --quote TEXT --at ISO-TIME.";
170
+ if (given.length !== 1 || flags.file !== undefined) throw new CliError("USAGE", usage, 2);
171
+ const value = requireFlag(flags, given[0]);
172
+ if (given[0] !== "quote") {
173
+ if (flags.at !== undefined) throw new CliError("USAGE", usage, 2);
174
+ return { [given[0]]: value };
175
+ }
176
+ return { quote: value, at: requireFlag(flags, "at") };
177
+ }
178
+
160
179
  /**
161
180
  * DEC-0011 (MW1-28.1): the body for `lane non-issue`. A re-route needs at
162
181
  * least one issue key or an explicit `none`, and the branch work is either
@@ -506,6 +525,31 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
506
525
  if (!outerDeadline) deadline.clear();
507
526
  }
508
527
  }
528
+ // MW2-45: `lane review` and a step's `--done --review` record through one path.
529
+ const recordReview = async (reviewFlags = flags) => {
530
+ // The input is checked before git is read, so a bad call never touches the worktree.
531
+ const notRequired = reviewFlags["not-required"] ? notRequiredCitation(reviewFlags) : null;
532
+ const filed = notRequired ? null : await reviewBody(reviewFlags, read);
533
+ const current = await reviewContext();
534
+ const body = filed ?? { base: current.base, head: current.commit, iterationCount: 0, exitReason: "NOT_REQUIRED", appliedPatches: [], unresolvedProposals: [], reviewerErrors: [], findings: [], notRequired, ...via };
535
+ if (body.head !== current.commit || body.base !== current.base)
536
+ throw new CliError(
537
+ "REVIEW_FILE_STALE",
538
+ `The review file covers base ${body.base ?? "(missing)"} and head ${body.head ?? "(missing)"}, but this worktree is base ${current.base} and head ${current.commit}. Review the current diff and write a new receipt.`,
539
+ 2,
540
+ );
541
+ const payload = await request("POST", `/api/v1/lanes/${encode(id)}/review`, body);
542
+ try { await reviewReceipt(); }
543
+ catch (error) {
544
+ throw new CliError("REVIEW_RECEIPT_FAILED", `${ref}: Dev recorded the review, but the local push receipt could not be written (${error?.message ?? error}). Re-run nodii-dev lane review ${ref} --file REVIEW.json from its worktree.`, 1);
545
+ }
546
+ return payload;
547
+ };
548
+ const reviewSummary = (p) => {
549
+ const findings = p.data.findings ?? [];
550
+ const missed = findings.filter((finding) => finding.missedTrigger === true).length;
551
+ return `review recorded for the current commit and origin/main (${p.data.exitReason}; ${findings.length} finding(s), ${missed} missed trigger(s))`;
552
+ };
509
553
  if (action === "step") {
510
554
  const selected = ["start", "done", "block", "not-applicable"].filter((x) => flags[x]);
511
555
  if (selected.length !== 1) throw new CliError("USAGE", "lane step needs exactly one of --start, --done, --block, or --not-applicable.", 2);
@@ -515,9 +559,12 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
515
559
  if (flags.reason !== undefined && (selected[0] !== "block" || !reason)) throw new CliError("USAGE", "--reason TEXT goes with --block.", 2);
516
560
  const ruling = selected[0] === "not-applicable" ? notApplicableRuling(flags) : null;
517
561
  if (!ruling && (flags["reason-code"] !== undefined || flags.note !== undefined)) throw new CliError("USAGE", "--reason-code and --note go with --not-applicable.", 2);
562
+ if (flags.review !== undefined && (selected[0] !== "done" || typeof flags.review !== "string" || !flags.review)) throw new CliError("USAGE", "--review REVIEW.json goes with --done.", 2);
518
563
  const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`)).data;
519
564
  const step = lane.steps.find((x) => x.side === side && (x.id === stepRef || x.stepKey === stepRef));
520
565
  if (!step) throw new CliError("STEP_NOT_FOUND", `No ${side} step ${stepRef} on ${ref}.`, 1);
566
+ // A review goes only with a step it can finish; record a late one with lane review.
567
+ if (flags.review !== undefined && step.state !== "current") throw new CliError("INVALID_STEP_STATE", `${side}/${step.stepKey} on ${ref} is ${step.state}, not current; nothing was recorded. To store the review anyway, run nodii-dev lane review ${ref} --file ${flags.review}.`, 1);
521
568
  // Dev finishes gather and starts the next step itself when Gather's Freeze
522
569
  // is written (NODII-DEV-CLI-3), so asking for that again is already true.
523
570
  const already = { done: "done", start: "current" }[selected[0]];
@@ -541,7 +588,18 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
541
588
  await recordReason();
542
589
  return { payload: { data: step, reason }, human: `${ref}: ${side}/${step.stepKey} is still blocked; new reason recorded.` };
543
590
  }
544
- const payload = await request("POST", `/api/v1/lanes/${encode(id)}/steps/${encode(step.id)}/${selected[0]}`, via);
591
+ // MW1-68: the build step's review is recorded first; a refused review leaves the step open.
592
+ const review = flags.review === undefined ? null : await recordReview({ file: flags.review });
593
+ let payload;
594
+ try {
595
+ payload = await request("POST", `/api/v1/lanes/${encode(id)}/steps/${encode(step.id)}/${selected[0]}`, via);
596
+ } catch (error) {
597
+ if (!review && ["REVIEW_NOT_RECORDED", "REVIEW_NOT_CLEAN"].includes(error?.code))
598
+ throw new CliError(error.code, `${side}/${step.stepKey} on ${ref} needs a stored review record whose latest entry is CLEAN, or a NOT-REQUIRED record citing a ruling (hub-58). Run lane step ${ref} --side ${side} --done ${step.stepKey} --review REVIEW.json, or lane review ${ref} --not-required --decision DEC-nnnn|--question ID|--quote TEXT --at ISO-TIME first.`, 1);
599
+ if (!review) throw error;
600
+ throw new CliError(error?.code ?? "STEP_DONE_FAILED", `${ref}: the review is recorded, but ${side}/${step.stepKey} was not marked done (${error?.message ?? error}). Run the same lane step --done without --review.`, 1);
601
+ }
602
+ if (review) return { payload: { ...payload, review: review.data }, human: `${ref}: ${side}/${step.stepKey} done; ${reviewSummary(review)}.` };
545
603
  if (reason) {
546
604
  try {
547
605
  await recordReason();
@@ -562,7 +620,17 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
562
620
  const value = typeof flags.evidence === "string" ? flags.evidence.trim() : "";
563
621
  if (!value) throw new CliError("USAGE", "lane done-check needs --evidence TEXT|URL: what shows the lane's done check is met.", 2);
564
622
  const body = { kind: "done_check", ...(/^https?:\/\//i.test(value) && URL.canParse(value) ? { url: value } : { ref: value }), ...via };
565
- const payload = await request("POST", `/api/v1/lanes/${encode(id)}/evidence`, body);
623
+ let payload;
624
+ try {
625
+ payload = await request("POST", `/api/v1/lanes/${encode(id)}/evidence`, body);
626
+ } catch (error) {
627
+ // A lane already verified on dev (or closed) has finished: its run ends
628
+ // finished on exit (lane-supervisor.js), so a later done check is a no-op.
629
+ if (error?.code !== "LANE_TERMINAL") throw error;
630
+ const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`)).data;
631
+ if (!doneCheckRecorded(lane)) throw error;
632
+ return { payload: { data: lane }, human: `${ref} is already ${lane.status}, which counts as its done check; nothing more to record. The lane's run ends finished when its process exits.` };
633
+ }
566
634
  if (!payload?.data?.doneCheckRecorded)
567
635
  throw new CliError("DONE_CHECK_NOT_RECORDED", `Dev took the evidence but does not show ${ref}'s done check as recorded; it predates NODII-DEV-CLI-4. The run would still end orphaned.`, 1);
568
636
  return { payload, human: `${ref}: done check recorded. The lane's run ends finished when its process exits.` };
@@ -617,29 +685,7 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
617
685
  };
618
686
  }
619
687
  if (action === "land") return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/land`, via), human: `${ref}: recorded on main.` };
620
- if (action === "review") {
621
- const body = await reviewBody(flags, read);
622
- const current = await reviewContext();
623
- if (body.head !== current.commit || body.base !== current.base)
624
- throw new CliError(
625
- "REVIEW_FILE_STALE",
626
- `The review file covers base ${body.base ?? "(missing)"} and head ${body.head ?? "(missing)"}, but this worktree is base ${current.base} and head ${current.commit}. Review the current diff and write a new receipt.`,
627
- 2,
628
- );
629
- const payload = await request("POST", `/api/v1/lanes/${encode(id)}/review`, body);
630
- try { await reviewReceipt(); }
631
- catch (error) {
632
- throw new CliError("REVIEW_RECEIPT_FAILED", `${ref}: Dev recorded the review, but the local push receipt could not be written (${error?.message ?? error}). Re-run nodii-dev lane review ${ref} --file REVIEW.json from its worktree.`, 1);
633
- }
634
- return {
635
- payload,
636
- human: (p) => {
637
- const findings = p.data.findings ?? [];
638
- const missed = findings.filter((finding) => finding.missedTrigger === true).length;
639
- return `${ref}: review recorded for the current commit and origin/main (${p.data.exitReason}; ${findings.length} finding(s), ${missed} missed trigger(s)).`;
640
- },
641
- };
642
- }
688
+ if (action === "review") return { payload: await recordReview(), human: (p) => `${ref}: ${reviewSummary(p)}.` };
643
689
  if (action === "reviews") {
644
690
  const payload = await request("GET", `/api/v1/lanes/${encode(id)}`);
645
691
  return {
@@ -651,7 +697,7 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
651
697
  .map((run) => {
652
698
  const missed = run.findings.filter((finding) => finding.missedTrigger === true).length;
653
699
  return [
654
- `${run.createdAt}\t${run.head}\t${run.exitReason}\t${run.findings.length} finding(s)\t${missed} missed trigger(s)`,
700
+ `${run.createdAt}\t${run.head}\t${run.exitReason}\t${run.findings.length} finding(s)\t${missed} missed trigger(s)${run.notRequiredKind ? `\tcites ${run.notRequiredKind} ${run.notRequiredKind === "operator_quote" ? `"${run.notRequiredQuote}" (${run.notRequiredAt})` : run.notRequiredRef}` : ""}`,
655
701
  ...run.findings.map(
656
702
  (finding) =>
657
703
  ` ${finding.severity}\t${finding.perspective}\t${finding.clauseId ?? "-"}\t${finding.summary}${finding.missedTrigger ? "\tMISSED_TRIGGER" : ""}`,
@@ -723,7 +769,7 @@ export async function nextCommand(ref, flags, { request = devRequest, changedPat
723
769
  const lane = (await request("GET", `/api/v1/lanes/${encode(ref)}`)).data;
724
770
  const pending = lane.steps.filter((x) => ["waiting", "current", "blocked"].includes(x.state)).sort((a, b) => a.position - b.position);
725
771
  const steps = ["backend", "frontend"].map((side) => pending.find((x) => x.side === side)).filter(Boolean);
726
- 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}` }));
772
+ 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}${step.state === "current" && REVIEW_STEPS.has(step.stepKey) ? " --review REVIEW.json" : ""}` }));
727
773
  // M05-32: the clauses Dev serves for this step. A failure here never hides the next step.
728
774
  let rules = null;
729
775
  if (data.length || flags.deltaContext) {
package/dist/nudges.js CHANGED
@@ -9,7 +9,9 @@ import { execFile } from "node:child_process";
9
9
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
10
10
  import { dirname, isAbsolute, relative, resolve } from "node:path";
11
11
  import { promisify } from "node:util";
12
+ import { devRequest, encode } from "./core.js";
12
13
  import { isFigmaTool, isReadTool } from "./hooks.js";
14
+ import { continueTaskMessage, doneCheckRecorded, laneBlocked } from "./lane-supervisor.js";
13
15
  import { patchPaths } from "./safety.js";
14
16
 
15
17
  const execFileAsync = promisify(execFile);
@@ -17,6 +19,7 @@ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "apply
17
19
  const SHELL_TOOLS = new Set(["Bash", "shell", "exec_command"]);
18
20
  export const LANE_KNOWLEDGE = "nodii-lane-knowledge.json";
19
21
  export const SUBMIT_CHECKS = "nodii-submit-checks.json";
22
+ export const STOP_GATE = "nodii-stop-gate.json";
20
23
  export const SUBMIT_CHECK_FLAGS = ["unused-properties", "stale-placeholders", "dialog-body"];
21
24
  const OPEN_MD = "docs/figma-ux-drive/qa-with-abhi/open.md";
22
25
  const DS_SOURCE = /["'](@cognion-nucleus\/nodii-(?:design-system|blocks)(?:\/[^"']*)?)["']/;
@@ -414,10 +417,50 @@ export async function lastAssistantText(payload) {
414
417
  const openUnconfirmed = (lane) => [lane.freeze?.unconfirmed, lane.unconfirmed].flat().filter(Boolean).filter((item) =>
415
418
  typeof item === "string" || !(item?.resolvedAt || item?.confirmedAt || ["resolved", "confirmed", "dropped"].includes(item?.status)));
416
419
 
420
+ const stopBlockLimit = (env) => {
421
+ const value = Number(env.NODII_DEV_STOP_BLOCK_LIMIT ?? 5);
422
+ return Number.isInteger(value) && value >= 0 ? value : 5;
423
+ };
424
+
425
+ /**
426
+ * NODII-DEV-CLI-8 (hub-58, gate for AGENT-HARNESS-13): a lane session may not
427
+ * stop before its done check is recorded unless the lane is BLOCKED. The
428
+ * lane is read fresh; an unreadable lane lets the stop through (fail open).
429
+ * Each session is held at most NODII_DEV_STOP_BLOCK_LIMIT times (5) in a row,
430
+ * a cost backstop; the launch supervisor then continues it (NODII-DEV-CLI-9).
431
+ */
432
+ export async function doneCheckStopGate(payload, context, { request = devRequest, env = process.env, state = (update) => writePrivateJson(context.root, STOP_GATE, update) } = {}) {
433
+ if (context?.role !== "lane" || !context.lane?.key) return null;
434
+ let lane;
435
+ try { lane = (await request("GET", `/api/v1/lanes/${encode(context.lane.key)}`)).data; } catch { return null; }
436
+ const session = String(payload.session_id ?? "");
437
+ const forget = (before) => { const sessions = { ...before.sessions }; delete sessions[session]; return { sessions }; };
438
+ if (!lane || doneCheckRecorded(lane) || laneBlocked(lane)) {
439
+ await state(forget).catch(() => {});
440
+ return null;
441
+ }
442
+ let held = 0;
443
+ try {
444
+ await state((before) => {
445
+ held = (before.sessions?.[session] ?? 0) + 1;
446
+ return held > stopBlockLimit(env) ? forget(before) : { sessions: { ...before.sessions, [session]: held } };
447
+ });
448
+ } catch {
449
+ // No count to hold against: block once, as the other Stop nudges do.
450
+ held = payload.stop_hook_active ? Number.POSITIVE_INFINITY : 1;
451
+ }
452
+ if (held > stopBlockLimit(env)) return null;
453
+ return continueTaskMessage(context.lane.key, lane);
454
+ }
455
+
417
456
  /** Stop nudges. A lane that has just been nudged once is left to stop (stop_hook_active). */
418
- export async function nudgeStop(payload, context, { lastText = lastAssistantText, status = (root) => git(root, "status", "--porcelain") } = {}) {
457
+ export async function nudgeStop(payload, context, { lastText = lastAssistantText, status = (root) => git(root, "status", "--porcelain"), gate = doneCheckStopGate } = {}) {
419
458
  const lane = context?.lane;
420
- if (!lane || payload.stop_hook_active) return [];
459
+ if (!lane) return [];
460
+ // The done-check gate holds the stop even after an earlier block (stop_hook_active).
461
+ const held = await gate(payload, context).catch(() => null);
462
+ if (held) return [held];
463
+ if (payload.stop_hook_active) return [];
421
464
  const notes = [];
422
465
  const text = await lastText(payload);
423
466
  if (claimsDone(text) && lane.status !== "on_dev")
package/dist/safety.js CHANGED
@@ -95,10 +95,11 @@ function gitInvocations(tokens) {
95
95
 
96
96
  function commandRef(after, verb) {
97
97
  if (!after.length) return null;
98
- if (["checkout", "reset"].includes(verb) && after[0] === "--") return null;
99
98
  let skip = false;
100
99
  for (const item of after) {
101
100
  if (skip) { skip = false; continue; }
101
+ // Everything after `--` is a pathspec (`git reset -q -- file` unstages), never a ref.
102
+ if (item === "--" && ["checkout", "reset"].includes(verb)) return null;
102
103
  if (["-b", "-B", "--orphan", "--pathspec-from-file"].includes(item)) { skip = true; continue; }
103
104
  if (item.startsWith("-")) continue;
104
105
  return item;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.43",
3
+ "version": "0.6.45",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {