@nodii/dev 0.6.29 → 0.6.31

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/hooks.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { CliError, devRequest, readConfig } from "./core.js";
2
2
  import { FOUNDATIONS_ID, figmaLockCheck, outOfScopeText } from "./figma.js";
3
3
  import { execFile } from "node:child_process";
4
- import { readFile, realpath } from "node:fs/promises";
5
- import { resolve } from "node:path";
4
+ import { readFile } from "node:fs/promises";
6
5
  import { promisify } from "node:util";
7
6
  import { safetySettingsSnippet } from "./safety.js";
7
+ import { regroundCurrentLane } from "./freeze.js";
8
8
 
9
9
  const execFileAsync = promisify(execFile);
10
10
 
@@ -228,46 +228,20 @@ export async function heartbeatCurrentLane(request = devRequest) {
228
228
  /** SessionStart compact context, plain text for both Claude and Codex. */
229
229
  export async function sessionStartContext(payload, { request = devRequest, config = readConfig } = {}) {
230
230
  if (payload?.source !== "compact") return null;
231
- let lane = null;
232
- try {
233
- const cwd = payload.cwd ?? process.cwd();
234
- const branch = (await execFileAsync("git", ["branch", "--show-current"], { cwd, encoding: "utf8" })).stdout.trim();
235
- const rootText = (await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" })).stdout.trim();
236
- const root = await realpath(rootText).catch(() => resolve(rootText));
237
- const current = await config(false);
238
- try {
239
- const markerPath = (await execFileAsync("git", ["rev-parse", "--path-format=absolute", "--git-path", "nodii-lane.json"], { cwd, encoding: "utf8" })).stdout.trim();
240
- const marker = JSON.parse(await readFile(markerPath, "utf8"));
241
- const marked = (await request("GET", `/api/v1/lanes/${encodeURIComponent(marker.laneKey)}`)).data;
242
- const repository = marked.repositories?.find((item) => item.branch === branch && resolve(item.worktree) === root);
243
- if (marked.agentId === current.agentId && repository && !["on_dev", "stopped"].includes(marked.status)) lane = marked;
244
- } catch {}
245
- if (lane) {
246
- const freeze = lane.freeze ?? {};
247
- return [
248
- `Freeze re-grounding for ${lane.key}:`,
249
- `title: ${lane.title ?? "(untitled)"}`,
250
- `status: ${lane.status}`,
251
- `boundaries: ${JSON.stringify(freeze.boundaries ?? {})}`,
252
- `rules: ${JSON.stringify(freeze.rules ?? freeze.ruleRefs ?? [])}`,
253
- ].join("\n");
254
- }
255
- const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
256
- for (const item of lanes) {
257
- if (item.agentId !== current.agentId || item.branch !== branch || ["on_dev", "stopped"].includes(item.status)) continue;
258
- const worktree = await realpath(item.worktree ?? root).catch(() => resolve(item.worktree ?? root));
259
- if (worktree === root) { lane = item; break; }
260
- }
261
- } catch {}
262
- if (!lane) return "Freeze re-grounding: no active Dev lane was available for this branch; re-read the lane brief before continuing.";
263
- const freeze = lane.freeze ?? {};
264
- return [
265
- `Freeze re-grounding for ${lane.key}:`,
266
- `title: ${lane.title ?? "(untitled)"}`,
267
- `status: ${lane.status}`,
268
- `boundaries: ${JSON.stringify(freeze.boundaries ?? {})}`,
269
- `rules: ${JSON.stringify(freeze.rules ?? freeze.ruleRefs ?? [])}`,
270
- ].join("\n");
231
+ let context = "";
232
+ const result = await regroundCurrentLane(
233
+ {
234
+ cwd: payload.cwd ?? process.cwd(),
235
+ force: true,
236
+ write: (text) => {
237
+ context += text;
238
+ },
239
+ },
240
+ { request, config },
241
+ );
242
+ return result.shown
243
+ ? context.trimEnd()
244
+ : "Freeze re-grounding: no active Dev lane with initialized Freeze was available for this worktree; run `nodii-dev lane freeze show <key>` before continuing.";
271
245
  }
272
246
 
273
247
  export function workflowSuggestion(payload) {
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
  }
@@ -135,7 +135,12 @@ export async function dispatchManagedHook(event, payloadText, deps = {}) {
135
135
  }
136
136
  const structured = messages.map((message) => message.hookSpecificOutput?.additionalContext).filter(Boolean);
137
137
  const plain = successful.map((result) => responseOf(result.stdout) ? "" : result.stdout.trim()).filter(Boolean);
138
- if (["Stop", "SubagentStop"].includes(event)) return "{}\n";
138
+ // A Stop nudge asks the session to continue once; any other Stop output is dropped.
139
+ if (event === "Stop") {
140
+ const block = messages.find((message) => message.decision === "block" && typeof message.reason === "string");
141
+ return block ? `${JSON.stringify({ decision: "block", reason: block.reason })}\n` : "{}\n";
142
+ }
143
+ if (event === "SubagentStop") return "{}\n";
139
144
  return [...structured, ...plain].join("\n") + ([...structured, ...plain].length ? "\n" : "");
140
145
  } catch {
141
146
  return emptyResponse(event);
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
  }