@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 +47 -3
- package/dist/cli.js +7 -2
- package/dist/core.js +2 -2
- package/dist/lane-supervisor.js +126 -13
- package/dist/lanes.js +73 -27
- package/dist/nudges.js +45 -2
- package/dist/safety.js +2 -1
- package/package.json +1 -1
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 #
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 = [];
|
package/dist/lane-supervisor.js
CHANGED
|
@@ -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
|
-
*
|
|
80
|
-
*
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|