@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 +17 -0
- package/dist/cli.js +24 -3
- package/dist/core.js +1 -1
- package/dist/figma-scope.js +205 -0
- package/dist/figma.js +16 -3
- package/dist/freeze.js +10 -1
- package/dist/gather.js +177 -12
- package/dist/hooks.js +16 -1
- package/dist/lanes.js +124 -19
- package/dist/nodii-dev.js +2 -2
- package/dist/runtime-launcher.js +2 -1
- package/dist/starting.js +36 -9
- package/dist/tune.js +1 -1
- package/package.json +1 -1
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
|
|
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 (!
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
176
|
+
model,
|
|
173
177
|
stream: false,
|
|
174
|
-
format:
|
|
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: (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
365
|
+
const figma = figmaScope.map((file) => ({
|
|
278
366
|
fileKey: file.fileKey,
|
|
279
|
-
...(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, ...(
|
|
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
|
-
|
|
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 --
|
|
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 =
|
|
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;
|
package/dist/runtime-launcher.js
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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${
|
|
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
|
}
|