@nodii/dev 0.6.44 → 0.6.46
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 +54 -3
- package/dist/cli.js +4 -0
- package/dist/core.js +2 -2
- package/dist/doctor.js +2 -2
- package/dist/gather.js +78 -21
- package/dist/lane-supervisor.js +144 -14
- package/dist/lanes.js +73 -27
- package/dist/nudges.js +45 -2
- package/dist/safety.js +2 -1
- package/dist/watchdog.js +22 -5
- 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
|
|
@@ -420,6 +427,10 @@ without ending their run, `/tmp/cc-socks/<pid>.sock` sockets whose process is
|
|
|
420
427
|
gone, and watched worktrees whose HEAD has commits absent from its configured
|
|
421
428
|
remote branch. Each observation is posted to the watched run by the machine
|
|
422
429
|
coordinator; `process_dead` transitions it to explicit `orphan` immediately.
|
|
430
|
+
Every watched run whose PID is alive (or whose lane supervisor is, between
|
|
431
|
+
same-session continuations) is reported as `process_alive`. Dev counts that as
|
|
432
|
+
the run's liveness (hub-58, AH-4), so a late heartbeat never makes a working
|
|
433
|
+
run quiet or orphaned.
|
|
423
434
|
A sweep leaves a dead lane runtime alone while its lane supervisor still lives
|
|
424
435
|
(the supervisor reports the exit itself) and skips it once the supervisor has
|
|
425
436
|
reported.
|
|
@@ -505,7 +516,15 @@ covers `dialog-content`). Edits are judged against the file they produce, so
|
|
|
505
516
|
an Edit or `apply_patch` that only changes an attribute is read in full.
|
|
506
517
|
|
|
507
518
|
The Stop nudge asks the session to continue once (`decision: block`, skipped
|
|
508
|
-
when `stop_hook_active`); Codex passes it through its managed dispatcher.
|
|
519
|
+
when `stop_hook_active`); Codex passes it through its managed dispatcher.
|
|
520
|
+
Before that, in a lane session (a `lane-*.json` identity), the same hook holds
|
|
521
|
+
the stop while the lane's done check is not recorded and the lane is not
|
|
522
|
+
BLOCKED (NODII-DEV-CLI-8), even after an earlier block: it reads the lane from
|
|
523
|
+
Dev and answers `Lane KEY: done check not recorded. Continue the Task: STEP.`
|
|
524
|
+
Recording the done check (`lane done-check`), a blocked step (`lane step
|
|
525
|
+
--block`) or a heartbeat note that starts with "blocked" lets the stop
|
|
526
|
+
through, and so does a lane Dev can't serve. One session is held at most
|
|
527
|
+
`NODII_DEV_STOP_BLOCK_LIMIT` times in a row (default 5), as a cost backstop. A
|
|
509
528
|
nudge hook skips the amendment guard and the waiting-results banner, since
|
|
510
529
|
either would corrupt its JSON; it cannot run a Delta gather.
|
|
511
530
|
|
|
@@ -939,6 +958,22 @@ counts as unfinished. Every report carries `exit_code`, `worktree` and
|
|
|
939
958
|
`lane_status` evidence. `process.pid` still holds the runtime's own PID, and the
|
|
940
959
|
supervisor appends its report line to `process.log`.
|
|
941
960
|
|
|
961
|
+
Before it reports an unfinished exit, the supervisor continues the same session
|
|
962
|
+
(NODII-DEV-CLI-9) when the lane is not BLOCKED and the run log names the
|
|
963
|
+
session (Claude's `session_id`, Codex's `thread_id`). It runs `claude ...
|
|
964
|
+
--resume SESSION`, or `codex exec resume ... SESSION -`, with the same model
|
|
965
|
+
and permission mode, and sends `Lane KEY: done check not recorded. Continue the
|
|
966
|
+
Task: STEP.` on stdin. It does this up to `NODII_DEV_LANE_CONTINUATIONS` times
|
|
967
|
+
(default 3; 0 turns it off). Each continuation is a run heartbeat, and
|
|
968
|
+
`process.pid` and the watchdog record follow the new PID. A killed runtime
|
|
969
|
+
(a signal) is never continued. While the runtime lives, continuations
|
|
970
|
+
included, the supervisor heartbeats its run every
|
|
971
|
+
`NODII_DEV_RUN_HEARTBEAT_SECONDS` (60) as the lane identity that owns it;
|
|
972
|
+
nothing else heartbeats a launched lane's run. After the last continuation, the exit is
|
|
973
|
+
reported as above and its reason counts the continuations. A supervisor runs
|
|
974
|
+
the CLI version that started it, so a fix to this rule reaches a lane only
|
|
975
|
+
when it is launched or resumed on that version.
|
|
976
|
+
|
|
942
977
|
A lane's Figma scope is held by published key only (MW8-20): `--figma
|
|
943
978
|
FILE_KEY` for a whole file, or `--figma FILE_KEY=COMPONENT_SET_KEY[,KEY]` for
|
|
944
979
|
its component sets. A node id is refused. `--route /app/...` (repeatable)
|
|
@@ -1064,6 +1099,22 @@ commit in `base`, exact reviewed commit in `head`,
|
|
|
1064
1099
|
`rules`), `severity`, `summary`, `detail`, `disposition`, optional `path` and
|
|
1065
1100
|
`line`, and a required `clauseId` for rules findings. `lane reviews` reads the
|
|
1066
1101
|
append-only history, including whether each cited clause was a missed trigger.
|
|
1102
|
+
Dev stores a review while the lane is `building` (the build step's review,
|
|
1103
|
+
MW1-68) as well as `built` or `review`. `lane step KEY --done STEP --review
|
|
1104
|
+
REVIEW.json` records the same file the same way, then marks the step done;
|
|
1105
|
+
`nodii-dev next` prints `--review REVIEW.json` on the `build_test`,
|
|
1106
|
+
`adopt_test` and `local_test` done command.
|
|
1107
|
+
|
|
1108
|
+
Dev completes those three steps only when the lane's latest review record is
|
|
1109
|
+
CLEAN or NOT-REQUIRED (hub-58, 30 Oct); otherwise `lane step --done` gets
|
|
1110
|
+
`REVIEW_NOT_RECORDED` or `REVIEW_NOT_CLEAN`. `lane review KEY --not-required`
|
|
1111
|
+
stores a NOT-REQUIRED record for the current base and head, citing one ruling
|
|
1112
|
+
Dev checks: `--decision` (a locked decision, `DEC-nnnn` or a pre-dev id),
|
|
1113
|
+
`--question` (an answered Dev question id), or `--quote TEXT --at ISO-TIME`
|
|
1114
|
+
(found verbatim in a Dev inbox answer within ten minutes of that time). Free
|
|
1115
|
+
text is not a citation; Dev refuses an unknown one with
|
|
1116
|
+
`REVIEW_RULING_NOT_FOUND` or `REVIEW_RULING_SUPERSEDED`. It writes the same
|
|
1117
|
+
local push receipt as a review.
|
|
1067
1118
|
|
|
1068
1119
|
Heartbeat notes are optional and limited by Dev to 500 characters.
|
|
1069
1120
|
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
|
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.46";
|
|
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/doctor.js
CHANGED
|
@@ -5,7 +5,7 @@ import { devRequest, encode, isOAuthConfig, query, setAuthProvider } from "./cor
|
|
|
5
5
|
import { OAuthAuthProvider, decodeClaims, loadRefreshToken } from "./oauth.js";
|
|
6
6
|
import { inferGitContext, repoName } from "./lanes.js";
|
|
7
7
|
import { figmaFileKey, figmaLockCheck, holdsSet, notRegisteredHint, scopeText } from "./figma.js";
|
|
8
|
-
import { PICKER_MODEL, PICKER_MODEL_DIGEST, inspectOllama } from "./gather.js";
|
|
8
|
+
import { PICKER_MODEL, PICKER_MODEL_DIGEST, STALE_PIN_HINT, inspectOllama } from "./gather.js";
|
|
9
9
|
import { hookDiagnostics } from "./hook-doctor.js";
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -274,7 +274,7 @@ export async function doctorCommand(deps = {}) {
|
|
|
274
274
|
model: PICKER_MODEL, digest: picker.digest ?? null, size: picker.size ?? null,
|
|
275
275
|
}));
|
|
276
276
|
else if (!picker.modelInstalled) checks.push(missing("picker_model", `${PICKER_MODEL} is not installed`, "nodii-dev setup --yes", { model: PICKER_MODEL }));
|
|
277
|
-
else checks.push(missing("picker_model", `${PICKER_MODEL} digest is ${picker.digest ?? "unknown"};
|
|
277
|
+
else checks.push(missing("picker_model", `${PICKER_MODEL} digest is ${picker.digest ?? "unknown"}; this CLI pins ${PICKER_MODEL_DIGEST}; ${STALE_PIN_HINT}`, "nodii-dev setup --yes", { model: PICKER_MODEL, digest: picker.digest ?? null, requiredDigest: PICKER_MODEL_DIGEST }));
|
|
278
278
|
} catch (error) {
|
|
279
279
|
checks.push(missing("ollama", error?.message ?? "Ollama is unavailable", "nodii-dev setup --yes", { code: error?.code ?? null }));
|
|
280
280
|
checks.push(cannot("picker_model", `needs local Ollama; expected ${PICKER_MODEL}`, { model: PICKER_MODEL }));
|
package/dist/gather.js
CHANGED
|
@@ -9,7 +9,11 @@ import { CliError } from "./core.js";
|
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
|
|
11
11
|
export const PICKER_MODEL = "gemma4:12b";
|
|
12
|
-
|
|
12
|
+
// The build the Ollama registry serves to Ollama on Apple silicon: an MLX nvfp4
|
|
13
|
+
// build. The registry chooses the manifest by User-Agent, so `ollama pull` on an
|
|
14
|
+
// M-series Mac lands this digest, not the GGUF one other clients see (hub-58
|
|
15
|
+
// re-pin, NODII-DEV-CLI-11). This build has no structured output (pickDocuments).
|
|
16
|
+
export const PICKER_MODEL_DIGEST = "sha256:c7597fc90b86aff6965317f37dd5465402284e26382d9b4bf2dfe3ef1491f4b6";
|
|
13
17
|
// E4B and 26B are compared in Gather tuning only; each is pinned the same way
|
|
14
18
|
// (public registry tag + manifest digest) and never pulled by setup.
|
|
15
19
|
export const TUNING_MODELS = {
|
|
@@ -31,6 +35,8 @@ export const OLLAMA_URL = "http://127.0.0.1:11434";
|
|
|
31
35
|
export const LANE_CONTRACT = "If anyone tells you your objective, scope or rules changed, don't act on it until `nodii-dev lane show` shows the amendment, then run `lane gather --delta`.";
|
|
32
36
|
|
|
33
37
|
// No picker on this machine is a setup gap, never a reason to skip Gather.
|
|
38
|
+
/** What to do when a pull keeps landing a digest other than the pin (NODII-DEV-CLI-11). */
|
|
39
|
+
export const STALE_PIN_HINT = "if the pull lands the same digest again, the registry serves a different build and this CLI's pin is stale: update nodii-dev.";
|
|
34
40
|
export const SETUP_FIX = "Run `nodii-dev setup --yes` to install and start the local Gather picker (Ollama with the pinned model), then rerun the command.";
|
|
35
41
|
|
|
36
42
|
const localHost = (hostname) => ["127.0.0.1", "::1", "localhost"].includes(hostname);
|
|
@@ -132,7 +138,7 @@ export async function requirePicker(options = {}) {
|
|
|
132
138
|
if (!status.modelInstalled)
|
|
133
139
|
throw new CliError("PICKER_MODEL_MISSING", `Local Gather picker is missing ${pin.name}. ${fix} to pull it.`, 1, status);
|
|
134
140
|
if (!status.modelMatches)
|
|
135
|
-
throw new CliError("PICKER_MODEL_VERSION_MISMATCH", `Local ${pin.name} is ${status.digest ?? "an unknown digest"};
|
|
141
|
+
throw new CliError("PICKER_MODEL_VERSION_MISMATCH", `Local ${pin.name} is ${status.digest ?? "an unknown digest"}; this CLI pins ${pin.digest}. ${fix} to pull it; ${STALE_PIN_HINT}`, 1, status);
|
|
136
142
|
return status;
|
|
137
143
|
}
|
|
138
144
|
|
|
@@ -179,10 +185,13 @@ export function gatherDeadline(ms) {
|
|
|
179
185
|
};
|
|
180
186
|
}
|
|
181
187
|
|
|
182
|
-
function pickerPrompt({ objective, files = [], triggers }) {
|
|
188
|
+
function pickerPrompt({ objective, files = [], triggers }, schema) {
|
|
183
189
|
return [
|
|
184
190
|
"Select every rule document that applies to this software task.",
|
|
185
|
-
|
|
191
|
+
schema
|
|
192
|
+
? `Return only a JSON object matching this JSON schema, with no other text: ${JSON.stringify(schema)}`
|
|
193
|
+
: "Return only JSON matching the supplied schema.",
|
|
194
|
+
"Never invent a document code.",
|
|
186
195
|
`Objective: ${objective}`,
|
|
187
196
|
`Files: ${files.length ? files.join(", ") : "not known yet"}`,
|
|
188
197
|
"Trigger shortlist:",
|
|
@@ -194,11 +203,13 @@ const forced = (trigger) => Array.isArray(trigger?.forcedBy) ? trigger.forcedBy.
|
|
|
194
203
|
|
|
195
204
|
/**
|
|
196
205
|
* Why a lane Gather hands the pick to the agent instead (hub-58, 30 Oct): this
|
|
197
|
-
* Mac has no picker model, or no Ollama at all
|
|
206
|
+
* Mac has no picker model, or no Ollama at all, or a model without structured
|
|
207
|
+
* output answered invalid JSON twice (Q-155). Null for every other failure.
|
|
198
208
|
*/
|
|
199
209
|
export function agentFallbackReason(error) {
|
|
200
210
|
if (error?.code === "PICKER_MODEL_MISSING") return `${error.details?.model ?? PICKER_MODEL} not installed; run nodii-dev setup`;
|
|
201
211
|
if (error?.code === "PICKER_UNAVAILABLE" && error.details?.unreachable) return `no local Ollama at ${error.details.url}; run nodii-dev setup`;
|
|
212
|
+
if (error?.code === "PICKER_RESPONSE_INVALID" && error.details?.unconstrained) return "gemma output invalid";
|
|
202
213
|
return null;
|
|
203
214
|
}
|
|
204
215
|
|
|
@@ -216,24 +227,23 @@ export async function pickDocuments(input, options = {}) {
|
|
|
216
227
|
const started = Date.now();
|
|
217
228
|
try {
|
|
218
229
|
await requirePicker(timedOptions);
|
|
219
|
-
const
|
|
230
|
+
const schema = pickSchema(input.triggers);
|
|
231
|
+
const chat = (messages, extra) => jsonRequest("/api/chat", {
|
|
220
232
|
...timedOptions,
|
|
221
|
-
body: {
|
|
222
|
-
model,
|
|
223
|
-
stream: false,
|
|
224
|
-
format: pickSchema(input.triggers),
|
|
225
|
-
messages: [{ role: "user", content: pickerPrompt(input) }],
|
|
226
|
-
options: { num_ctx: PICKER_CONTEXT, temperature: 0 },
|
|
227
|
-
},
|
|
233
|
+
body: { model, stream: false, ...extra, messages, options: { num_ctx: PICKER_CONTEXT, temperature: 0 } },
|
|
228
234
|
});
|
|
235
|
+
let payload;
|
|
229
236
|
let result;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
237
|
+
let attempts = 1;
|
|
238
|
+
try {
|
|
239
|
+
payload = await chat([{ role: "user", content: pickerPrompt(input) }], { format: schema });
|
|
240
|
+
result = checkPick(payload.message?.content, schema);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (!structuredOutputUnavailable(error)) throw error;
|
|
243
|
+
({ payload, result, attempts } = await unconstrainedPick(chat, input, schema, model));
|
|
244
|
+
}
|
|
235
245
|
const required = input.triggers.filter(forced).map((x) => x.code);
|
|
236
|
-
return { documents: [...new Set([...result.documents, ...required])], model, context: PICKER_CONTEXT, latency: pickLatency(payload, Date.now() - started) };
|
|
246
|
+
return { documents: [...new Set([...result.documents, ...required])], model, context: PICKER_CONTEXT, structured: !result.unconstrained, attempts, latency: pickLatency(payload, Date.now() - started) };
|
|
237
247
|
} catch (error) {
|
|
238
248
|
// Our own deadline, not the caller's: stop the stuck pick so nothing queues behind it.
|
|
239
249
|
if (controller.signal.aborted && !options.signal?.aborted) {
|
|
@@ -247,6 +257,52 @@ export async function pickDocuments(input, options = {}) {
|
|
|
247
257
|
}
|
|
248
258
|
}
|
|
249
259
|
|
|
260
|
+
const structuredOutputUnavailable = (error) => error?.code === "PICKER_UNAVAILABLE" && /structured output is unavailable/i.test(error.message ?? "");
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A pick from a build without structured output (the MLX gemma4:12b, Q-155 via
|
|
264
|
+
* hub-58): ask for the schema in the prompt and validate the answer after. One
|
|
265
|
+
* retry carries the validation error; a second invalid answer is
|
|
266
|
+
* PICKER_RESPONSE_INVALID with `unconstrained`, which a lane Gather hands to
|
|
267
|
+
* the agent picker.
|
|
268
|
+
*/
|
|
269
|
+
async function unconstrainedPick(chat, input, schema, model) {
|
|
270
|
+
const messages = [{ role: "user", content: pickerPrompt(input, schema) }];
|
|
271
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
272
|
+
// Without `format` Ollama turns Gemma's thinking on, which spends the
|
|
273
|
+
// whole pick budget reasoning (ah-17 live run: 120 s timeout vs 0.5 s).
|
|
274
|
+
const payload = await chat(messages, { think: false });
|
|
275
|
+
const content = payload.message?.content;
|
|
276
|
+
try {
|
|
277
|
+
return { payload, result: { ...checkPick(content, schema, { fenced: true }), unconstrained: true }, attempts: attempt };
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error?.code !== "PICKER_RESPONSE_INVALID") throw error;
|
|
280
|
+
if (attempt === 2)
|
|
281
|
+
throw new CliError("PICKER_RESPONSE_INVALID", `Local Gather picker ${model} has no structured output and answered invalid JSON twice: ${error.message}`, 1, { unconstrained: true, model, attempts: attempt });
|
|
282
|
+
messages.push({ role: "assistant", content: String(content ?? "") }, { role: "user", content: `That answer is invalid: ${error.message} Answer again with only the JSON object.` });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Check a pick against its schema: an object with only `documents`, a list of
|
|
289
|
+
* shortlist codes (duplicates are removed after, as for the schema path).
|
|
290
|
+
* `fenced` also accepts the Markdown code fence an unconstrained model wraps
|
|
291
|
+
* its JSON in.
|
|
292
|
+
*/
|
|
293
|
+
export function checkPick(content, schema, { fenced = false } = {}) {
|
|
294
|
+
const text = fenced ? String(content ?? "").replace(/^\s*```(?:json)?\s*\n?([\s\S]*?)\n?\s*```\s*$/, "$1") : content;
|
|
295
|
+
let result;
|
|
296
|
+
try { result = JSON.parse(text); }
|
|
297
|
+
catch { throw new CliError("PICKER_RESPONSE_INVALID", "Local Gather picker did not return its required JSON document list.", 1); }
|
|
298
|
+
const { enum: codes } = schema.properties.documents.items;
|
|
299
|
+
if (!result || typeof result !== "object" || Array.isArray(result) || Object.keys(result).some((key) => key !== "documents"))
|
|
300
|
+
throw new CliError("PICKER_RESPONSE_INVALID", "Local Gather picker must return a JSON object with only a `documents` list.", 1);
|
|
301
|
+
if (!Array.isArray(result.documents) || result.documents.some((x) => typeof x !== "string" || !codes.includes(x)))
|
|
302
|
+
throw new CliError("PICKER_RESPONSE_INVALID", "Local Gather picker returned a document outside the trigger shortlist.", 1);
|
|
303
|
+
return { documents: result.documents };
|
|
304
|
+
}
|
|
305
|
+
|
|
250
306
|
/**
|
|
251
307
|
* The pick's JSON schema. No `uniqueItems`: it wedges Ollama's grammar
|
|
252
308
|
* sampler on real Gather prompts (ah-19 sandbox finding, 30 Oct). Duplicates
|
|
@@ -510,7 +566,7 @@ export async function setupOllama({ check = false, inspect = inspectOllama, exec
|
|
|
510
566
|
const current = await inspect();
|
|
511
567
|
if (current.modelInstalled && current.modelMatches) return { changed: [], ...current };
|
|
512
568
|
if (check && current.modelInstalled)
|
|
513
|
-
throw new CliError("PICKER_MODEL_VERSION_MISMATCH", `${PICKER_MODEL} digest is ${current.digest ?? "unknown"};
|
|
569
|
+
throw new CliError("PICKER_MODEL_VERSION_MISMATCH", `${PICKER_MODEL} digest is ${current.digest ?? "unknown"}; this CLI pins ${PICKER_MODEL_DIGEST}. Run \`nodii-dev setup --yes\` to pull it; ${STALE_PIN_HINT}`, 1, current);
|
|
514
570
|
if (check) throw new CliError("PICKER_MODEL_MISSING", `${PICKER_MODEL} is not installed.`, 1, current);
|
|
515
571
|
} catch (error) {
|
|
516
572
|
if (check || !["PICKER_UNAVAILABLE", "PICKER_MODEL_MISSING"].includes(error?.code)) throw error;
|
|
@@ -522,6 +578,7 @@ export async function setupOllama({ check = false, inspect = inspectOllama, exec
|
|
|
522
578
|
await run("ollama", ["pull", PICKER_MODEL], exec);
|
|
523
579
|
const ready = await inspect();
|
|
524
580
|
if (!ready.modelInstalled) throw new CliError("PICKER_MODEL_MISSING", `Ollama pull finished but ${PICKER_MODEL} is not installed.`, 1, ready);
|
|
525
|
-
|
|
581
|
+
// A fresh pull is what the registry serves now, so a mismatch here is a stale pin.
|
|
582
|
+
if (!ready.modelMatches) throw new CliError("PICKER_MODEL_VERSION_MISMATCH", `The Ollama registry serves ${PICKER_MODEL} at ${ready.digest ?? "an unknown digest"}, but this CLI pins ${PICKER_MODEL_DIGEST}: the pin is stale, so pulling again will not help. Update nodii-dev to a release with the current pin.`, 1, { ...ready, stalePin: true });
|
|
526
583
|
return { changed: [PICKER_MODEL], ...ready };
|
|
527
584
|
}
|
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,66 @@ 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
|
+
// The run's expected heartbeat is 60 s; Dev marks it quiet at 15 min and orphan at 45.
|
|
229
|
+
const heartbeatInterval = (env) => {
|
|
230
|
+
const seconds = Number(env.NODII_DEV_RUN_HEARTBEAT_SECONDS ?? 60);
|
|
231
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 60_000;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const continuationLimit = (env) => {
|
|
235
|
+
const value = Number(env.NODII_DEV_LANE_CONTINUATIONS ?? 3);
|
|
236
|
+
return Number.isInteger(value) && value >= 0 ? value : 3;
|
|
237
|
+
};
|
|
238
|
+
|
|
159
239
|
/** The supervisor process: spawn the runtime, hand its PID back, report its exit. */
|
|
240
|
+
|
|
160
241
|
export async function superviseLane({
|
|
161
242
|
channel = process,
|
|
162
243
|
spawn = nodeSpawn,
|
|
163
244
|
report = reportLaneExit,
|
|
164
245
|
exit = (code) => process.exit(code),
|
|
246
|
+
request = devRequest,
|
|
247
|
+
rewatch = rewatchRun,
|
|
248
|
+
env = process.env,
|
|
165
249
|
} = {}) {
|
|
166
250
|
if (typeof channel.send !== "function")
|
|
167
251
|
throw new CliError("USAGE", "__lane-supervisor is started by lane launch and lane resume only.", 2);
|
|
@@ -180,6 +264,13 @@ export async function superviseLane({
|
|
|
180
264
|
return exit(1);
|
|
181
265
|
}
|
|
182
266
|
let state = "spawning";
|
|
267
|
+
// The run's heartbeat (30 Oct, hub-58 P0): nothing else heartbeats a launched
|
|
268
|
+
// lane's run, so while the runtime lives (continuations included) the
|
|
269
|
+
// supervisor does, as the lane identity that owns the run.
|
|
270
|
+
const heartbeat = setInterval(() => {
|
|
271
|
+
if (state !== "running") return;
|
|
272
|
+
request("POST", `/api/v1/runs/${encode(runId)}/heartbeat`, { note: `lane runtime pid ${child.pid} alive (supervisor ${process.pid})` }).catch(() => {});
|
|
273
|
+
}, heartbeatInterval(env));
|
|
183
274
|
const closed = new Promise((resolve) => child.once("close", (exitCode, signal) => resolve({ exitCode, signal })));
|
|
184
275
|
child.once("error", (error) => {
|
|
185
276
|
if (state !== "spawning") return;
|
|
@@ -209,11 +300,50 @@ export async function superviseLane({
|
|
|
209
300
|
}
|
|
210
301
|
});
|
|
211
302
|
// A signal sent to the supervisor ends the runtime too, and is reported.
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
303
|
+
let stopping = false;
|
|
304
|
+
for (const name of SIGNALS) process.on(name, () => { stopping = true; child.kill(name); });
|
|
305
|
+
let { exitCode, signal } = await closed;
|
|
306
|
+
if (state !== "running") {
|
|
307
|
+
clearInterval(heartbeat);
|
|
308
|
+
return exit(state === "aborted" ? 0 : 1);
|
|
309
|
+
}
|
|
310
|
+
const limit = continuationLimit(env);
|
|
311
|
+
let continued = 0;
|
|
312
|
+
// NODII-DEV-CLI-9: an early stop continues the same session before it is reported.
|
|
313
|
+
while (!stopping && !signal && continued < limit) {
|
|
314
|
+
const sessionId = await sessionIdFromLog(logPath, invocation.command).catch(() => null);
|
|
315
|
+
if (!sessionId) break;
|
|
316
|
+
let lane;
|
|
317
|
+
try { lane = (await request("GET", `/api/v1/lanes/${encode(laneKey)}`)).data; } catch { break; }
|
|
318
|
+
if (doneCheckRecorded(lane) || laneBlocked(lane)) break;
|
|
319
|
+
const message = continueTaskMessage(laneKey, lane);
|
|
320
|
+
continued += 1;
|
|
321
|
+
await request("POST", `/api/v1/runs/${encode(runId)}/heartbeat`, {
|
|
322
|
+
note: `continuation ${continued}/${limit} of session ${sessionId} after exit ${exitCode}: ${message}`.slice(0, 500),
|
|
323
|
+
}).catch(() => {});
|
|
324
|
+
const next = continuationInvocation(invocation, sessionId);
|
|
325
|
+
try {
|
|
326
|
+
child = spawn(next.command, next.args, { cwd: next.cwd, env: next.env, shell: false, stdio: ["pipe", "inherit", "inherit"] });
|
|
327
|
+
} catch {
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
const ended = new Promise((resolve) => {
|
|
331
|
+
child.once("close", (code, sig) => resolve({ exitCode: code, signal: sig }));
|
|
332
|
+
child.once("error", () => resolve({ exitCode: -1, signal: null }));
|
|
333
|
+
});
|
|
334
|
+
child.once("spawn", () => {
|
|
335
|
+
writeFile(join(dirname(logPath), "process.pid"), `${child.pid}\n`, { mode: 0o600 }).catch(() => {});
|
|
336
|
+
rewatch(runId, child.pid).catch(() => {});
|
|
337
|
+
});
|
|
338
|
+
// A runtime that exits before reading its prompt must not end the supervisor (EPIPE).
|
|
339
|
+
child.stdin?.on("error", () => {});
|
|
340
|
+
child.stdin?.end(`${message}\n`);
|
|
341
|
+
process.stderr.write(`nodii-dev: ${message} (same-session continuation ${continued}/${limit})\n`);
|
|
342
|
+
({ exitCode, signal } = await ended);
|
|
343
|
+
}
|
|
344
|
+
clearInterval(heartbeat);
|
|
215
345
|
try {
|
|
216
|
-
const result = await report({ runId, laneKey, exitCode, signal, worktrees, logPath });
|
|
346
|
+
const result = await report({ runId, laneKey, exitCode, signal, worktrees, logPath, continuations: continued });
|
|
217
347
|
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
348
|
return exit(0);
|
|
219
349
|
} 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;
|
package/dist/watchdog.js
CHANGED
|
@@ -142,11 +142,14 @@ export async function collectWatchdogFacts(records, {
|
|
|
142
142
|
errors = [],
|
|
143
143
|
} = {}) {
|
|
144
144
|
const deadRuns = [];
|
|
145
|
+
const liveRuns = [];
|
|
145
146
|
for (const record of records) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
const alive = await isPidAlive(record.pid, record.processStartedAt);
|
|
148
|
+
// A live lane supervisor is reporting this exit itself (NODII-DEV-CLI-1),
|
|
149
|
+
// and between same-session continuations it is what keeps the run alive.
|
|
150
|
+
const supervised = Boolean(record.supervisorPid) && await isPidAlive(record.supervisorPid, record.supervisorStartedAt);
|
|
151
|
+
if (alive || supervised) liveRuns.push({ runId: record.runId, pid: record.pid, runtimeAlive: alive });
|
|
152
|
+
else deadRuns.push({ runId: record.runId, pid: record.pid, ...(record.worktree ? { worktree: record.worktree } : {}) });
|
|
150
153
|
}
|
|
151
154
|
const unpushedWorktrees = [];
|
|
152
155
|
for (const worktree of new Set(records.map((record) => record.worktree).filter(Boolean))) {
|
|
@@ -160,7 +163,7 @@ export async function collectWatchdogFacts(records, {
|
|
|
160
163
|
let staleSockets = [];
|
|
161
164
|
try { staleSockets = await listStaleSockets(); }
|
|
162
165
|
catch (error) { errors.push({ kind: "socket", code: error?.code ?? "SOCKET_INSPECTION_FAILED" }); }
|
|
163
|
-
return { checkedAt: now().toISOString(), deadRuns, staleSockets, unpushedWorktrees, errors };
|
|
166
|
+
return { checkedAt: now().toISOString(), deadRuns, liveRuns, staleSockets, unpushedWorktrees, errors };
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
async function assertCoordinator(config, request) {
|
|
@@ -249,6 +252,20 @@ export async function sweepWatchdog({
|
|
|
249
252
|
facts.errors.push({ kind: "run", runId: record.runId, observation: "worktree_unpushed", code: error?.code ?? "RUN_WATCHDOG_REPORT_FAILED" });
|
|
250
253
|
}
|
|
251
254
|
}
|
|
255
|
+
// hub-58 (AH-4): a PID this Mac sees alive is the run's liveness, so a run
|
|
256
|
+
// whose heartbeat is late is never quiet or orphaned while it works.
|
|
257
|
+
for (const live of facts.liveRuns ?? []) {
|
|
258
|
+
const record = byId.get(live.runId);
|
|
259
|
+
if (!record || record.orphanReportedAt || record.terminalSeenAt) continue;
|
|
260
|
+
try {
|
|
261
|
+
await request("POST", `/api/v1/runs/${encode(record.runId)}/watchdog`, observationBody({
|
|
262
|
+
observation: "process_alive", machineId: effectiveMachineId, pid: record.pid, observedAt: now().toISOString(),
|
|
263
|
+
detail: live.runtimeAlive ? "Process is alive." : "Its lane supervisor is alive (between same-session continuations).",
|
|
264
|
+
}));
|
|
265
|
+
} catch (error) {
|
|
266
|
+
facts.errors.push({ kind: "run", runId: record.runId, observation: "process_alive", code: error?.code ?? "RUN_WATCHDOG_REPORT_FAILED" });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
252
269
|
for (const dead of facts.deadRuns) {
|
|
253
270
|
const record = byId.get(dead.runId);
|
|
254
271
|
if (!record || record.orphanReportedAt || record.terminalSeenAt) continue;
|