@nodii/dev 0.6.32 → 0.6.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -807,6 +807,23 @@ The launched process uses the already-provisioned lowercase identity (for
807
807
  example `~/.config/nodii-dev/agents/lane-ah-12.json`), never the coordinator's
808
808
  Dev identity.
809
809
 
810
+ A lane's Figma scope is held by published key only (MW8-20): `--figma
811
+ FILE_KEY` for a whole file, or `--figma FILE_KEY=COMPONENT_SET_KEY[,KEY]` for
812
+ its component sets. A node id is refused. `--route /app/...` (repeatable)
813
+ derives the scope from the DS catalog (`https://design.nodii.co/registry.json`,
814
+ or `NODII_DEV_DS_REGISTRY_URL`): the route's Surfaces (page, dialogs, tabs),
815
+ each one's published `componentSetKey`, and its file. A route with no Surface
816
+ is `FIGMA_ROUTE_UNMAPPED` (a DS catalog fix), and a Surface whose key is not
817
+ published yet is `FIGMA_KEY_UNPUBLISHED`. The scope becomes the Freeze's Figma
818
+ boundaries. Gather sends the DS components those Surfaces are built from, so
819
+ their guidelines and U-docs apply, and pins one `figma-guidelines` lookup.
820
+ An explicit set key must be a published Surface in that file
821
+ (`FIGMA_KEY_UNKNOWN`). `figma lock`, `figma scope add|set` and the Figma
822
+ pre-write hook refuse a file, page, section or set outside the scope with
823
+ `FIGMA_OUTSIDE_LANE_SCOPE` (the hook exits 2). A lane that declared no Figma
824
+ scope is not bounded; a Freeze with `figma: []` means "no Figma" and refuses
825
+ every Figma lock and write.
826
+
810
827
  Objective, scope and rule changes are durable `lane amend` records, never chat
811
828
  messages. Every CLI call from a launched worktree checks the current watched
812
829
  run's acknowledgement version. A pending change is printed and blocks the
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  refuseFromLane,
21
21
  revokeOAuth,
22
22
  } from "./oauth.js";
23
- import { heartbeatCurrentLane, postHook, postHookFailure, preHook, sessionStartContext, settingsSnippet, workflowSuggestion } from "./hooks.js";
23
+ import { FIGMA_HOOK_DENIALS, heartbeatCurrentLane, postHook, postHookFailure, preHook, sessionStartContext, settingsSnippet, workflowSuggestion } from "./hooks.js";
24
24
  import { setupCommand } from "./setup.js";
25
25
  import { harnessCommand } from "./harness.js";
26
26
  import { hookRuntime, recordHookFire, safetyContext, safetyPostHook, safetyPreHook } from "./safety.js";
@@ -53,6 +53,7 @@ import {
53
53
  } from "./figma.js";
54
54
  export { figmaLock, submitMode } from "./figma.js";
55
55
  import { tuneCommand } from "./tune.js";
56
+ import { TUNING_MODELS, measurePickerLatency } from "./gather.js";
56
57
  import { docsCommand, rulebookCommand, rulesCommand } from "./upload.js";
57
58
  import { changeSetCommand, guidelineCommand } from "./guidelines.js";
58
59
  import { designCheck, designCheckText, designRoles, designRolesText } from "./design.js";
@@ -165,19 +166,27 @@ Usage: nodii-dev <command> [options] [--json]
165
166
  gather tune trial <run-id> --case ID --picker P --iteration N [--documents A,B]
166
167
  # one trial; lane-agent passes its own --documents pick
167
168
  gather tune finalize <run-id> # Dev scores the full 5-picker x 5-repeat matrix
169
+ gather picker-latency [--picker gemma-12b|gemma-e4b|gemma-26b]
170
+ # cold (model unloaded first) and warm pick latency, for the tuning record
168
171
 
169
172
  coordinator start --machine NAME # register this machine's one watched coordinator; refuses a live second
170
173
  lane launch <key> --done-check TEXT --kind KIND --side SIDE
171
174
  --issue KEY --repo NAME=/SOURCE --branch NAME=BRANCH [--repo ... --branch ...]
172
175
  --machine NAME --runtime claude|codex --profile PATH --model MODEL --subagents N
173
176
  [--base NAME=REF] [--worktree NAME=PATH] [--owned-path NAME=PATH]
174
- [--figma FILE[=COMPONENT_SET[,SET]]] [--notes TEXT]
177
+ [--route /app/... ...] [--figma FILE_KEY[=COMPONENT_SET_KEY[,KEY]]] [--notes TEXT]
175
178
  [--narrowing-note TEXT --narrowing-remainder DESTINATION]
176
179
  # authoritative preflight, one worktree per repo, watched launch
177
180
  # uses the provisioned agents/lane-<key>.json Dev identity
181
+ # Figma scope by published key: --route derives it from the
182
+ # DS catalog (route -> Surfaces -> componentSetKey -> file)
178
183
  lane amend <key> --objective|--scope|--rules TEXT --reason TEXT
179
184
  lane gather [<key>] [--delta] [--objective TEXT] [--done-check TEXT] [--components a,b] [--paths a,b]
180
185
  # infers a launched worktree; explicit contract inputs support manual lane start
186
+ lane gather [<key>] --picker agent [--shortlist ID --documents CODE[,CODE]]
187
+ # the lane agent picks instead of local Gemma: alone it prints the
188
+ # recorded trigger shortlist; with --shortlist/--documents it records
189
+ # the pick (picker: agent) and the Clause map
181
190
  run [--kind build|test_run|subagent|deploy] [--name TEXT] -- <command> [args...]
182
191
  # watched command: registers with NODII_DEV_RUN_ID,
183
192
  # heartbeats, and records exit code + redacted log tail
@@ -185,6 +194,8 @@ Usage: nodii-dev <command> [options] [--json]
185
194
  lane start <key> --title TEXT --kind KIND --side SIDE [--repo REPO] [--issue ISSUE]
186
195
  lane step <key> --side SIDE --start|--done|--block <step> [--reason TEXT]
187
196
  # --block --reason on a blocked step records its new reason
197
+ lane step <key> --side SIDE --not-applicable gather --reason-code pre_harness_lane [--note TEXT]
198
+ # coordinator only: Gather never applied (pre-Gather lane); never "done"
188
199
  lane evidence <key> --pr|--commit|--figma|--deploy|--dev-check REF
189
200
  lane subagent-quota <key> <n> # coordinator assigns 0-100 subagent slots
190
201
  lane heartbeat|resume|land|verify|prioritize|deprioritize|stop <key>
@@ -578,6 +589,14 @@ export async function main(argv = process.argv.slice(2)) {
578
589
  if (command === "docs") return emitCommand(await docsCommand(action, rest, f));
579
590
  if (command === "rulebook" && ["list", "show", "diff"].includes(action)) return emitCommand(await rulebookCommand(action, rest, f));
580
591
  if (command === "rules") return emitCommand(await rulesCommand(action, rest, f));
592
+ if (command === "gather" && action === "picker-latency") {
593
+ const name = f.picker ?? "gemma-12b";
594
+ const model = Object.hasOwn(TUNING_MODELS, name) ? TUNING_MODELS[name] : null;
595
+ if (!model) throw new CliError("USAGE", `gather picker-latency [--picker ${Object.keys(TUNING_MODELS).join("|")}]`, 2);
596
+ const result = await measurePickerLatency({ model });
597
+ const line = (label, x) => `${label} ${x.wallMs} ms (Ollama total ${x.totalMs ?? "?"} ms, model load ${x.loadMs ?? "?"} ms)`;
598
+ return emit(result, json, (r) => `${r.model}\n${line("cold", r.cold)}\n${line("warm", r.warm)}`);
599
+ }
581
600
  if (command === "gather" && action === "tune") {
582
601
  const result = await tuneCommand(rest[0], rest.slice(1), f);
583
602
  emit(result.payload, json, result.human);
@@ -609,6 +628,8 @@ export async function main(argv = process.argv.slice(2)) {
609
628
  if (command === "lane" && action === "amend") return emitCommand(await amendLaneCommand(rest[0], f));
610
629
  if (command === "lane" && action === "gather") {
611
630
  const result = await gatherLaneCommand(rest[0], f);
631
+ // An agent picker's shortlist step records nothing yet.
632
+ if (result.payload?.data?.recorded === false) return emitCommand(result);
612
633
  // What a gather covered is the lane's gathered set for the nudge hooks.
613
634
  const asked = (value) => (typeof value === "string" ? value.split(",").map((item) => item.trim()).filter(Boolean) : []);
614
635
  await (async () => recordGathered(process.cwd(), { guidelines: asked(f.components), deltaGathered: f.delta ? (f.paths ? asked(f.paths) : await changedPaths(process.cwd())) : [] }))().catch(() => {});
@@ -910,7 +931,7 @@ export async function main(argv = process.argv.slice(2)) {
910
931
  } catch (error) {
911
932
  // A known policy denial still blocks. Hook/runtime failures fail open
912
933
  // and stay silent so a broken environment cannot stop the session.
913
- if (!["FIGMA_FILE_KEY_MISSING", "FIGMA_LOCK_DENIED"].includes(error?.code)) return;
934
+ if (!FIGMA_HOOK_DENIALS.includes(error?.code)) return;
914
935
  if (payload && hookRuntime(payload) === "codex") {
915
936
  console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: `Blocked Figma write: ${error?.message ?? String(error)}` } }));
916
937
  return;
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.32";
7
+ export const VERSION = "0.6.34";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
@@ -0,0 +1,205 @@
1
+ // A lane's Figma scope (AGENT-HARNESS-19, operator 30 Oct): Figma files and
2
+ // component sets by PUBLISHED key (MW8-20), never by node id. Launch derives it
3
+ // from the DS catalog (route → Surfaces → componentSetKey → file); Gather turns
4
+ // it into the DS components whose guidelines apply; the hooks refuse Figma
5
+ // locks and writes outside it.
6
+ import { CliError, devRequest } from "./core.js";
7
+ import { currentLane } from "./freeze.js";
8
+
9
+ /** The DS catalog as the design site publishes it (`build:public-assets`). */
10
+ export const DS_REGISTRY_URL = "https://design.nodii.co/registry.json";
11
+ export const DS_REGISTRY_TIMEOUT_MS = 20_000;
12
+ export const FIGMA_FILE_KEY = /^[A-Za-z0-9]{10,64}$/;
13
+ export const COMPONENT_SET_KEY = /^[0-9a-f]{40}$/;
14
+ const NODE_ID = /^I?\d+[:-]\d+/;
15
+
16
+ const notAKey = (value, what) =>
17
+ new CliError(
18
+ "FIGMA_SCOPE_INVALID",
19
+ NODE_ID.test(value)
20
+ ? `${value} is a node id; a lane's Figma scope names ${what} by published key, never by node id (MW8-20).`
21
+ : `${value} is not ${what === "a file" ? "a Figma file key" : "a published component-set key (40 hex characters)"}.`,
22
+ 2,
23
+ );
24
+
25
+ export function checkFileKey(value) {
26
+ const key = String(value ?? "").trim();
27
+ if (!FIGMA_FILE_KEY.test(key)) throw notAKey(key, "a file");
28
+ return key;
29
+ }
30
+
31
+ export function checkComponentSetKey(value) {
32
+ const key = String(value ?? "").trim();
33
+ if (!COMPONENT_SET_KEY.test(key)) throw notAKey(key, "a component set");
34
+ return key;
35
+ }
36
+
37
+ /** Union per file; an empty set list means the whole file and wins. */
38
+ export function mergeScopes(...lists) {
39
+ const files = new Map();
40
+ for (const scope of lists.flat()) {
41
+ const current = files.get(scope.fileKey);
42
+ if (!current) files.set(scope.fileKey, { fileKey: scope.fileKey, componentSets: [...scope.componentSets] });
43
+ else if (!current.componentSets.length || !scope.componentSets.length) current.componentSets = [];
44
+ else current.componentSets = [...new Set([...current.componentSets, ...scope.componentSets])];
45
+ }
46
+ return [...files.values()];
47
+ }
48
+
49
+ const componentName = (dependency) => /\/r\/components\/([a-z0-9-]+)\.json$/.exec(String(dependency))?.[1] ?? null;
50
+
51
+ /** The product-page Surfaces in the DS registry, with what scope checks need. */
52
+ export function surfacesFrom(registry) {
53
+ const items = Array.isArray(registry?.items) ? registry.items : null;
54
+ if (!items) throw new CliError("DS_CATALOG_INVALID", "The DS registry has no items list.", 1);
55
+ return items
56
+ .filter((item) => item?.meta?.productPage && item?.meta?.figma)
57
+ .map((item) => {
58
+ const figma = item.meta.figma;
59
+ return {
60
+ name: item.name,
61
+ route: item.meta.productPage.frontendRoute ?? null,
62
+ surfaceType: item.meta.productPage.kind ?? null,
63
+ fileKey: figma.fileKey,
64
+ componentSetKey: figma.componentSetKey ?? null,
65
+ pendingKey: figma.pendingKey ?? null,
66
+ nodeIds: [figma.nodeId ?? item.figmaNodeId, ...Object.values(figma.states ?? {})].filter(Boolean),
67
+ components: [...new Set((item.registryDependencies ?? []).map(componentName).filter(Boolean))],
68
+ };
69
+ });
70
+ }
71
+
72
+ let cached;
73
+ /** Fetch the DS catalog once per process. */
74
+ export async function loadDsCatalog({ fetchImpl = fetch, url = process.env.NODII_DEV_DS_REGISTRY_URL ?? DS_REGISTRY_URL, timeoutMs = DS_REGISTRY_TIMEOUT_MS, signal } = {}) {
75
+ if (cached?.url === url) return cached.surfaces;
76
+ let response;
77
+ try {
78
+ // A caller's signal (Gather's deadline) bounds the read; otherwise our own.
79
+ response = await fetchImpl(url, { headers: { accept: "application/json" }, signal: signal ?? AbortSignal.timeout(timeoutMs) });
80
+ } catch (error) {
81
+ throw new CliError("DS_CATALOG_UNAVAILABLE", `Could not read the DS catalog at ${url}: ${error.message}`, 1);
82
+ }
83
+ if (!response.ok) throw new CliError("DS_CATALOG_UNAVAILABLE", `The DS catalog at ${url} returned HTTP ${response.status}.`, 1);
84
+ let registry;
85
+ try { registry = await response.json(); }
86
+ catch { throw new CliError("DS_CATALOG_INVALID", `The DS catalog at ${url} is not JSON.`, 1); }
87
+ const surfaces = surfacesFrom(registry);
88
+ cached = { url, surfaces };
89
+ return surfaces;
90
+ }
91
+ export const clearDsCatalogCache = () => { cached = undefined; };
92
+
93
+ /** route → Surfaces → published componentSetKey → file (rules-AB E5). */
94
+ const routeKey = (route) => (route.length > 1 ? route.replace(/\/+$/, "") : route);
95
+
96
+ export function scopesFromRoutes(surfaces, routes) {
97
+ const scopes = [];
98
+ for (const route of routes) {
99
+ const matched = surfaces.filter((surface) => surface.route && routeKey(surface.route) === routeKey(route));
100
+ if (!matched.length) {
101
+ const near = surfaces.filter((surface) => surface.route?.startsWith(`${routeKey(route)}/`)).map((surface) => surface.route);
102
+ throw new CliError("FIGMA_ROUTE_UNMAPPED", `No DS catalog Surface has frontendRoute ${route}.${near.length ? ` Under it: ${[...new Set(near)].slice(0, 5).join(", ")} (single-quote routes with $params).` : ""} A missing mapping is a DS catalog fix in a DS lane, never a launch workaround.`, 1);
103
+ }
104
+ for (const surface of matched) {
105
+ if (!COMPONENT_SET_KEY.test(surface.componentSetKey ?? "")) {
106
+ const pending = surface.pendingKey ? ` (pending ${surface.pendingKey.changeSetId}, ${surface.pendingKey.followUpIssue})` : "";
107
+ throw new CliError("FIGMA_KEY_UNPUBLISHED", `${surface.name} (${route}) has no published componentSetKey yet${pending}. Publish the set first, or scope the lane to its file with --figma ${surface.fileKey}.`, 1);
108
+ }
109
+ scopes.push({ fileKey: checkFileKey(surface.fileKey), componentSets: [surface.componentSetKey] });
110
+ }
111
+ }
112
+ return mergeScopes(scopes);
113
+ }
114
+
115
+ /** An explicit set key must be a published Surface in that file, or no lock could ever be checked. */
116
+ export function assertKnownSets(surfaces, scopes) {
117
+ for (const scope of scopes)
118
+ for (const key of scope.componentSets) {
119
+ const surface = surfaces.find((item) => item.componentSetKey === key);
120
+ if (!surface)
121
+ throw new CliError("FIGMA_KEY_UNKNOWN", `${key} is not a published component-set key in the DS catalog. Scope the lane to the whole file (--figma ${scope.fileKey}) or derive it with --route.`, 1);
122
+ if (surface.fileKey !== scope.fileKey)
123
+ throw new CliError("FIGMA_KEY_UNKNOWN", `${key} (${surface.name}) is in file ${surface.fileKey}, not ${scope.fileKey}.`, 1);
124
+ }
125
+ return scopes;
126
+ }
127
+
128
+ /**
129
+ * The lane's Figma boundaries: its Freeze's `figma` once stored, else its
130
+ * launch scope. null = the lane declared no Figma scope, so nothing is bounded
131
+ * (lanes from before scopes existed); [] = declared "no Figma", so every Figma
132
+ * lock and write is refused.
133
+ */
134
+ export function laneFigmaScope(lane) {
135
+ const stored = lane?.freeze?.boundaries?.figma;
136
+ if (Array.isArray(stored))
137
+ return stored.map((file) => ({ fileKey: file.fileKey, componentSets: file.componentSetKeys ?? [] }));
138
+ const launch = lane?.launch?.figmaScopes;
139
+ if (!Array.isArray(launch) || !launch.length) return null;
140
+ try {
141
+ return launch.map((file) => ({ fileKey: checkFileKey(file.fileKey), componentSets: (file.componentSets ?? []).map(checkComponentSetKey) }));
142
+ } catch (error) {
143
+ const fix = lane?.freeze
144
+ ? `Set the published keys with ${SWAP_BOUNDARIES(lane.key)}.`
145
+ : `Run \`nodii-dev lane gather\` (it stores the Freeze with no Figma scope), then set the published keys with ${SWAP_BOUNDARIES(lane.key)}.`;
146
+ throw Object.assign(new CliError("FIGMA_SCOPE_INVALID", `Lane ${lane.key ?? ""} was launched with a Figma scope that is not published keys: ${error.message.replace(/\.$/, "")}. ${fix}`, 1), { reason: error.message });
147
+ }
148
+ }
149
+
150
+ /** The DS components (guideline keys) and Surfaces a scope covers. */
151
+ export function componentsForScope(surfaces, scope) {
152
+ const keys = new Set(scope.flatMap((file) => file.componentSets));
153
+ const files = new Set(scope.filter((file) => !file.componentSets.length).map((file) => file.fileKey));
154
+ const covered = surfaces.filter((surface) => keys.has(surface.componentSetKey));
155
+ const known = new Set(covered.map((surface) => surface.componentSetKey));
156
+ return {
157
+ surfaces: covered.map((surface) => surface.name),
158
+ components: [...new Set(covered.flatMap((surface) => surface.components))].sort(),
159
+ unknownKeys: [...keys].filter((key) => !known.has(key)),
160
+ wholeFiles: [...files],
161
+ };
162
+ }
163
+
164
+ /** The Freeze lookup that sends the agent to each in-scope component's guideline. */
165
+ export function guidelineLookup(components) {
166
+ if (!components.length) return null;
167
+ return {
168
+ id: "figma-guidelines",
169
+ instruction: `Before editing a DS component in this lane's Figma scope, run \`nodii-dev design guideline show <key>\` for it: ${components.join(", ")}.`,
170
+ };
171
+ }
172
+
173
+ export const SWAP_BOUNDARIES = (key = "<key>") => `\`nodii-dev lane freeze swap ${key} --section boundaries --file BOUNDARIES.json --reason TEXT\` (the whole section: repos plus figma: [{fileKey, componentSetKeys}]; start from \`nodii-dev lane freeze show ${key}\`)`;
174
+
175
+ const outside = (message, details) => new CliError("FIGMA_OUTSIDE_LANE_SCOPE", `${message} Change the scope only with ${SWAP_BOUNDARIES()}, or ask the coordinator to amend the lane.`, 2, details);
176
+
177
+ /**
178
+ * Refuse a Figma lock (lockScopes = the scopes asked for; [] = whole file) or a
179
+ * write (lockScopes = null) outside the lane's scope. `scope` null = the lane
180
+ * declared none, so nothing is bounded here.
181
+ */
182
+ export async function assertWithinLaneScope(scope, fileKey, lockScopes, { catalog = loadDsCatalog } = {}) {
183
+ if (scope === null) return { checked: false };
184
+ const file = scope.find((item) => item.fileKey === fileKey);
185
+ if (!file)
186
+ throw outside(`${fileKey} is outside this lane's Figma scope (${scope.length ? scope.map((item) => item.fileKey).join(", ") : "no Figma files"}).`, { fileKey, scope });
187
+ if (lockScopes === null || !file.componentSets.length) return { checked: true, fileKey };
188
+ const wide = lockScopes.length ? lockScopes.filter((item) => item.kind !== "component") : [{ kind: "file" }];
189
+ if (wide.length)
190
+ throw outside(`This lane may lock only its component sets in ${fileKey}, not ${wide.map((item) => item.kind).join(", ")}.`, { fileKey, scope: file });
191
+ const allowed = new Set((await catalog()).filter((surface) => file.componentSets.includes(surface.componentSetKey)).flatMap((surface) => surface.nodeIds));
192
+ const stray = lockScopes.filter((item) => !allowed.has(item.nodeId));
193
+ if (stray.length)
194
+ throw outside(`${stray.map((item) => item.nodeId).join(", ")} in ${fileKey} is not one of this lane's component sets (${file.componentSets.join(", ")}).`, { fileKey, scope: file, nodeIds: stray.map((item) => item.nodeId) });
195
+ return { checked: true, fileKey };
196
+ }
197
+
198
+ /** The Figma scope of the lane this worktree belongs to; null outside a lane or when it declares none. */
199
+ export async function currentLaneFigmaScope({ cwd = process.cwd() } = {}, deps = {}) {
200
+ let lane = await currentLane({ cwd }, deps);
201
+ if (!lane) return null;
202
+ // A lane found through the list carries no launch record; read its detail.
203
+ if (!("launch" in lane)) lane = (await (deps.request ?? devRequest)("GET", `/api/v1/lanes/${encodeURIComponent(lane.key)}`)).data ?? lane;
204
+ return laneFigmaScope(lane);
205
+ }
package/dist/figma.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
3
  import { CliError, devAsyncRequest, devRequest, encode } from "./core.js";
4
+ import { assertWithinLaneScope, currentLaneFigmaScope } from "./figma-scope.js";
4
5
 
5
6
  /** A Figma file key, or the key inside a figma.com file link (a branch link's branch key). */
6
7
  export function figmaFileKey(value) {
@@ -405,8 +406,10 @@ export const holdsSet = (check) => Boolean(check?.changeSetId) && (check.allowed
405
406
 
406
407
  const isWholeFile = (scopes) => !scopes?.length || scopes.some((s) => s.kind === "file");
407
408
 
408
- export async function figmaLock(value, title, { scopes = [], request = devRequest } = {}) {
409
+ export async function figmaLock(value, title, { scopes = [], request = devRequest, laneScope = currentLaneFigmaScope } = {}) {
409
410
  const key = figmaFileKey(value);
411
+ // The lane's Freeze bounds which files and component sets it may lock.
412
+ await assertWithinLaneScope(await laneScope({}, { request }), key, scopes);
410
413
  const result = await fileCall(key, request)("POST", `/api/v1/design/files/${encode(key)}/lock`, {
411
414
  changeSetTitle: title,
412
415
  ...(scopes.length ? { scopes } : {}),
@@ -431,8 +434,17 @@ export const figmaLockText = (cs) =>
431
434
  : `Holds ${cs.scopes.length} scope(s) in ${cs.fileKey}: ${cs.scopes.map(scopeText).join(", ")}`,
432
435
  ].join("\n");
433
436
 
434
- export async function figmaScopeAdd(id, scopes, { request = devRequest } = {}) {
437
+ /** A widened or replaced claim is held to the lane's Figma scope like a new lock. */
438
+ async function assertChangeSetInLaneScope(id, scopes, request, laneScope) {
439
+ const scope = await laneScope({}, { request });
440
+ if (scope === null) return;
441
+ const changeSet = (await request("GET", `/api/v1/design/change-sets/${encode(id)}`)).data;
442
+ await assertWithinLaneScope(scope, changeSet?.fileKey, scopes);
443
+ }
444
+
445
+ export async function figmaScopeAdd(id, scopes, { request = devRequest, laneScope = currentLaneFigmaScope } = {}) {
435
446
  if (!id || !scopes.length) throw new CliError("USAGE", `figma scope add CHANGE_SET ${SCOPE_USAGE} [--scope ...]`, 2);
447
+ await assertChangeSetInLaneScope(id, scopes, request, laneScope);
436
448
  return changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/scopes`, { scopes });
437
449
  }
438
450
 
@@ -442,8 +454,9 @@ export async function figmaScopeAdd(id, scopes, { request = devRequest } = {}) {
442
454
  * sit inside what the set holds: the whole file holds everything, a page its
443
455
  * components and creates.
444
456
  */
445
- export async function figmaScopeSet(id, scopes, { request = devRequest } = {}) {
457
+ export async function figmaScopeSet(id, scopes, { request = devRequest, laneScope = currentLaneFigmaScope } = {}) {
446
458
  if (!id || !scopes.length) throw new CliError("USAGE", `figma scope set CHANGE_SET ${SCOPE_USAGE} [--scope ...]`, 2);
459
+ await assertChangeSetInLaneScope(id, scopes, request, laneScope);
447
460
  try {
448
461
  return await request("PUT", `/api/v1/design/change-sets/${encode(id)}/scopes`, { scopes });
449
462
  } catch (error) {
package/dist/freeze.js CHANGED
@@ -82,6 +82,8 @@ export function pinnedClausesFromGather(clauses) {
82
82
  });
83
83
  }
84
84
 
85
+ export const GATHER_LOOKUP_IDS = ["figma-guidelines"];
86
+
85
87
  /** Build the six-section stored contract; Delta Gather keeps lane-authored sections. */
86
88
  export function freezeFromGather({
87
89
  gather,
@@ -89,6 +91,7 @@ export function freezeFromGather({
89
91
  issue,
90
92
  laneNotes,
91
93
  doneCheck,
94
+ lookups = [],
92
95
  current = null,
93
96
  }) {
94
97
  const effectiveDoneCheck = doneCheck ?? current?.objective?.doneCheck;
@@ -116,7 +119,13 @@ export function freezeFromGather({
116
119
  },
117
120
  boundaries,
118
121
  pinnedClauses: pinnedClausesFromGather(gather.clauses),
119
- lookups: current?.lookups ?? [],
122
+ // Gather owns the lookups it derives (the Figma scope's guidelines): each
123
+ // Gather replaces them, or drops them when the scope no longer yields any.
124
+ // Lookups the lane added itself are kept.
125
+ lookups: [
126
+ ...(current?.lookups ?? []).filter((item) => !GATHER_LOOKUP_IDS.includes(item.id) && !lookups.some((next) => next.id === item.id)),
127
+ ...lookups,
128
+ ],
120
129
  laneDecisions: current?.laneDecisions ?? [],
121
130
  unconfirmed: current?.unconfirmed ?? [],
122
131
  };
package/dist/gather.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
- import { platform } from "node:os";
2
+ import { homedir, platform } from "node:os";
3
3
  import { promisify } from "node:util";
4
+ import { randomUUID } from "node:crypto";
5
+ import { link, lstat, mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
4
7
  import { CliError } from "./core.js";
5
8
 
6
9
  const execFileAsync = promisify(execFile);
@@ -15,12 +18,17 @@ export const TUNING_MODELS = {
15
18
  "gemma-26b": { name: "gemma4:26b", digest: "sha256:001e5dafc3c77684c2307ebc6ab8e336e10c9b18eca52acf547d72fc83c3ca8c" },
16
19
  };
17
20
  export const PICKER_CONTEXT = 8192;
18
- export const PICKER_TIMEOUT_MS = 15_000;
21
+ // A hard per-pick deadline: a pick that has not answered by then is stopped
22
+ // (PICKER_TIMEOUT) so the next one never queues behind it.
23
+ export const PICKER_TIMEOUT_MS = 30_000;
19
24
  export const GATHER_TIMEOUT_MS = 60_000;
20
25
  export const PICKER_WARM_TIMEOUT_MS = 180_000;
21
26
  export const OLLAMA_URL = "http://127.0.0.1:11434";
22
27
  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`.";
23
28
 
29
+ // No picker on this machine is a setup gap, never a reason to skip Gather.
30
+ 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.";
31
+
24
32
  const localHost = (hostname) => ["127.0.0.1", "::1", "localhost"].includes(hostname);
25
33
 
26
34
  function combineSignals(signals) {
@@ -73,7 +81,7 @@ async function jsonRequest(path, { baseUrl = ollamaUrl(), fetchImpl = fetch, tim
73
81
  const timedOut = requestSignal.aborted || error?.name === "TimeoutError" || error?.name === "AbortError";
74
82
  throw new CliError("PICKER_UNAVAILABLE", timedOut
75
83
  ? `Local Gather picker timed out after ${timeoutMs} ms.`
76
- : `Local Gather picker is unavailable at ${baseUrl}: ${error.message}`, 1);
84
+ : `Local Gather picker is unavailable at ${baseUrl}: ${error.message}. ${SETUP_FIX}`, 1);
77
85
  }
78
86
  let raw;
79
87
  try {
@@ -158,20 +166,16 @@ export async function pickDocuments(input, options = {}) {
158
166
  const deadline = setTimeout(() => controller.abort(), timeoutMs);
159
167
  const signal = combineSignals([options.signal, controller.signal]);
160
168
  const timedOptions = { ...options, timeoutMs, signal };
169
+ const model = (options.model ?? PINNED).name;
170
+ const started = Date.now();
161
171
  try {
162
172
  await requirePicker(timedOptions);
163
- const schema = {
164
- type: "object",
165
- properties: { documents: { type: "array", items: { type: "string", enum: input.triggers.map((x) => x.code) }, uniqueItems: true } },
166
- required: ["documents"],
167
- additionalProperties: false,
168
- };
169
173
  const payload = await jsonRequest("/api/chat", {
170
174
  ...timedOptions,
171
175
  body: {
172
- model: (options.model ?? PINNED).name,
176
+ model,
173
177
  stream: false,
174
- format: schema,
178
+ format: pickSchema(input.triggers),
175
179
  messages: [{ role: "user", content: pickerPrompt(input) }],
176
180
  options: { num_ctx: PICKER_CONTEXT, temperature: 0 },
177
181
  },
@@ -183,12 +187,173 @@ export async function pickDocuments(input, options = {}) {
183
187
  if (!Array.isArray(result.documents) || result.documents.some((x) => typeof x !== "string" || !allowed.has(x)))
184
188
  throw new CliError("PICKER_RESPONSE_INVALID", "Local Gather picker returned a document outside the trigger shortlist.", 1);
185
189
  const required = input.triggers.filter(forced).map((x) => x.code);
186
- return { documents: [...new Set([...result.documents, ...required])], model: (options.model ?? PINNED).name, context: PICKER_CONTEXT };
190
+ return { documents: [...new Set([...result.documents, ...required])], model, context: PICKER_CONTEXT, latency: pickLatency(payload, Date.now() - started) };
191
+ } catch (error) {
192
+ // Our own deadline, not the caller's: stop the stuck pick so nothing queues behind it.
193
+ if (controller.signal.aborted && !options.signal?.aborted) {
194
+ // Not awaited: the refusal is immediate; the unload finishes before exit.
195
+ unloadPicker(model, options).catch(() => {});
196
+ throw new CliError("PICKER_TIMEOUT", `Local Gather picker ${model} did not answer within ${Math.round(timeoutMs / 1000)} s; the pick was stopped and the model unloaded. Rerun the command.`, 1);
197
+ }
198
+ throw error;
187
199
  } finally {
188
200
  clearTimeout(deadline);
189
201
  }
190
202
  }
191
203
 
204
+ /**
205
+ * The pick's JSON schema. No `uniqueItems`: it wedges Ollama's grammar
206
+ * sampler on real Gather prompts (ah-19 sandbox finding, 30 Oct). Duplicates
207
+ * are removed after the pick; `maxItems` still bounds the list.
208
+ */
209
+ export function pickSchema(triggers) {
210
+ const codes = triggers.map((x) => x.code);
211
+ return {
212
+ type: "object",
213
+ properties: { documents: { type: "array", items: { type: "string", enum: codes }, maxItems: new Set(codes).size } },
214
+ required: ["documents"],
215
+ additionalProperties: false,
216
+ };
217
+ }
218
+
219
+ const ms = (ns) => (typeof ns === "number" ? Math.round(ns / 1e6) : null);
220
+ /** Wall time, plus Ollama's own total and model-load times for the pick. */
221
+ export const pickLatency = (payload, wallMs) => ({ wallMs, totalMs: ms(payload?.total_duration), loadMs: ms(payload?.load_duration) });
222
+
223
+ /** Unload the model (Ollama keep_alive 0), which also ends a run stuck on it. */
224
+ export async function unloadPicker(model, options = {}) {
225
+ await jsonRequest("/api/generate", { ...options, signal: undefined, timeoutMs: 5_000, body: { model, keep_alive: 0, stream: false } });
226
+ }
227
+
228
+ /** Per-user lock directory, under the user's own runtime or cache dir (not shared /tmp). */
229
+ export const pickLockDir = (env = process.env) => env.XDG_RUNTIME_DIR ? join(env.XDG_RUNTIME_DIR, "nodii-dev", "picks") : join(homedir(), ".cache", "nodii-dev", "picks");
230
+
231
+ async function privateDir(dir) {
232
+ await mkdir(dir, { recursive: true, mode: 0o700 });
233
+ const st = await lstat(dir);
234
+ if (!st.isDirectory() || (process.getuid && st.uid !== process.getuid()) || (process.platform !== "win32" && (st.mode & 0o077)))
235
+ throw new CliError("PICKER_LOCK_UNSAFE", `The Gather pick lock directory ${dir} is not a private directory owned by you; remove it and rerun.`, 1);
236
+ }
237
+
238
+ const NO_HARD_LINKS = new Set(["EPERM", "ENOTSUP", "EXDEV", "ENOSYS", "EOPNOTSUPP"]);
239
+ /** Create the lock whole (hard link of a written token file); exclusive create where links are unsupported. */
240
+ async function createLock(temp, lock, token) {
241
+ try { await link(temp, lock); }
242
+ catch (error) {
243
+ if (!NO_HARD_LINKS.has(error?.code)) throw error;
244
+ await writeFile(lock, token, { flag: "wx", mode: 0o600 });
245
+ }
246
+ }
247
+
248
+ const GUARD_STALE_MS = 5_000;
249
+ /**
250
+ * Remove a stale lock, one takeover at a time (a guard file serializes them),
251
+ * judged from a single open handle and unlinked only if the path still holds
252
+ * that same file. Returns the live holder's pid when the lock is not stale.
253
+ */
254
+ async function removeIfStale(lock, token, { alive, now, staleMs }) {
255
+ const guard = `${lock}.takeover`;
256
+ try { await writeFile(guard, token, { flag: "wx", mode: 0o600 }); }
257
+ catch (error) {
258
+ if (error?.code !== "EEXIST") throw error;
259
+ const st = await stat(guard).catch(() => null);
260
+ if (st && now() - st.mtimeMs > GUARD_STALE_MS) await rm(guard, { force: true });
261
+ return { retry: true };
262
+ }
263
+ try {
264
+ let handle;
265
+ try { handle = await open(lock, "r"); }
266
+ catch (error) { if (error?.code === "ENOENT") return { retry: true }; throw error; }
267
+ try {
268
+ const st = await handle.stat();
269
+ const holder = Number((await handle.readFile("utf8")).split(".")[0]);
270
+ const fresh = now() - st.mtimeMs < staleMs;
271
+ if (fresh && (!(Number.isInteger(holder) && holder > 0) || alive(holder))) return { holder: holder || null };
272
+ const current = await stat(lock).catch(() => null);
273
+ if (current && current.ino === st.ino && current.dev === st.dev) await rm(lock, { force: true });
274
+ return { retry: true };
275
+ } finally {
276
+ await handle.close();
277
+ }
278
+ } finally {
279
+ if ((await readFile(guard, "utf8").catch(() => null)) === token) await rm(guard, { force: true });
280
+ }
281
+ }
282
+
283
+ /**
284
+ * One in-flight pick per lane, across processes. The lock is created whole,
285
+ * released only by its owner, and taken over only when stale: its holder is
286
+ * dead, or it is older than twice the pick deadline (a pick never runs that
287
+ * long). Takeovers are serialized, so the lock path is never emptied under a
288
+ * live pick.
289
+ */
290
+ export async function withPickLock(lane, task, { dir = pickLockDir(), pid = process.pid, alive = processAlive, now = Date.now, staleMs = 2 * PICKER_TIMEOUT_MS, attempts = 40, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
291
+ await privateDir(dir);
292
+ const name = encodeURIComponent(lane);
293
+ const lock = join(dir, `${name}.lock`);
294
+ const token = `${pid}.${randomUUID()}`;
295
+ const temp = join(dir, `.${name}.${token}`);
296
+ await writeFile(temp, token, { mode: 0o600 });
297
+ let acquired = false;
298
+ try {
299
+ for (let attempt = 0; attempt < attempts && !acquired; attempt += 1) {
300
+ try {
301
+ await createLock(temp, lock, token);
302
+ acquired = true;
303
+ break;
304
+ } catch (error) {
305
+ if (error?.code !== "EEXIST") throw error;
306
+ }
307
+ const verdict = await removeIfStale(lock, token, { alive, now, staleMs });
308
+ if (!verdict.retry)
309
+ throw new CliError("PICKER_BUSY", `A Gather pick for lane ${lane} is already running (pid ${verdict.holder ?? "unknown"}); it ends within ${Math.round(PICKER_TIMEOUT_MS / 1000)} s. Rerun after it finishes (lock: ${lock}).`, 1, { pid: verdict.holder, lock });
310
+ await sleep(5 + Math.random() * 20);
311
+ }
312
+ } finally {
313
+ await rm(temp, { force: true });
314
+ }
315
+ if (!acquired) throw new CliError("PICKER_BUSY", `Could not take the Gather pick lock for lane ${lane} (lock: ${lock}).`, 1, { lock });
316
+ try {
317
+ return await task();
318
+ } finally {
319
+ if ((await readFile(lock, "utf8").catch(() => null)) === token) await rm(lock, { force: true });
320
+ }
321
+ }
322
+
323
+ const processAlive = (pid) => {
324
+ try { process.kill(pid, 0); return true; }
325
+ catch (error) { return error?.code === "EPERM"; }
326
+ };
327
+
328
+ const LATENCY_PROBE = {
329
+ objective: "Add a contacts list endpoint with cursor paging.",
330
+ triggers: [
331
+ { code: "S10", trigger: "Applies when a change adds or edits an HTTP request handler." },
332
+ { code: "PII", trigger: "Applies when a change stores, shows or sends a person's name, email or phone." },
333
+ { code: "U05", trigger: "Applies when a page shows a person." },
334
+ ],
335
+ };
336
+
337
+ /** Cold (model unloaded, confirmed gone) and warm pick latency for one pinned picker. */
338
+ export async function measurePickerLatency(options = {}, { pick = pickDocuments, unload = unloadPicker, loaded = modelLoaded, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
339
+ const model = (options.model ?? PINNED).name;
340
+ await requirePicker(options);
341
+ await unload(model, options);
342
+ for (let i = 0; i < 50 && (await loaded(model, options)); i += 1) await sleep(200);
343
+ if (await loaded(model, options)) throw new CliError("PICKER_UNAVAILABLE", `${model} did not unload, so a cold pick cannot be measured.`, 1);
344
+ // A cold load can take tens of seconds; it gets the warm-up deadline, not the pick's.
345
+ const cold = await pick(LATENCY_PROBE, { ...options, timeoutMs: PICKER_WARM_TIMEOUT_MS });
346
+ // A different objective, so the warm pick is not a cached-prompt replay.
347
+ const warm = await pick({ ...LATENCY_PROBE, objective: "Show each contact's owner on the contacts page." }, options);
348
+ return { model, cold: cold.latency, warm: warm.latency };
349
+ }
350
+
351
+ /** Whether Ollama currently holds the model in memory (/api/ps). */
352
+ export async function modelLoaded(model, options = {}) {
353
+ const ps = await jsonRequest("/api/ps", { ...options, signal: undefined });
354
+ return (ps.models ?? []).some((x) => x?.name === model || x?.model === model);
355
+ }
356
+
192
357
  const tags = (clause) => {
193
358
  const repos = clause.repos ?? [];
194
359
  const steps = clause.steps ?? [];
package/dist/hooks.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { CliError, devRequest, readConfig } from "./core.js";
2
2
  import { FOUNDATIONS_ID, figmaLockCheck, outOfScopeText } from "./figma.js";
3
+ import { assertWithinLaneScope, currentLaneFigmaScope } from "./figma-scope.js";
3
4
  import { execFile } from "node:child_process";
4
5
  import { readFile } from "node:fs/promises";
5
6
  import { promisify } from "node:util";
@@ -107,7 +108,11 @@ export function extractNodeIds(value, out = new Set(), { text = false } = {}) {
107
108
  return [...out];
108
109
  }
109
110
 
110
- export async function preHook(payload, request = devRequest) {
111
+ /** Policy refusals a Figma hook must surface (exit 2); anything else fails open. */
112
+ export const FIGMA_HOOK_DENIALS = ["FIGMA_FILE_KEY_MISSING", "FIGMA_LOCK_DENIED", "FIGMA_OUTSIDE_LANE_SCOPE", "FIGMA_SCOPE_INVALID", "WRITE_OUT_OF_SCOPE"];
113
+ export const isHookDenial = (code) => String(code ?? "").startsWith("SAFETY_") || FIGMA_HOOK_DENIALS.includes(code);
114
+
115
+ export async function preHook(payload, request = devRequest, laneScope = currentLaneFigmaScope) {
111
116
  const name = payload.tool_name ?? "";
112
117
  if (!isFigmaTool(name) || isReadTool(name))
113
118
  return { allowed: true, ignored: true };
@@ -118,6 +123,16 @@ export async function preHook(payload, request = devRequest) {
118
123
  "Blocked Figma write: no fileKey/file_key/Figma URL was found.",
119
124
  2,
120
125
  );
126
+ // A write outside the lane's Freeze Figma scope is refused before the lock check.
127
+ let scope;
128
+ try {
129
+ scope = await laneScope({ cwd: payload.cwd ?? process.cwd() }, { request });
130
+ } catch (error) {
131
+ // A lane whose stored scope is invalid is refused, never left unbounded.
132
+ if (error?.code === "FIGMA_SCOPE_INVALID") throw error;
133
+ throw new CliError("FIGMA_CHECK_FAILED", `Blocked Figma write: ${error.message}`, 2);
134
+ }
135
+ await assertWithinLaneScope(scope, fileKey, null);
121
136
  const config = (await import("./core.js")).readConfig;
122
137
  const { agentId } = await config();
123
138
  let response;
package/dist/lanes.js CHANGED
@@ -4,8 +4,9 @@ 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";
7
+ import { GATHER_TIMEOUT_MS, PICKER_MODEL, gatherText, pickDocuments, withPickLock } from "./gather.js";
8
8
  import { storeGatherFreeze } from "./freeze.js";
9
+ import { SWAP_BOUNDARIES, componentsForScope, guidelineLookup, laneFigmaScope, loadDsCatalog } from "./figma-scope.js";
9
10
 
10
11
  const execFileAsync = promisify(execFile);
11
12
  const via = { via: "nodii-dev" };
@@ -46,7 +47,65 @@ export async function inferGitContext() {
46
47
  }
47
48
 
48
49
  const laneLine = (x) => `${x.key}\t${x.status}\t${x.kind}/${x.side}\t${x.title}${x.stuck ? "\tSTUCK" : ""}`;
49
- const detailText = (x) => [laneLine(x), ...(x.steps ?? []).map((s) => ` ${s.side}\t${s.state}\t${s.stepKey}\t${s.label}`)].join("\n");
50
+ // GATHER-NA: a step the coordinator ruled not applicable is never shown as done.
51
+ const stepState = (s) => s.state === "not_applicable" ? `not applicable: ${s.notApplicableReason ?? "(no reason recorded)"}` : s.state;
52
+ const detailText = (x) => [laneLine(x), ...(x.steps ?? []).map((s) => ` ${s.side}\t${stepState(s)}\t${s.stepKey}\t${s.label}`)].join("\n");
53
+
54
+ /**
55
+ * GATHER-NA: `--picker agent` makes the lane agent Gather's picker. Without
56
+ * --shortlist it only shortlists and prints the triggers to pick from; with
57
+ * --shortlist ID --documents A,B it records that pick against the recorded
58
+ * shortlist: Dev reuses its objective, paths and components, refuses a pick
59
+ * it did not offer, and adds its forced triggers. Gather never records a
60
+ * pick with no shortlist behind it.
61
+ */
62
+ function agentPickerFlags(flags) {
63
+ if (flags.picker === undefined) {
64
+ if (flags.shortlist !== undefined || flags.documents !== undefined)
65
+ throw new CliError("USAGE", "--shortlist and --documents go with --picker agent.", 2);
66
+ return null;
67
+ }
68
+ if (flags.picker !== "agent")
69
+ throw new CliError("USAGE", `--picker accepts only agent; without it the local ${PICKER_MODEL} picks.`, 2);
70
+ const shortlist = typeof flags.shortlist === "string" ? flags.shortlist.trim() : undefined;
71
+ const documents = typeof flags.documents === "string" ? list(flags.documents) : undefined;
72
+ if ((flags.shortlist !== undefined && !shortlist) || (flags.documents !== undefined && !documents?.length))
73
+ throw new CliError("USAGE", "--shortlist needs an id and --documents needs one or more codes.", 2);
74
+ if (Boolean(shortlist) !== Boolean(documents))
75
+ throw new CliError("USAGE", "Record an agent pick with both --shortlist ID and --documents CODE[,CODE]; run --picker agent alone first to see the shortlist.", 2);
76
+ return { shortlist, documents };
77
+ }
78
+
79
+ // hub-58: the reason is a fixed code (Dev stores its canonical text); a note may add context.
80
+ export const NOT_APPLICABLE_REASON_CODES = ["pre_harness_lane"];
81
+ function notApplicableRuling(flags) {
82
+ const code = flags["reason-code"];
83
+ if (!NOT_APPLICABLE_REASON_CODES.includes(code))
84
+ throw new CliError("USAGE", `--not-applicable needs --reason-code ${NOT_APPLICABLE_REASON_CODES.join("|")}.`, 2);
85
+ if (flags.note !== undefined && (typeof flags.note !== "string" || !flags.note.trim()))
86
+ throw new CliError("USAGE", "--note needs non-empty text.", 2);
87
+ return { reasonCode: code, ...(flags.note !== undefined ? { note: flags.note.trim() } : {}) };
88
+ }
89
+
90
+ const shellQuote = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
91
+ // The follow-up repeats the flags that shaped this Gather, so it records the same one.
92
+ const agentFollowUp = (ref, shortlistId, flags) => [
93
+ `nodii-dev lane gather ${ref} --picker agent --shortlist ${shortlistId} --documents CODE[,CODE]`,
94
+ ...(flags.delta ? ["--delta"] : []),
95
+ // --paths/--components are not resent to Dev (it reuses the shortlist's),
96
+ // but the local gathered set for the nudge hooks reads them.
97
+ ...["objective", "done-check", "section-limit-per-document", "paths", "components"]
98
+ .filter((flag) => typeof flags[flag] === "string")
99
+ .map((flag) => `--${flag} ${shellQuote(flags[flag])}`),
100
+ ].join(" ");
101
+ const agentShortlistText = (ref, data, flags) => [
102
+ `Trigger shortlist ${data.shortlistId} · ${JSON.stringify(data.objective)}`,
103
+ "You are Gather's picker (picker: agent). Nothing is recorded yet.",
104
+ ...data.triggers.map((trigger) => ` ${trigger.code}\t${trigger.title ?? ""}: ${trigger.sentence ?? ""}${trigger.forcedBy?.length ? ` [forced by ${trigger.forcedBy.join(", ")}]` : ""}`),
105
+ "",
106
+ "Pick every document that applies (forced ones are added for you), then record the pick:",
107
+ ` ${agentFollowUp(ref, data.shortlistId, flags)}`,
108
+ ].join("\n");
50
109
 
51
110
  function evidence(flags) {
52
111
  const selected = ["pr", "commit", "figma", "deploy", "dev-check"].filter((x) => flags[x] !== undefined);
@@ -148,7 +207,7 @@ async function currentGatherLane(request, config, gitContext) {
148
207
  return matches[0].key;
149
208
  }
150
209
 
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 } = {}) {
210
+ 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, catalog = loadDsCatalog, pickLock = withPickLock } = {}) {
152
211
  if (action === "list") {
153
212
  const c = flags.mine ? await config() : null;
154
213
  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 })}`);
@@ -183,6 +242,7 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
183
242
  const deadline = controller ? setTimeout(() => controller.abort(), gatherTimeoutMs) : null;
184
243
  const signal = gatherSignal ?? controller.signal;
185
244
  try {
245
+ const byAgent = agentPickerFlags(flags);
186
246
  const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`, undefined, { signal })).data;
187
247
  if (flags.objective !== undefined && (typeof flags.objective !== "string" || !flags.objective.trim()))
188
248
  throw new CliError("USAGE", "lane gather --objective needs non-empty text", 2);
@@ -243,11 +303,29 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
243
303
  ...(Array.isArray(lane.repos) ? lane.repos.map((repo) => typeof repo === "string" ? repo : repo.repo) : []),
244
304
  ];
245
305
  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 ?? []),
306
+ // The Figma scope (published keys) pulls in the DS components its
307
+ // Surfaces are built from, so their guidelines and U-docs apply.
308
+ // null = no declared Figma scope (kept undeclared); [] = declared "no Figma".
309
+ // A first Gather on a launch scope stored before keys were enforced
310
+ // stores "no Figma" (fails closed) so the lane can swap boundaries.
311
+ let declaredFigma;
312
+ let invalidScope = null;
313
+ try {
314
+ declaredFigma = laneFigmaScope(lane);
315
+ } catch (error) {
316
+ if (error?.code !== "FIGMA_SCOPE_INVALID" || lane.freeze) throw error;
317
+ declaredFigma = [];
318
+ invalidScope = error.reason ?? error.message;
319
+ }
320
+ const figmaScope = declaredFigma ?? [];
321
+ const scoped = figmaScope.some((file) => file.componentSets.length)
322
+ ? componentsForScope(await catalog({ signal }), figmaScope)
323
+ : { surfaces: [], components: [], unknownKeys: [] };
324
+ const components = flags.components ? list(flags.components) : [...new Set([
325
+ ...scoped.components,
326
+ ...scoped.surfaces,
249
327
  ...(lane.components ?? []),
250
- ];
328
+ ])];
251
329
  const shortlistBody = {
252
330
  objective,
253
331
  paths,
@@ -255,9 +333,17 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
255
333
  components,
256
334
  ...(limit !== undefined ? { limit } : {}),
257
335
  };
258
- const shortlist = await request("POST", "/api/v1/rules/shortlist", shortlistBody, { signal });
336
+ // An agent pick records against the shortlist it was made from.
337
+ const shortlist = byAgent?.shortlist
338
+ ? { data: { shortlistId: byAgent.shortlist, triggers: [] } }
339
+ : await request("POST", "/api/v1/rules/shortlist", shortlistBody, { signal });
340
+ const shortlistId = shortlist.data?.shortlistId;
259
341
  const triggers = shortlist.data?.triggers ?? [];
260
- const picked = await picker({
342
+ if (byAgent && !byAgent.shortlist)
343
+ return { payload: { data: { recorded: false, picker: "agent", shortlistId, objective, triggers } }, human: (p) => agentShortlistText(ref, p.data, flags) };
344
+ // Only the local model pick takes the per-lane pick lock; an agent pick
345
+ // records a choice already made (AGENT-HARNESS-19 + GATHER-NA).
346
+ const picked = byAgent ? { documents: byAgent.documents } : await pickLock(lane.id ?? id, () => picker({
261
347
  objective,
262
348
  files: paths,
263
349
  triggers: triggers.map((trigger) => ({
@@ -265,19 +351,22 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
265
351
  trigger: trigger.sentence,
266
352
  ...(trigger.forcedBy ? { forcedBy: trigger.forcedBy } : {}),
267
353
  })),
268
- }, { signal });
354
+ }, { signal }));
269
355
  const payload = await request("POST", `/api/v1/lanes/${encode(id)}/gather`, {
270
356
  objective,
271
357
  documentCodes: picked.documents,
272
- paths,
273
- components,
358
+ // An agent pick answers the shortlist's own paths and components.
359
+ ...(byAgent ? {} : { paths, components }),
274
360
  mode: flags.delta ? "delta" : "full",
275
361
  ...(sectionLimit !== undefined ? { sectionLimitPerDocument: sectionLimit } : {}),
362
+ shortlistId,
363
+ picker: byAgent ? "agent" : picked.model ?? PICKER_MODEL,
276
364
  }, { signal });
277
- const figma = lane.freeze?.boundaries?.figma ?? (lane.launch?.figmaScopes ?? []).map((file) => ({
365
+ const figma = figmaScope.map((file) => ({
278
366
  fileKey: file.fileKey,
279
- ...(file.componentSets?.length ? { componentSetKeys: file.componentSets } : {}),
367
+ ...(file.componentSets.length ? { componentSetKeys: file.componentSets } : {}),
280
368
  }));
369
+ const lookup = guidelineLookup(scoped.components);
281
370
  const issue = lane.issue ? { key: lane.issue.key, title: lane.issue.title } : null;
282
371
  const laneNotes = [
283
372
  lane.launch?.notes,
@@ -287,16 +376,23 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
287
376
  const freezeRequest = (method, path, body, options = {}) => request(method, path, body, { ...options, signal });
288
377
  await storeFreeze(id, {
289
378
  gather: payload.data,
290
- boundaries: { repos: repositories, ...(figma.length ? { figma } : {}) },
379
+ boundaries: { repos: repositories, ...(declaredFigma ? { figma } : {}) },
291
380
  issue,
292
381
  laneNotes,
293
382
  doneCheck,
383
+ lookups: lookup ? [lookup] : [],
294
384
  }, { request: freezeRequest });
295
385
  const reasons = Object.fromEntries(triggers.map((trigger) => [
296
386
  trigger.code,
297
387
  Array.isArray(trigger.forcedBy) && trigger.forcedBy.length ? `forced by ${trigger.forcedBy.join(", ")}` : trigger.sentence,
298
388
  ]));
299
- return { payload, human: (p) => gatherText(p, objective, reasons) };
389
+ const scopeNotice = [
390
+ ...(scoped.unknownKeys.length ? [`Notice: no DS catalog Surface has component-set key ${scoped.unknownKeys.join(", ")}; no component guidelines were pulled for ${scoped.unknownKeys.length === 1 ? "it" : "them"}.`] : []),
391
+ ...(invalidScope ? [`Notice: the launch Figma scope is not published keys (${invalidScope.replace(/\.$/, "")}), so the Freeze was stored with no Figma scope and every Figma lock and write is refused. Set the published keys with ${SWAP_BOUNDARIES(id)}.`] : []),
392
+ ].map((line) => `\n${line}`).join("");
393
+ // The pick's model and latency ride along in --json (morning check records them).
394
+ const result = { ...payload, picker: { model: picked.model ?? null, latency: picked.latency ?? null } };
395
+ return { payload: result, human: (p) => `${gatherText(p, objective, reasons)}${scopeNotice}` };
300
396
  } catch (error) {
301
397
  if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded its ${Math.round(gatherTimeoutMs / 1000)}-second deadline; nothing was retried.`, 1);
302
398
  throw error;
@@ -305,15 +401,24 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
305
401
  }
306
402
  }
307
403
  if (action === "step") {
308
- const selected = ["start", "done", "block"].filter((x) => flags[x]);
309
- if (selected.length !== 1) throw new CliError("USAGE", "lane step needs exactly one of --start, --done, or --block.", 2);
404
+ const selected = ["start", "done", "block", "not-applicable"].filter((x) => flags[x]);
405
+ if (selected.length !== 1) throw new CliError("USAGE", "lane step needs exactly one of --start, --done, --block, or --not-applicable.", 2);
310
406
  const side = requireFlag(flags, "side");
311
- const stepRef = need(flags[selected[0]] === true ? args[1] : flags[selected[0]], "lane step needs <key> --side <side> --start|--done|--block <step>");
407
+ const stepRef = need(flags[selected[0]] === true ? args[1] : flags[selected[0]], "lane step needs <key> --side <side> --start|--done|--block|--not-applicable <step>");
312
408
  const reason = typeof flags.reason === "string" ? flags.reason.trim() : "";
313
409
  if (flags.reason !== undefined && (selected[0] !== "block" || !reason)) throw new CliError("USAGE", "--reason TEXT goes with --block.", 2);
410
+ const ruling = selected[0] === "not-applicable" ? notApplicableRuling(flags) : null;
411
+ if (!ruling && (flags["reason-code"] !== undefined || flags.note !== undefined)) throw new CliError("USAGE", "--reason-code and --note go with --not-applicable.", 2);
314
412
  const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`)).data;
315
413
  const step = lane.steps.find((x) => x.side === side && (x.id === stepRef || x.stepKey === stepRef));
316
414
  if (!step) throw new CliError("STEP_NOT_FOUND", `No ${side} step ${stepRef} on ${ref}.`, 1);
415
+ // hub-58: Gather is the only step a coordinator may rule not applicable.
416
+ if (ruling && step.stepKey !== "gather") throw new CliError("USAGE", "--not-applicable covers only the gather step.", 2);
417
+ // GATHER-NA: coordinator only; Dev refuses a lane identity (COORDINATOR_REQUIRED).
418
+ if (ruling) {
419
+ const payload = await request("POST", `/api/v1/lanes/${encode(id)}/steps/${encode(step.id)}/not-applicable`, { ...ruling, ...via });
420
+ return { payload, human: (p) => `${ref}: ${side}/${step.stepKey} not applicable: ${p.data.notApplicableReason}${ruling.note ? ` (${ruling.note})` : ""}` };
421
+ }
317
422
  // Dev's blockLaneStep stores no reason and refuses a step that is already
318
423
  // blocked (INVALID_STEP_STATE), so the reason is a lane heartbeat note
319
424
  // until Dev issue 125 lets the step carry it.
package/dist/nodii-dev.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { fail, main } from "./cli.js";
3
3
  import { regroundCurrentLane, shouldReground } from "./freeze.js";
4
+ import { isHookDenial } from "./hooks.js";
4
5
 
5
6
  // Hooks run in the agent's environment, which may be incomplete or may not be
6
7
  // a git worktree. An unavailable hook must never turn into a refused tool call.
@@ -16,8 +17,7 @@ async function run() {
16
17
  }
17
18
 
18
19
  run().catch((error) => {
19
- const denial = String(error?.code ?? "").startsWith("SAFETY_") ||
20
- ["FIGMA_FILE_KEY_MISSING", "FIGMA_LOCK_DENIED", "WRITE_OUT_OF_SCOPE"].includes(error?.code);
20
+ const denial = isHookDenial(error?.code);
21
21
  if (argv[0] === "hook" && !denial) {
22
22
  process.exitCode = 0;
23
23
  return;
@@ -108,7 +108,8 @@ function buildInvocation(options, { env = process.env, home = homedir() } = {})
108
108
  const { runId, laneKey, runtime, model, cwd, addDirs, profileHome, devConfigPath } = options;
109
109
  const args = runtime === "codex"
110
110
  ? ["exec", "--json", "--cd", cwd, "--model", model, ...addDirs.flatMap((path) => ["--add-dir", path]), "-"]
111
- : ["--print", "--output-format", "stream-json", "--model", model, ...addDirs.flatMap((path) => ["--add-dir", path])];
111
+ // Claude Code refuses --print with stream-json unless --verbose is set.
112
+ : ["--print", "--output-format", "stream-json", "--verbose", "--model", model, ...addDirs.flatMap((path) => ["--add-dir", path])];
112
113
  return {
113
114
  command: runtime,
114
115
  args,
package/dist/starting.js CHANGED
@@ -4,6 +4,7 @@ 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 { assertKnownSets, checkComponentSetKey, checkFileKey, loadDsCatalog, mergeScopes, scopesFromRoutes } from "./figma-scope.js";
7
8
  import { GATHER_TIMEOUT_MS } from "./gather.js";
8
9
  import { laneCommand, nextCommand } from "./lanes.js";
9
10
 
@@ -79,18 +80,30 @@ export function launchRepositories(key, flags) {
79
80
  }
80
81
 
81
82
  export function figmaScopes(value) {
82
- return values(value).map((raw) => {
83
+ return mergeScopes(values(value).map((raw) => {
83
84
  const scope = String(raw).trim();
84
85
  const at = scope.indexOf("=");
85
86
  const fileKey = (at < 0 ? scope : scope.slice(0, at)).trim();
86
87
  const componentSets = at < 0 ? [] : scope.slice(at + 1).split(",").map((item) => item.trim()).filter(Boolean);
87
88
  if (!fileKey || (at >= 0 && componentSets.length === 0))
88
- throw new CliError("USAGE", "--figma must be FILE_KEY or FILE_KEY=COMPONENT_SET[,COMPONENT_SET] and may be repeated.", 2);
89
+ throw new CliError("USAGE", "--figma must be FILE_KEY or FILE_KEY=COMPONENT_SET_KEY[,COMPONENT_SET_KEY] (published keys) and may be repeated.", 2);
89
90
  return {
90
- fileKey,
91
- componentSets,
91
+ fileKey: checkFileKey(fileKey),
92
+ componentSets: componentSets.map(checkComponentSetKey),
92
93
  };
93
- });
94
+ }));
95
+ }
96
+
97
+ /** --figma as given, plus what each --route derives from the DS catalog. */
98
+ export async function launchFigmaScopes(flags, { catalog = loadDsCatalog } = {}) {
99
+ const explicit = figmaScopes(flags.figma);
100
+ const routes = values(flags.route).map((route) => String(route).trim());
101
+ if (routes.some((route) => !route.startsWith("/")))
102
+ throw new CliError("USAGE", "--route must be a frontend route such as /app/dev/building and may be repeated.", 2);
103
+ if (!routes.length && !explicit.some((scope) => scope.componentSets.length)) return explicit;
104
+ const surfaces = await catalog();
105
+ assertKnownSets(surfaces, explicit);
106
+ return routes.length ? mergeScopes(explicit, scopesFromRoutes(surfaces, routes)) : explicit;
94
107
  }
95
108
 
96
109
  const stateFile = (machine) => join(dirname(configPath()), `coordinator-${encodeURIComponent(machine)}.json`);
@@ -273,6 +286,7 @@ export async function launchLaneCommand(key, flags, {
273
286
  head = primaryHead,
274
287
  launcher = defaultLaunchRuntime,
275
288
  figmaSeat = verifyFigmaSeat,
289
+ catalog = loadDsCatalog,
276
290
  laneConfig = loadJson,
277
291
  identityPath = laneConfigPath,
278
292
  } = {}) {
@@ -294,7 +308,7 @@ export async function launchLaneCommand(key, flags, {
294
308
  const identity = await laneConfig(devConfigPath);
295
309
  if (!identity?.agentId)
296
310
  throw new CliError("LANE_IDENTITY_REQUIRED", `Lane ${key} needs its provisioned Dev identity at ${devConfigPath}.`, 1);
297
- const scopes = figmaScopes(flags.figma);
311
+ const scopes = await launchFigmaScopes(flags, { catalog });
298
312
  const figmaSeatVerified = scopes.length ? await figmaSeat() : false;
299
313
  const profile = requireFlag(flags, "profile");
300
314
  if (!isAbsolute(profile)) throw new CliError("USAGE", "--profile must be an absolute runtime profile path.", 2);
@@ -356,7 +370,11 @@ export async function launchLaneCommand(key, flags, {
356
370
  }
357
371
  throw new CliError("LANE_LAUNCH_FAILED", `Lane ${key} is registered but its runtime did not start: ${error.message}. Dev recorded it for lane resume.`, 1);
358
372
  }
359
- return { payload, human: `Launched ${key}: ${payload.data.brief}` };
373
+ const figmaLines = body.figmaScopes.map((scope) => `Figma scope: ${scope.fileKey} ${scope.componentSets.length ? `component sets ${scope.componentSets.join(", ")}` : "(whole file)"}`);
374
+ return {
375
+ payload: { ...payload, figmaScopes: body.figmaScopes },
376
+ human: [`Launched ${key}: ${payload.data.brief}`, ...(figmaLines.length ? figmaLines : ["Figma scope: none declared"])].join("\n"),
377
+ };
360
378
  }
361
379
 
362
380
  export async function amendLaneCommand(key, flags, { request = devRequest } = {}) {
@@ -394,8 +412,14 @@ export async function amendmentGuard(argv, { request = devRequest, marker = curr
394
412
  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 });
395
413
  }
396
414
 
415
+ // Gather's human output is a renderer of its payload; Delta gather prints the
416
+ // Clause map too, never only "Gather complete.".
417
+
397
418
  const runGather = (key, flags, { request, signal }) => laneCommand("gather", [key], flags, { request, gatherSignal: signal });
398
419
 
420
+ // Gather's human text is its Clause map (a function of the payload).
421
+ const gatheredText = (gathered) => typeof gathered.human === "function" ? gathered.human(gathered.payload) : gathered.human ?? "Gather complete.";
422
+
399
423
  export async function gatherLaneCommand(key, flags, { request = devRequest, marker = currentMarker, gather = runGather, acknowledge = nextCommand, gatherTimeoutMs = GATHER_TIMEOUT_MS } = {}) {
400
424
  const controller = new AbortController();
401
425
  const deadline = setTimeout(() => controller.abort(), gatherTimeoutMs);
@@ -411,10 +435,13 @@ export async function gatherLaneCommand(key, flags, { request = devRequest, mark
411
435
  const pending = await timedRequest("GET", `/api/v1/lanes/${encode(key)}/amendments${query({ runId: lane.runId })}`);
412
436
  if (!pending.data.length) {
413
437
  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.` };
438
+ if (gathered.payload?.data?.recorded === false) return gathered;
439
+ return { ...gathered, human: `${gatheredText(gathered)}\nNo pending amendments.` };
415
440
  }
416
441
  const version = Math.max(...pending.data.map((item) => item.version));
417
442
  const gathered = await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
443
+ // An agent picker's shortlist step records nothing, so it acknowledges nothing.
444
+ if (gathered.payload?.data?.recorded === false) return gathered;
418
445
  const acknowledgement = await withSignal(acknowledge(key, {
419
446
  ...flags,
420
447
  deltaContext: { runId: lane.runId, amendmentVersion: version },
@@ -424,7 +451,7 @@ export async function gatherLaneCommand(key, flags, { request = devRequest, mark
424
451
  throw new CliError("LANE_AMENDMENT_ACK_FAILED", `Delta gather did not acknowledge amendment version ${version}; the lane remains blocked.`, 1);
425
452
  return {
426
453
  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}.`,
454
+ human: `${pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n")}\n${gatheredText(gathered)}\nAcknowledged amendment version ${version}.`,
428
455
  };
429
456
  } catch (error) {
430
457
  if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded its ${Math.round(gatherTimeoutMs / 1000)}-second deadline; nothing was retried.`, 1);
package/dist/tune.js CHANGED
@@ -345,7 +345,7 @@ export async function tuneCommand(action, args, flags, { request = devRequest, p
345
345
  } catch (error) {
346
346
  // A Gemma picker this Mac cannot run is reported, not filled in; any
347
347
  // other failure is a real error and stops the run.
348
- if (!(error instanceof CliError) || !["PICKER_UNAVAILABLE", "PICKER_MODEL_MISSING", "PICKER_MODEL_VERSION_MISMATCH", "OLLAMA_URL_INVALID"].includes(error.code)) throw error;
348
+ if (!(error instanceof CliError) || !["PICKER_UNAVAILABLE", "PICKER_TIMEOUT", "PICKER_MODEL_MISSING", "PICKER_MODEL_VERSION_MISMATCH", "OLLAMA_URL_INVALID"].includes(error.code)) throw error;
349
349
  Object.assign(result, { status: "unavailable", code: error.code, reason: error.message });
350
350
  }
351
351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.32",
3
+ "version": "0.6.34",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {