@nodii/dev 0.6.29 → 0.6.30

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/dist/lanes.js CHANGED
@@ -4,9 +4,12 @@ import { promisify } from "node:util";
4
4
  import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./core.js";
5
5
  import { launchRecovery } from "./runtime-launcher.js";
6
6
  import { recordReviewReceipt, reviewCommitContext } from "./safety.js";
7
+ import { GATHER_TIMEOUT_MS, gatherText, pickDocuments } from "./gather.js";
8
+ import { storeGatherFreeze } from "./freeze.js";
7
9
 
8
10
  const execFileAsync = promisify(execFile);
9
11
  const via = { via: "nodii-dev" };
12
+ const GATHER_ACTIVE = new Set(["queued", "building"]);
10
13
  // heartbeatLane's note limit.
11
14
  const BLOCK_NOTE_MAX = 500;
12
15
 
@@ -118,7 +121,34 @@ export async function branchCommits(lane) {
118
121
  }
119
122
  }
120
123
 
121
- export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt, launch = launchRecovery } = {}) {
124
+ async function currentGatherLane(request, config, gitContext) {
125
+ const c = await config();
126
+ const context = await gitContext();
127
+ const lanes = [];
128
+ let cursor;
129
+ do {
130
+ const page = await request("GET", `/api/v1/lanes${query({ agent: c.agentId, cursor, limit: 200 })}`);
131
+ lanes.push(...(page.data ?? []));
132
+ cursor = page.nextCursor ?? undefined;
133
+ } while (cursor);
134
+ const active = lanes.filter((lane) => GATHER_ACTIVE.has(lane.status));
135
+ const repositories = (lane) => lane.repositories?.length
136
+ ? lane.repositories
137
+ : lane.freeze?.boundaries?.repos?.length
138
+ ? lane.freeze.boundaries.repos
139
+ : [{ repo: lane.repos?.[0], branch: lane.branch, worktree: lane.worktree }];
140
+ const exact = active.filter((lane) => repositories(lane).some((repository) => repository.worktree === context.worktree));
141
+ const branch = active.filter((lane) => repositories(lane).some((repository) =>
142
+ repository.branch === context.branch && repoName(repository.repo ?? "") === context.repo));
143
+ const matches = exact.length ? exact : branch;
144
+ if (matches.length !== 1)
145
+ throw new CliError("CURRENT_LANE_AMBIGUOUS", matches.length
146
+ ? `This worktree matches ${matches.length} active lanes (${matches.map((lane) => lane.key).join(", ")}); pass one key explicitly.`
147
+ : "No active lane matches this agent and worktree; pass the lane key explicitly.", 2);
148
+ return matches[0].key;
149
+ }
150
+
151
+ export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt, picker = pickDocuments, changedPaths = laneChangedPaths, gatherTimeoutMs = GATHER_TIMEOUT_MS, gatherSignal, storeFreeze = storeGatherFreeze, launch = launchRecovery } = {}) {
122
152
  if (action === "list") {
123
153
  const c = flags.mine ? await config() : null;
124
154
  const payload = await request("GET", `/api/v1/lanes${query({ kind: flags.kind, status: flags.status, machine: flags.machine, agent: flags.mine ? c.agentId : flags.agent, "waiting-on": flags["waiting-on"], stuck: flags.stuck, cursor: flags.cursor, limit: flags.limit })}`);
@@ -142,12 +172,138 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
142
172
  const payload = await request("POST", `/api/v1/lanes/${encode(key)}/start`, via);
143
173
  return { payload, human: (p) => `Started ${laneLine(p.data)}` };
144
174
  }
145
- const ref = need(args[0], `lane ${action} needs <key>`);
175
+ const ref = need(args[0] ?? (action === "gather" ? await currentGatherLane(request, config, gitContext) : undefined), `lane ${action} needs <key>`);
146
176
  const id = ref;
147
177
  if (action === "show") {
148
178
  const payload = await request("GET", `/api/v1/lanes/${encode(id)}`);
149
179
  return { payload, human: (p) => detailText(p.data) };
150
180
  }
181
+ if (action === "gather") {
182
+ const controller = gatherSignal ? null : new AbortController();
183
+ const deadline = controller ? setTimeout(() => controller.abort(), gatherTimeoutMs) : null;
184
+ const signal = gatherSignal ?? controller.signal;
185
+ try {
186
+ const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`, undefined, { signal })).data;
187
+ if (flags.objective !== undefined && (typeof flags.objective !== "string" || !flags.objective.trim()))
188
+ throw new CliError("USAGE", "lane gather --objective needs non-empty text", 2);
189
+ if (flags["done-check"] !== undefined && (typeof flags["done-check"] !== "string" || !flags["done-check"].trim()))
190
+ throw new CliError("USAGE", "lane gather --done-check needs non-empty text", 2);
191
+ const durableObjective = [lane.objective, lane.freeze?.objective?.ask].find((x) => typeof x === "string" && x.trim());
192
+ const explicitObjective = typeof flags.objective === "string" ? flags.objective.trim() : undefined;
193
+ if (explicitObjective && durableObjective && explicitObjective !== durableObjective.trim())
194
+ throw new CliError("GATHER_OBJECTIVE_CONFLICT", `${ref} already has a different objective. Use lane amend; Gather cannot rewrite the durable contract.`, 1);
195
+ const issueObjective = typeof lane.issue?.bodyMd === "string" && lane.issue.bodyMd.trim() ? lane.issue.bodyMd : undefined;
196
+ const objective = durableObjective ?? explicitObjective ?? issueObjective;
197
+ if (!objective) throw new CliError("GATHER_OBJECTIVE_MISSING", `${ref} has no recorded objective. Amend the lane or pass --objective; the CLI does not invent one.`, 1);
198
+ if (objective.length > 20_000) throw new CliError("USAGE", "The recorded Gather objective exceeds Dev's 20,000-character limit.", 2);
199
+ const durableDoneCheck = [lane.doneCheck, lane.freeze?.objective?.doneCheck].find((x) => typeof x === "string" && x.trim());
200
+ const explicitDoneCheck = typeof flags["done-check"] === "string" ? flags["done-check"].trim() : undefined;
201
+ if (explicitDoneCheck && durableDoneCheck && explicitDoneCheck !== durableDoneCheck.trim())
202
+ throw new CliError("GATHER_DONE_CHECK_CONFLICT", `${ref} already has a different done check. Use lane amend; Gather cannot rewrite the durable contract.`, 1);
203
+ const doneCheck = durableDoneCheck ?? explicitDoneCheck;
204
+ if (!doneCheck) throw new CliError("GATHER_DONE_CHECK_MISSING", `${ref} has no recorded done check. Pass --done-check before Gather records anything.`, 1);
205
+ if (doneCheck.length > 10_000) throw new CliError("USAGE", "The Gather done check exceeds Dev's 10,000-character limit.", 2);
206
+ const legacyRepository = {
207
+ repo: lane.repos?.[0],
208
+ branch: lane.branch,
209
+ worktree: lane.worktree,
210
+ ownedPaths: lane.ownedPaths ?? [],
211
+ };
212
+ const sourceRepositories = lane.repositories?.length
213
+ ? lane.repositories
214
+ : lane.freeze?.boundaries?.repos?.length
215
+ ? lane.freeze.boundaries.repos
216
+ : [legacyRepository];
217
+ const repositories = sourceRepositories.map((repository) => Object.fromEntries(
218
+ ["repo", "branch", "worktree", "ownedPaths"]
219
+ .map((key) => [key, repository[key]])
220
+ .filter(([, value]) => value !== undefined),
221
+ ));
222
+ if (!repositories.length || repositories.some((repository) => !repository.repo || !repository.branch || !repository.worktree))
223
+ throw new CliError("GATHER_BOUNDARIES_MISSING", `${ref} has an incomplete repository boundary. Repair its repo, branch, and worktree before Gather records anything.`, 1);
224
+ const limit = flags.limit === undefined ? undefined : Number(flags.limit);
225
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 500))
226
+ throw new CliError("USAGE", "lane gather --limit needs a whole number from 1 to 500", 2);
227
+ const sectionLimit = flags["section-limit-per-document"] === undefined ? undefined : Number(flags["section-limit-per-document"]);
228
+ if (sectionLimit !== undefined && (!Number.isInteger(sectionLimit) || sectionLimit < 1 || sectionLimit > 20))
229
+ throw new CliError("USAGE", "lane gather --section-limit-per-document needs a whole number from 1 to 20", 2);
230
+ const recordedPaths = [
231
+ ...(lane.repositories ?? []).flatMap((repository) => repository.ownedPaths ?? []),
232
+ ...(lane.freeze?.boundaries?.repos ?? []).flatMap((repository) => repository.ownedPaths ?? []),
233
+ ...(lane.ownedPaths ?? []),
234
+ ];
235
+ const driftPaths = await changedPaths(lane, { signal });
236
+ if (driftPaths === null)
237
+ throw new CliError("GATHER_PATHS_UNAVAILABLE", "Gather could not read every recorded worktree. Repair the lane boundary or make its repositories available before continuing.", 1);
238
+ const paths = flags.paths ? list(flags.paths) : [...new Set([...recordedPaths, ...driftPaths])];
239
+ if (paths.length > 5000) throw new CliError("GATHER_SCOPE_TOO_LARGE", `Gather found ${paths.length} paths; Dev accepts at most 5,000. Narrow the lane's recorded scope before continuing.`, 1);
240
+ const repoRefs = [
241
+ ...(lane.repositories ?? []).map((repository) => repository.repo),
242
+ ...(lane.freeze?.boundaries?.repos ?? []).map((repository) => repository.repo),
243
+ ...(Array.isArray(lane.repos) ? lane.repos.map((repo) => typeof repo === "string" ? repo : repo.repo) : []),
244
+ ];
245
+ const repos = [...new Set(repoRefs.filter(Boolean).map(repoName))];
246
+ const components = flags.components ? list(flags.components) : [
247
+ ...(lane.freeze?.boundaries?.figma ?? []).flatMap((file) => file.componentSetKeys ?? []),
248
+ ...(lane.launch?.figmaScopes ?? []).flatMap((file) => file.componentSets ?? []),
249
+ ...(lane.components ?? []),
250
+ ];
251
+ const shortlistBody = {
252
+ objective,
253
+ paths,
254
+ repos,
255
+ components,
256
+ ...(limit !== undefined ? { limit } : {}),
257
+ };
258
+ const shortlist = await request("POST", "/api/v1/rules/shortlist", shortlistBody, { signal });
259
+ const triggers = shortlist.data?.triggers ?? [];
260
+ const picked = await picker({
261
+ objective,
262
+ files: paths,
263
+ triggers: triggers.map((trigger) => ({
264
+ code: trigger.code,
265
+ trigger: trigger.sentence,
266
+ ...(trigger.forcedBy ? { forcedBy: trigger.forcedBy } : {}),
267
+ })),
268
+ }, { signal });
269
+ const payload = await request("POST", `/api/v1/lanes/${encode(id)}/gather`, {
270
+ objective,
271
+ documentCodes: picked.documents,
272
+ paths,
273
+ components,
274
+ mode: flags.delta ? "delta" : "full",
275
+ ...(sectionLimit !== undefined ? { sectionLimitPerDocument: sectionLimit } : {}),
276
+ }, { signal });
277
+ const figma = lane.freeze?.boundaries?.figma ?? (lane.launch?.figmaScopes ?? []).map((file) => ({
278
+ fileKey: file.fileKey,
279
+ ...(file.componentSets?.length ? { componentSetKeys: file.componentSets } : {}),
280
+ }));
281
+ const issue = lane.issue ? { key: lane.issue.key, title: lane.issue.title } : null;
282
+ const laneNotes = [
283
+ lane.launch?.notes,
284
+ lane.launch?.narrowingNote && `Narrowing: ${lane.launch.narrowingNote}`,
285
+ lane.launch?.narrowingRemainder && `Narrowing remainder: ${lane.launch.narrowingRemainder}`,
286
+ ].filter(Boolean);
287
+ const freezeRequest = (method, path, body, options = {}) => request(method, path, body, { ...options, signal });
288
+ await storeFreeze(id, {
289
+ gather: payload.data,
290
+ boundaries: { repos: repositories, ...(figma.length ? { figma } : {}) },
291
+ issue,
292
+ laneNotes,
293
+ doneCheck,
294
+ }, { request: freezeRequest });
295
+ const reasons = Object.fromEntries(triggers.map((trigger) => [
296
+ trigger.code,
297
+ Array.isArray(trigger.forcedBy) && trigger.forcedBy.length ? `forced by ${trigger.forcedBy.join(", ")}` : trigger.sentence,
298
+ ]));
299
+ return { payload, human: (p) => gatherText(p, objective, reasons) };
300
+ } catch (error) {
301
+ if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded its ${Math.round(gatherTimeoutMs / 1000)}-second deadline; nothing was retried.`, 1);
302
+ throw error;
303
+ } finally {
304
+ if (deadline) clearTimeout(deadline);
305
+ }
306
+ }
151
307
  if (action === "step") {
152
308
  const selected = ["start", "done", "block"].filter((x) => flags[x]);
153
309
  if (selected.length !== 1) throw new CliError("USAGE", "lane step needs exactly one of --start, --done, or --block.", 2);
@@ -303,21 +459,26 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
303
459
  /**
304
460
  * The files a lane touches, for path-tagged rules (M05-32): its branch and
305
461
  * working-tree changes against origin/main, plus untracked files, read from
306
- * its recorded worktree. Null when that worktree cannot be read here; Dev then
307
- * serves every path-tagged clause rather than miss one.
462
+ * its recorded worktree. Null when any worktree cannot be read here; Gather
463
+ * refuses that unknown scope rather than silently omit path-triggered rules.
308
464
  */
309
- export async function laneChangedPaths(lane) {
465
+ export async function laneChangedPaths(lane, { signal, exec = execFileAsync } = {}) {
310
466
  try {
311
- const repositories = lane.repositories?.length ? lane.repositories : [{ repo: lane.repos?.[0], worktree: lane.worktree, baseRef: "origin/main" }];
467
+ const repositories = lane.repositories?.length
468
+ ? lane.repositories
469
+ : lane.freeze?.boundaries?.repos?.length
470
+ ? lane.freeze.boundaries.repos
471
+ : [{ repo: lane.repos?.[0], worktree: lane.worktree, baseRef: "origin/main" }];
312
472
  const changed = await Promise.all(repositories.map(async (repository) => {
313
- const run = async (...args) => (await execFileAsync("git", ["-C", repository.worktree, ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 })).stdout;
473
+ const run = async (...args) => (await exec("git", ["-C", repository.worktree, ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024, signal })).stdout;
314
474
  const base = (await run("merge-base", repository.baseRef ?? "origin/main", "HEAD")).trim();
315
475
  const tracked = (await run("diff", "--name-only", base)).split("\n");
316
476
  const untracked = (await run("ls-files", "--others", "--exclude-standard")).split("\n");
317
477
  return [...tracked, ...untracked].map((path) => path.trim()).filter(Boolean);
318
478
  }));
319
- return [...new Set(changed.flat())].slice(0, 5000);
320
- } catch {
479
+ return [...new Set(changed.flat())];
480
+ } catch (error) {
481
+ if (signal?.aborted) throw error;
321
482
  return null;
322
483
  }
323
484
  }
package/dist/nodii-dev.js CHANGED
@@ -1,14 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import { fail, main } from "./cli.js";
3
+ import { regroundCurrentLane, shouldReground } from "./freeze.js";
3
4
 
4
5
  // Hooks run in the agent's environment, which may be incomplete or may not be
5
6
  // a git worktree. An unavailable hook must never turn into a refused tool call.
6
7
  // Intentional policy denials are handled inside the hook command before they
7
8
  // reach this boundary.
8
- main().catch((error) => {
9
+ const argv = process.argv.slice(2);
10
+
11
+ async function run() {
12
+ // AGENT-HARNESS-19: this is the executable boundary, so every ordinary CLI
13
+ // invocation re-grounds without adding hidden requests to library callers.
14
+ if (shouldReground(argv)) await regroundCurrentLane();
15
+ await main(argv);
16
+ }
17
+
18
+ run().catch((error) => {
9
19
  const denial = String(error?.code ?? "").startsWith("SAFETY_") ||
10
20
  ["FIGMA_FILE_KEY_MISSING", "FIGMA_LOCK_DENIED", "WRITE_OUT_OF_SCOPE"].includes(error?.code);
11
- if (process.argv[2] === "hook" && !denial) {
21
+ if (argv[0] === "hook" && !denial) {
12
22
  process.exitCode = 0;
13
23
  return;
14
24
  }
package/dist/setup.js CHANGED
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from "node:path";
3
3
  import { CliError, checkLogin } from "./core.js";
4
4
  import { codexHooksFromClaude } from "./harness.js";
5
5
  import { regroundingHooksSnippet, safetySettingsSnippet } from "./safety.js";
6
+ import { PICKER_MODEL, setupOllama } from "./gather.js";
6
7
 
7
8
  export const SKILL_PATH = ".claude/skills/nodii-build-workflow/SKILL.md";
8
9
  const START = "<!-- nodii-dev build workflow:start -->";
@@ -118,21 +119,37 @@ export async function setupPlan(root = process.cwd()) {
118
119
  return changes;
119
120
  }
120
121
 
121
- export async function setupCommand(flags, { root = process.cwd(), authCheck = checkLogin } = {}) {
122
+ export async function setupCommand(flags, { root = process.cwd(), authCheck = checkLogin, pickerSetup = setupOllama } = {}) {
123
+ if (flags.yes && flags.check) throw new CliError("USAGE", "setup accepts either --yes or --check, not both.", 2);
122
124
  const changes = await setupPlan(root);
123
125
  let authenticated = true;
124
126
  try { await authCheck(); } catch { authenticated = false; }
127
+ let pickerReady = true;
128
+ let pickerStatus;
129
+ try { pickerStatus = await pickerSetup({ check: !flags.yes }); }
130
+ catch (error) {
131
+ pickerReady = false;
132
+ pickerStatus = { code: error?.code ?? "PICKER_UNAVAILABLE", message: error?.message ?? String(error) };
133
+ }
125
134
  if (flags.check) {
126
- if (changes.length) throw new CliError("SETUP_DRIFT", `Workflow harness drift: ${changes.map((x) => x.relative).join(", ")}`, 1);
127
- return { payload: { ok: true, changed: [], authenticated }, human: authenticated ? "Workflow harness is current." : "Workflow harness is current. Not logged in; run `nodii-dev login`." };
135
+ const drift = [
136
+ ...(changes.length ? [`Workflow harness drift: ${changes.map((x) => x.relative).join(", ")}`] : []),
137
+ ...(!pickerReady ? [`Gather picker: ${pickerStatus.message}`] : []),
138
+ ];
139
+ if (drift.length) throw new CliError("SETUP_DRIFT", drift.join("; "), 1);
140
+ return { payload: { ok: true, changed: [], authenticated, picker: pickerStatus }, human: authenticated ? "Workflow harness and Gather picker are current." : "Workflow harness and Gather picker are current. Not logged in; run `nodii-dev login`." };
128
141
  }
129
142
  if (!flags.yes) {
130
- const preview = changes.length ? changes.map((x) => `${x.before === null ? "+ create" : "~ update"} ${x.relative}`).join("\n") : "No changes.";
131
- return { payload: { dryRun: true, changed: changes.map((x) => x.relative), authenticated }, human: `${preview}\n${authenticated ? "Authentication: ready." : "Authentication: not logged in; run `nodii-dev login`."}\nRun \`nodii-dev setup --yes\` to apply.` };
143
+ const preview = [
144
+ ...(changes.length ? changes.map((x) => `${x.before === null ? "+ create" : "~ update"} ${x.relative}`) : ["No workflow changes."]),
145
+ pickerReady ? `Gather picker: ${PICKER_MODEL} is ready.` : `+ install Ollama and pull ${PICKER_MODEL}`,
146
+ ].join("\n");
147
+ return { payload: { dryRun: true, changed: [...changes.map((x) => x.relative), ...(!pickerReady ? [`ollama:${PICKER_MODEL}`] : [])], authenticated, picker: pickerStatus }, human: `${preview}\n${authenticated ? "Authentication: ready." : "Authentication: not logged in; run `nodii-dev login`."}\nRun \`nodii-dev setup --yes\` to apply.` };
132
148
  }
149
+ if (!pickerReady) throw new CliError(pickerStatus.code, pickerStatus.message, 1);
133
150
  for (const change of changes) {
134
151
  await mkdir(dirname(change.path), { recursive: true });
135
152
  await writeFile(change.path, change.content);
136
153
  }
137
- return { payload: { applied: true, changed: changes.map((x) => x.relative), authenticated }, human: `${changes.length ? `Applied: ${changes.map((x) => x.relative).join(", ")}` : "Workflow harness already current."}\n${authenticated ? "Authentication: ready." : "Not logged in; run `nodii-dev login`."}` };
154
+ return { payload: { applied: true, changed: [...changes.map((x) => x.relative), ...(pickerStatus.changed ?? []).map((x) => `ollama:${x}`)], authenticated, picker: pickerStatus }, human: `${changes.length ? `Applied: ${changes.map((x) => x.relative).join(", ")}` : "Workflow harness already current."}\nGather picker: ${PICKER_MODEL} ready on Ollama ${pickerStatus.version}.\n${authenticated ? "Authentication: ready." : "Not logged in; run `nodii-dev login`."}` };
138
155
  }
package/dist/starting.js CHANGED
@@ -4,12 +4,26 @@ import { setTimeout as delay } from "node:timers/promises";
4
4
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { CliError, configPath, devRequest, encode, query, readConfig, requireFlag } from "./core.js";
7
- import { nextCommand } from "./lanes.js";
7
+ import { GATHER_TIMEOUT_MS } from "./gather.js";
8
+ import { laneCommand, nextCommand } from "./lanes.js";
8
9
 
9
10
  const execFile = promisify(execFileCallback);
10
11
  const via = { via: "nodii-dev" };
11
12
  const values = (value) => value === undefined ? [] : Array.isArray(value) ? value : [value];
12
13
 
14
+ const withSignal = (promise, signal) => {
15
+ if (!signal) return promise;
16
+ if (signal.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
17
+ return new Promise((resolvePromise, rejectPromise) => {
18
+ const abort = () => rejectPromise(signal.reason ?? new Error("aborted"));
19
+ signal.addEventListener("abort", abort, { once: true });
20
+ Promise.resolve(promise).then(
21
+ (value) => { signal.removeEventListener("abort", abort); resolvePromise(value); },
22
+ (error) => { signal.removeEventListener("abort", abort); rejectPromise(error); },
23
+ );
24
+ });
25
+ };
26
+
13
27
  function pairs(value, flag) {
14
28
  const out = new Map();
15
29
  for (const raw of values(value)) {
@@ -380,27 +394,42 @@ export async function amendmentGuard(argv, { request = devRequest, marker = curr
380
394
  throw new CliError("LANE_AMENDMENT_ACK_REQUIRED", `Lane ${lane.laneKey} changed:\n${detail}\nDelta gather required: run nodii-dev lane gather ${lane.laneKey} --delta.`, 1, { amendments: pending.data });
381
395
  }
382
396
 
383
- export async function gatherLaneCommand(key, flags, { request = devRequest, marker = currentMarker, gather = nextCommand } = {}) {
384
- const lane = await marker();
385
- key ??= lane?.laneKey;
386
- if (!key) throw new CliError("USAGE", "lane gather needs <key> outside a launched lane worktree.", 2);
387
- if (!flags.delta) return gather(key, flags, { request });
388
- if (!lane || lane.laneKey !== key) throw new CliError("LANE_RUN_REQUIRED", "Delta gather must run inside this lane's registered worktree.", 1);
389
- const pending = await request("GET", `/api/v1/lanes/${encode(key)}/amendments${query({ runId: lane.runId })}`);
390
- if (!pending.data.length) {
391
- const gathered = await gather(key, flags, { request });
392
- return { ...gathered, human: `${typeof gathered.human === "string" ? gathered.human : "Gather complete."}\nNo pending amendments.` };
397
+ const runGather = (key, flags, { request, signal }) => laneCommand("gather", [key], flags, { request, gatherSignal: signal });
398
+
399
+ export async function gatherLaneCommand(key, flags, { request = devRequest, marker = currentMarker, gather = runGather, acknowledge = nextCommand, gatherTimeoutMs = GATHER_TIMEOUT_MS } = {}) {
400
+ const controller = new AbortController();
401
+ const deadline = setTimeout(() => controller.abort(), gatherTimeoutMs);
402
+ const signal = controller.signal;
403
+ const timedRequest = (method, path, body, options = {}) => withSignal(request(method, path, body, { ...options, signal }), signal);
404
+ try {
405
+ const lane = await withSignal(marker(), signal);
406
+ key ??= lane?.laneKey;
407
+ if (!key) throw new CliError("USAGE", "lane gather needs <key> outside a launched lane worktree.", 2);
408
+ if (!flags.delta) return await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
409
+ if (lane && lane.laneKey !== key) throw new CliError("LANE_RUN_REQUIRED", "Delta gather for another lane cannot run inside this lane's registered worktree.", 1);
410
+ if (!lane) return await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
411
+ const pending = await timedRequest("GET", `/api/v1/lanes/${encode(key)}/amendments${query({ runId: lane.runId })}`);
412
+ if (!pending.data.length) {
413
+ const gathered = await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
414
+ return { ...gathered, human: `${typeof gathered.human === "string" ? gathered.human : "Gather complete."}\nNo pending amendments.` };
415
+ }
416
+ const version = Math.max(...pending.data.map((item) => item.version));
417
+ const gathered = await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
418
+ const acknowledgement = await withSignal(acknowledge(key, {
419
+ ...flags,
420
+ deltaContext: { runId: lane.runId, amendmentVersion: version },
421
+ }, { request: timedRequest }), signal);
422
+ const acknowledgedVersion = acknowledgement.payload?.rules?.amendmentAcknowledgement?.version;
423
+ if (acknowledgedVersion !== version)
424
+ throw new CliError("LANE_AMENDMENT_ACK_FAILED", `Delta gather did not acknowledge amendment version ${version}; the lane remains blocked.`, 1);
425
+ return {
426
+ payload: { ...gathered.payload, rules: acknowledgement.payload?.rules, amendments: pending.data, acknowledgedVersion },
427
+ human: `${pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n")}\n${typeof gathered.human === "string" ? gathered.human : "Gather complete."}\nAcknowledged amendment version ${version}.`,
428
+ };
429
+ } catch (error) {
430
+ if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded its ${Math.round(gatherTimeoutMs / 1000)}-second deadline; nothing was retried.`, 1);
431
+ throw error;
432
+ } finally {
433
+ clearTimeout(deadline);
393
434
  }
394
- const version = Math.max(...pending.data.map((item) => item.version));
395
- const gathered = await gather(key, {
396
- ...flags,
397
- deltaContext: { runId: lane.runId, amendmentVersion: version },
398
- }, { request });
399
- const acknowledgedVersion = gathered.payload?.rules?.amendmentAcknowledgement?.version;
400
- if (acknowledgedVersion !== version)
401
- throw new CliError("LANE_AMENDMENT_ACK_FAILED", `Delta gather did not acknowledge amendment version ${version}; the lane remains blocked.`, 1);
402
- return {
403
- payload: { ...gathered.payload, amendments: pending.data, acknowledgedVersion },
404
- human: `${pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n")}\n${typeof gathered.human === "string" ? gathered.human : "Gather complete."}\nAcknowledged amendment version ${version}.`,
405
- };
406
435
  }
package/dist/upload.js CHANGED
@@ -8,6 +8,7 @@ import { join, relative, resolve } from "node:path";
8
8
  import { setTimeout as sleep } from "node:timers/promises";
9
9
  import { CliError, devRequest, devTextRequest, encode, query } from "./core.js";
10
10
  import { CHANGE_CLASSES, validateDocument } from "./docs.js";
11
+ import { rulesFindText } from "./gather.js";
11
12
 
12
13
  /** Exit code when Dev is waiting on the operator to acknowledge parked items. */
13
14
  export const EXIT_PARKED = 4;
@@ -307,6 +308,15 @@ const pad = (v, n) => String(v).padStart(n);
307
308
  * core, the delivery report, and the operator's core token cap.
308
309
  */
309
310
  export async function rulesCommand(action, args, flags, { request = devRequest } = {}) {
311
+ if (action === "find") {
312
+ const question = args.join(" ").trim();
313
+ if (!question) throw new CliError("USAGE", 'rules find needs a question, for example: nodii-dev rules find "may this route use offset paging?"', 2);
314
+ if (question.length > 20_000) throw new CliError("USAGE", "rules find questions are at most 20,000 characters", 2);
315
+ const limit = flags.limit === undefined ? 10 : Number(flags.limit);
316
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new CliError("USAGE", "rules find --limit needs a whole number from 1 to 100", 2);
317
+ const payload = await request("GET", `/api/v1/rules/find${query({ question, limit })}`);
318
+ return { payload, human: rulesFindText };
319
+ }
310
320
  if (action === "core") {
311
321
  // Dev refuses a core over the cap with CORE_OVER_CAP; that error is the output.
312
322
  const payload = await request("GET", "/api/v1/rules/core");
@@ -337,5 +347,5 @@ export async function rulesCommand(action, args, flags, { request = devRequest }
337
347
  const payload = await request("PUT", "/api/v1/rules/core/cap", { cap });
338
348
  return { payload, human: (p) => `Core cap: ${p.data.previous} -> ${p.data.cap} tokens.` };
339
349
  }
340
- throw new CliError("USAGE", "rules core | rules report [--section rulebook|services|all] [--lane KEY] | rules cap TOKENS", 2);
350
+ throw new CliError("USAGE", 'rules find "QUESTION" [--limit N] | rules core | rules report [--section rulebook|services|all] [--lane KEY] | rules cap TOKENS', 2);
341
351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.29",
3
+ "version": "0.6.30",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {