@nodii/dev 0.6.30 → 0.6.32
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 +41 -0
- package/dist/cli.js +65 -6
- package/dist/core.js +1 -1
- package/dist/gather.js +33 -8
- package/dist/harness.js +2 -0
- package/dist/managed-hooks.js +6 -1
- package/dist/nudges.js +441 -0
- package/dist/tune.js +361 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -137,6 +137,12 @@ nodii-dev lane freeze swap KEY --section SECTION [--item ID] --file VALUE.json -
|
|
|
137
137
|
nodii-dev lane freeze show KEY
|
|
138
138
|
nodii-dev lane freeze history KEY
|
|
139
139
|
nodii-dev rules find "QUESTION" [--limit N]
|
|
140
|
+
nodii-dev gather tune cases|runs
|
|
141
|
+
nodii-dev gather tune replay CASE_ID
|
|
142
|
+
nodii-dev gather tune start --cases all|ID,ID --corpus-ref REF [--trigger T] [--settings JSON]
|
|
143
|
+
nodii-dev gather tune exec RUN_ID [--pickers shortlist-only,gemma-e4b,gemma-12b,gemma-26b]
|
|
144
|
+
nodii-dev gather tune trial RUN_ID --case ID --picker P --iteration N [--documents A,B]
|
|
145
|
+
nodii-dev gather tune finalize RUN_ID
|
|
140
146
|
|
|
141
147
|
nodii-dev open-questions list|show|answer
|
|
142
148
|
nodii-dev feature-docs list|show|claim|release|status
|
|
@@ -313,6 +319,9 @@ nodii-dev hook claude-figma-pre # reads Claude Code hook JSON on stdin
|
|
|
313
319
|
nodii-dev hook claude-figma-post # reads Claude Code hook JSON on stdin
|
|
314
320
|
nodii-dev hook safety-pre # silent safety decision; blocks with exit 2
|
|
315
321
|
nodii-dev hook safety-post # silently records resources created by successful commands
|
|
322
|
+
nodii-dev hook nudge-pre # guideline nudges before a tool call; never blocks
|
|
323
|
+
nodii-dev hook nudge-stop # guideline nudges when a lane session stops; asks once
|
|
324
|
+
nodii-dev figma checked CS-n --unused-properties --stale-placeholders --dialog-body
|
|
316
325
|
nodii-dev hook print-settings # prints managed-settings JSON; writes nothing
|
|
317
326
|
|
|
318
327
|
nodii-dev lane start KEY --title TEXT --kind dev_fix|new_build|docs_change|uat_fix|prod_fix
|
|
@@ -429,6 +438,38 @@ deploy. A coordinator's exceptional `--no-verify` also requires
|
|
|
429
438
|
`NODII_DEV_NO_VERIFY_REASON`; the CLI records the reason in private Git
|
|
430
439
|
metadata. Lanes and subagents can never use `--no-verify`.
|
|
431
440
|
|
|
441
|
+
### Guideline nudges (AGENT-HARNESS-21)
|
|
442
|
+
|
|
443
|
+
`harness install` and `harness update` add two more `nodii-dev` hooks for
|
|
444
|
+
Claude Code and Codex. A nudge reminds a lane of a rule at the moment it is
|
|
445
|
+
about to be broken and **says nothing when the rule is met**. Nudges never
|
|
446
|
+
block a tool call, fail open on any error or unknown payload, and stay silent
|
|
447
|
+
in any session that is not a Dev lane. Each cites the clause it protects.
|
|
448
|
+
|
|
449
|
+
| Moment | Nudge | Silent when |
|
|
450
|
+
|---|---|---|
|
|
451
|
+
| Edit or write | A DS component or product page is used or edited and its guideline is not in the lane's gathered set (U07-19, DEC-0010) | the guideline is in the gathered set, or was read with `design guideline show` |
|
|
452
|
+
| Edit or write | `className` or `style` on a DS component (U07-19) | the tag is not a DS import, or has neither prop |
|
|
453
|
+
| Edit or write | A new migration: journal `when` = max + 1, then a Delta gather. A `_journal.json` write whose `when` values do not rise | the migration is covered by a Delta gather, or already exists |
|
|
454
|
+
| Edit or write | Code renders a person: `PiiText` without `self`, a raw `.email`/`.phone`/`.displayName`, or the page masks a value itself (R5, U05-25) | `PiiText` carries `self`, or no person is rendered |
|
|
455
|
+
| Figma read | A code lane reads by node id (`get_design_context`, `get_metadata`, `get_screenshot`, ...) instead of the published key (MW8-20) | a Figma lane, or no node id |
|
|
456
|
+
| `figma submit CS-n` | The unused-property, stale `Blocked on DS` placeholder and Dialog-without-body hand checks (F-34, CS-2453) | all three are recorded with `figma checked CS-n --unused-properties --stale-placeholders --dialog-body` |
|
|
457
|
+
| AskUserQuestion | The question is also an item in `docs/figma-ux-drive/qa-with-abhi/open.md` (R7) | open.md already holds the question |
|
|
458
|
+
| Stop | The lane is not `on_dev` but "done" was claimed (MW3); an "I couldn't" with no filed issue; Unconfirmed Freeze items still open; uncommitted work | none of those; a Stop that was already nudged once may stop |
|
|
459
|
+
|
|
460
|
+
The lane's gathered set is the private Git file `nodii-lane-knowledge.json`:
|
|
461
|
+
`lane gather --components a,b` adds the components it covered to `guidelines`,
|
|
462
|
+
`lane gather --delta` adds the paths it saw (`--paths`, else every path this
|
|
463
|
+
worktree changes against origin/main) to `deltaGathered`, and `design guideline
|
|
464
|
+
show` adds to `guidelinesRead`. A part's guideline is its root's (`dialog`
|
|
465
|
+
covers `dialog-content`). Edits are judged against the file they produce, so
|
|
466
|
+
an Edit or `apply_patch` that only changes an attribute is read in full.
|
|
467
|
+
|
|
468
|
+
The Stop nudge asks the session to continue once (`decision: block`, skipped
|
|
469
|
+
when `stop_hook_active`); Codex passes it through its managed dispatcher. A
|
|
470
|
+
nudge hook skips the amendment guard and the waiting-results banner, since
|
|
471
|
+
either would corrupt its JSON; it cannot run a Delta gather.
|
|
472
|
+
|
|
432
473
|
### DS conformance: `design roles` and `design check` (DEC-0034)
|
|
433
474
|
|
|
434
475
|
Every UI role names exactly one DS component (MW6-14). Resolve your brief's
|
package/dist/cli.js
CHANGED
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
import { 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
|
-
import { hookRuntime, recordHookFire, safetyPostHook, safetyPreHook } from "./safety.js";
|
|
26
|
+
import { hookRuntime, recordHookFire, safetyContext, safetyPostHook, safetyPreHook } from "./safety.js";
|
|
27
|
+
import { SUBMIT_CHECK_FLAGS, changedPaths, nudgeCandidate, nudgePre, nudgeStop, recordGathered, recordGuidelineRead, recordSubmitChecks } from "./nudges.js";
|
|
27
28
|
import { doctorCommand } from "./doctor.js";
|
|
28
29
|
import { dispatchManagedHook } from "./managed-hooks.js";
|
|
29
30
|
import { freezeCommand } from "./freeze.js";
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
conformanceLines,
|
|
52
53
|
} from "./figma.js";
|
|
53
54
|
export { figmaLock, submitMode } from "./figma.js";
|
|
55
|
+
import { tuneCommand } from "./tune.js";
|
|
54
56
|
import { docsCommand, rulebookCommand, rulesCommand } from "./upload.js";
|
|
55
57
|
import { changeSetCommand, guidelineCommand } from "./guidelines.js";
|
|
56
58
|
import { designCheck, designCheckText, designRoles, designRolesText } from "./design.js";
|
|
@@ -126,6 +128,7 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
126
128
|
figma published FILE_KEY|CHANGE_SET --figma-version V # a file key marks every awaiting set published
|
|
127
129
|
figma check|log-write|submit|apply|abandon|release
|
|
128
130
|
figma submit CHANGE_SET --file SUBMIT.json [--sync]
|
|
131
|
+
figma checked CHANGE_SET --unused-properties --stale-placeholders --dialog-body # the hand checks the submit nudge asks for
|
|
129
132
|
# submit, resubmit and design check --change-set run as a job in Dev
|
|
130
133
|
# and poll it (a large set outlasts the edge's timeout); --sync waits
|
|
131
134
|
# on one request instead. A 502/504 is checked against the set, never resent
|
|
@@ -135,7 +138,7 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
135
138
|
figma changeset show CHANGE_SET # items, variants, round, decision and comment (with the previous round's)
|
|
136
139
|
figma file add FILE_KEY --name TEXT --kind design-system|surfaces|prototype [--module M]
|
|
137
140
|
# register a Figma file in Dev so it can be locked
|
|
138
|
-
hook claude-figma-pre|claude-figma-post|safety-pre|safety-post|session-start|managed-dispatch|print-settings
|
|
141
|
+
hook claude-figma-pre|claude-figma-post|safety-pre|safety-post|nudge-pre|nudge-stop|session-start|managed-dispatch|print-settings
|
|
139
142
|
setup [--yes|--check] # dry-run by default; installs/checks Ollama and pulls the Gather model
|
|
140
143
|
harness install|update [--include-drafts[=false]] [--adopt PATH,...] [--dry-run] [--system]
|
|
141
144
|
# --system stages managed hooks and only prints operator-reviewed sudo commands
|
|
@@ -153,6 +156,15 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
153
156
|
rules report [--section S] [--lane KEY] # delivery totals per document; rules served per lane
|
|
154
157
|
rules find "QUESTION" [--limit N] # answer an ad-hoc rules question from the indexed sections
|
|
155
158
|
rules cap TOKENS # operator: set the core token cap (default 3000)
|
|
159
|
+
gather tune cases|runs # Gather tuning (AH-11): frozen cases; runs with per-picker scores
|
|
160
|
+
gather tune replay <case-id> # blind replay + trigger shortlist, for the lane-agent picker
|
|
161
|
+
gather tune start --cases all|ID,ID --corpus-ref REF [--trigger manual] [--settings JSON]
|
|
162
|
+
gather tune exec <run-id> [--pickers shortlist-only,gemma-e4b,gemma-12b,gemma-26b]
|
|
163
|
+
# runs each automatic picker 5x per case; a Gemma model this Mac
|
|
164
|
+
# lacks is reported unavailable (exit 1), never filled in
|
|
165
|
+
gather tune trial <run-id> --case ID --picker P --iteration N [--documents A,B]
|
|
166
|
+
# one trial; lane-agent passes its own --documents pick
|
|
167
|
+
gather tune finalize <run-id> # Dev scores the full 5-picker x 5-repeat matrix
|
|
156
168
|
|
|
157
169
|
coordinator start --machine NAME # register this machine's one watched coordinator; refuses a live second
|
|
158
170
|
lane launch <key> --done-check TEXT --kind KIND --side SIDE
|
|
@@ -164,7 +176,7 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
164
176
|
# authoritative preflight, one worktree per repo, watched launch
|
|
165
177
|
# uses the provisioned agents/lane-<key>.json Dev identity
|
|
166
178
|
lane amend <key> --objective|--scope|--rules TEXT --reason TEXT
|
|
167
|
-
lane gather [<key>] [--delta] [--objective TEXT] [--done-check TEXT]
|
|
179
|
+
lane gather [<key>] [--delta] [--objective TEXT] [--done-check TEXT] [--components a,b] [--paths a,b]
|
|
168
180
|
# infers a launched worktree; explicit contract inputs support manual lane start
|
|
169
181
|
run [--kind build|test_run|subagent|deploy] [--name TEXT] -- <command> [args...]
|
|
170
182
|
# watched command: registers with NODII_DEV_RUN_ID,
|
|
@@ -444,7 +456,9 @@ export async function figmaFileAdd(value, flags, { request = devRequest } = {})
|
|
|
444
456
|
export async function main(argv = process.argv.slice(2)) {
|
|
445
457
|
// Amendment acknowledgement is a lane-wide backstop, including otherwise
|
|
446
458
|
// local commands such as help and version.
|
|
447
|
-
|
|
459
|
+
// A nudge hook is fail-open and writes only its own JSON: it cannot run a
|
|
460
|
+
// Delta gather, and a waiting-results banner would corrupt its output.
|
|
461
|
+
if (argv[0] !== "__run-supervisor" && !(argv[0] === "hook" && argv[1]?.startsWith("nudge-"))) {
|
|
448
462
|
await amendmentGuard(argv);
|
|
449
463
|
await showWaitingResults({ acknowledge: argv[0] !== "hook" });
|
|
450
464
|
}
|
|
@@ -564,8 +578,19 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
564
578
|
if (command === "docs") return emitCommand(await docsCommand(action, rest, f));
|
|
565
579
|
if (command === "rulebook" && ["list", "show", "diff"].includes(action)) return emitCommand(await rulebookCommand(action, rest, f));
|
|
566
580
|
if (command === "rules") return emitCommand(await rulesCommand(action, rest, f));
|
|
581
|
+
if (command === "gather" && action === "tune") {
|
|
582
|
+
const result = await tuneCommand(rest[0], rest.slice(1), f);
|
|
583
|
+
emit(result.payload, json, result.human);
|
|
584
|
+
if (result.exitCode) process.exitCode = result.exitCode;
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
567
587
|
// Guideline change sets: approving or rejecting one is a person's action in Dev.
|
|
568
|
-
if (command === "design" && action === "guideline")
|
|
588
|
+
if (command === "design" && action === "guideline") {
|
|
589
|
+
const result = await guidelineCommand(rest[0], rest.slice(1), f);
|
|
590
|
+
// A guideline read in a lane counts toward the lane's gathered set (nudge hooks).
|
|
591
|
+
if (rest[0] === "show" && rest[1]) await recordGuidelineRead(process.cwd(), rest[1]).catch(() => {});
|
|
592
|
+
return emitCommand(result);
|
|
593
|
+
}
|
|
569
594
|
if (command === "design" && action === "changeset") return emitCommand(await changeSetCommand(rest[0], rest.slice(1), f));
|
|
570
595
|
if (command === "design" && action === "roles") return emit(await designRoles(rest), json, designRolesText);
|
|
571
596
|
if (command === "design" && action === "check") {
|
|
@@ -582,7 +607,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
582
607
|
if (command === "coordinator") return emitCommand(await coordinatorCommand(action, f));
|
|
583
608
|
if (command === "lane" && action === "launch") return emitCommand(await launchLaneCommand(rest[0], f));
|
|
584
609
|
if (command === "lane" && action === "amend") return emitCommand(await amendLaneCommand(rest[0], f));
|
|
585
|
-
if (command === "lane" && action === "gather")
|
|
610
|
+
if (command === "lane" && action === "gather") {
|
|
611
|
+
const result = await gatherLaneCommand(rest[0], f);
|
|
612
|
+
// What a gather covered is the lane's gathered set for the nudge hooks.
|
|
613
|
+
const asked = (value) => (typeof value === "string" ? value.split(",").map((item) => item.trim()).filter(Boolean) : []);
|
|
614
|
+
await (async () => recordGathered(process.cwd(), { guidelines: asked(f.components), deltaGathered: f.delta ? (f.paths ? asked(f.paths) : await changedPaths(process.cwd())) : [] }))().catch(() => {});
|
|
615
|
+
return emitCommand(result);
|
|
616
|
+
}
|
|
586
617
|
if (command === "lane" && action === "freeze")
|
|
587
618
|
return emitCommand(await freezeCommand(rest[0], rest.slice(1), f));
|
|
588
619
|
if (command === "lane") return emitCommand(await laneCommand(action, rest, f));
|
|
@@ -816,6 +847,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
816
847
|
if (!result.data.allowed) process.exitCode = 3;
|
|
817
848
|
return;
|
|
818
849
|
}
|
|
850
|
+
if (action === "checked") {
|
|
851
|
+
const flagged = SUBMIT_CHECK_FLAGS.filter((check) => f[check]);
|
|
852
|
+
if (!isChangeSetId(rest[0]) || rest.length > 1 || !flagged.length)
|
|
853
|
+
throw new CliError("USAGE", `figma checked CHANGE_SET ${SUBMIT_CHECK_FLAGS.map((check) => `--${check}`).join(" ")} (at least one)`, 2);
|
|
854
|
+
await recordSubmitChecks(process.cwd(), rest[0], flagged);
|
|
855
|
+
return emit({ data: { changeSetId: rest[0], checked: flagged } }, json, `${rest[0]}: recorded ${flagged.join(", ")}.`);
|
|
856
|
+
}
|
|
819
857
|
if (action === "log-write")
|
|
820
858
|
return emit(await figmaLogWrite(rest[0], requireFlag(f, "tool"), requireFlag(f, "nodes").split(",").filter(Boolean)), json);
|
|
821
859
|
if (["apply", "published", "abandon"].includes(action)) {
|
|
@@ -921,6 +959,27 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
921
959
|
} catch {}
|
|
922
960
|
return;
|
|
923
961
|
}
|
|
962
|
+
// Guideline nudges (AGENT-HARNESS-21) never block: any error, a payload
|
|
963
|
+
// they don't know or a session outside a lane ends in no output.
|
|
964
|
+
if (action === "nudge-pre") {
|
|
965
|
+
try {
|
|
966
|
+
const payload = JSON.parse(await stdinText());
|
|
967
|
+
await recordHookFire(payload).catch(() => {});
|
|
968
|
+
if (!(await nudgeCandidate(payload))) return;
|
|
969
|
+
const nudges = await nudgePre(payload, await safetyContext(payload.cwd ?? process.cwd()));
|
|
970
|
+
if (nudges.length) console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: nudges.join("\n") } }));
|
|
971
|
+
} catch {}
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (action === "nudge-stop") {
|
|
975
|
+
try {
|
|
976
|
+
const payload = JSON.parse(await stdinText());
|
|
977
|
+
await recordHookFire(payload).catch(() => {});
|
|
978
|
+
const nudges = await nudgeStop(payload, await safetyContext(payload.cwd ?? process.cwd()));
|
|
979
|
+
if (nudges.length) console.log(JSON.stringify({ decision: "block", reason: nudges.join("\n") }));
|
|
980
|
+
} catch {}
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
924
983
|
if (action === "lane-heartbeat") {
|
|
925
984
|
const result = await heartbeatCurrentLane();
|
|
926
985
|
return emit(result, json, result.heartbeated ? `Heartbeat recorded for ${result.key}.` : "No current-branch lane; no heartbeat sent.");
|
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.32";
|
|
8
8
|
export const configPath = () =>
|
|
9
9
|
process.env.NODII_DEV_CONFIG_PATH ??
|
|
10
10
|
join(homedir(), ".config/nodii-dev/config.json");
|
package/dist/gather.js
CHANGED
|
@@ -7,9 +7,17 @@ const execFileAsync = promisify(execFile);
|
|
|
7
7
|
|
|
8
8
|
export const PICKER_MODEL = "gemma4:12b";
|
|
9
9
|
export const PICKER_MODEL_DIGEST = "sha256:6114515d63c17436a7c0417d82820ac65ad643e2806c5a3c89cb62846436ed0b";
|
|
10
|
+
// E4B and 26B are compared in Gather tuning only; each is pinned the same way
|
|
11
|
+
// (public registry tag + manifest digest) and never pulled by setup.
|
|
12
|
+
export const TUNING_MODELS = {
|
|
13
|
+
"gemma-e4b": { name: "gemma4:e4b", digest: "sha256:dc35e8d9c6061baa6f0fa870975ab6932e2542b579b13ea0f199fa4bb7300c9c" },
|
|
14
|
+
"gemma-12b": { name: PICKER_MODEL, digest: PICKER_MODEL_DIGEST },
|
|
15
|
+
"gemma-26b": { name: "gemma4:26b", digest: "sha256:001e5dafc3c77684c2307ebc6ab8e336e10c9b18eca52acf547d72fc83c3ca8c" },
|
|
16
|
+
};
|
|
10
17
|
export const PICKER_CONTEXT = 8192;
|
|
11
18
|
export const PICKER_TIMEOUT_MS = 15_000;
|
|
12
19
|
export const GATHER_TIMEOUT_MS = 60_000;
|
|
20
|
+
export const PICKER_WARM_TIMEOUT_MS = 180_000;
|
|
13
21
|
export const OLLAMA_URL = "http://127.0.0.1:11434";
|
|
14
22
|
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`.";
|
|
15
23
|
|
|
@@ -81,20 +89,24 @@ async function jsonRequest(path, { baseUrl = ollamaUrl(), fetchImpl = fetch, tim
|
|
|
81
89
|
return payload;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
const
|
|
92
|
+
const PINNED = { name: PICKER_MODEL, digest: PICKER_MODEL_DIGEST };
|
|
93
|
+
// Ollama's /api/tags reports the manifest digest as bare hex; the pin carries
|
|
94
|
+
// the registry's `sha256:` form. Compare them without the algorithm prefix.
|
|
95
|
+
const bareDigest = (digest) => typeof digest === "string" ? digest.replace(/^sha256:/, "") : null;
|
|
85
96
|
|
|
86
97
|
export async function inspectOllama(options = {}) {
|
|
98
|
+
const pin = options.model ?? PINNED;
|
|
87
99
|
const [version, tags] = await Promise.all([
|
|
88
100
|
jsonRequest("/api/version", options),
|
|
89
101
|
jsonRequest("/api/tags", options),
|
|
90
102
|
]);
|
|
91
|
-
const model = (tags.models ?? []).find(
|
|
103
|
+
const model = (tags.models ?? []).find((x) => x?.name === pin.name || x?.model === pin.name) ?? null;
|
|
92
104
|
return {
|
|
93
105
|
available: true,
|
|
94
106
|
version: String(version.version ?? "unknown"),
|
|
95
|
-
model:
|
|
107
|
+
model: pin.name,
|
|
96
108
|
modelInstalled: Boolean(model),
|
|
97
|
-
modelMatches: model?.digest ===
|
|
109
|
+
modelMatches: Boolean(model?.digest) && bareDigest(model.digest) === bareDigest(pin.digest),
|
|
98
110
|
digest: model?.digest ?? null,
|
|
99
111
|
size: model?.size ?? null,
|
|
100
112
|
details: model?.details ?? null,
|
|
@@ -102,11 +114,24 @@ export async function inspectOllama(options = {}) {
|
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
export async function requirePicker(options = {}) {
|
|
117
|
+
const pin = options.model ?? PINNED;
|
|
118
|
+
// setup pulls only the 12B Gather default; tuning-only models are pulled by hand.
|
|
119
|
+
const fix = pin.name === PICKER_MODEL ? "Run `nodii-dev setup --yes`" : `Run \`ollama pull ${pin.name}\``;
|
|
105
120
|
const status = await inspectOllama(options);
|
|
106
121
|
if (!status.modelInstalled)
|
|
107
|
-
throw new CliError("PICKER_MODEL_MISSING", `Local Gather picker is missing ${
|
|
122
|
+
throw new CliError("PICKER_MODEL_MISSING", `Local Gather picker is missing ${pin.name}. ${fix} to pull it.`, 1, status);
|
|
108
123
|
if (!status.modelMatches)
|
|
109
|
-
throw new CliError("PICKER_MODEL_VERSION_MISMATCH", `Local ${
|
|
124
|
+
throw new CliError("PICKER_MODEL_VERSION_MISMATCH", `Local ${pin.name} is ${status.digest ?? "an unknown digest"}; Gather requires ${pin.digest}. ${fix} to pull the pinned version.`, 1, status);
|
|
125
|
+
return status;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Load a pinned model before timed picks, so a cold load (tens of seconds for
|
|
130
|
+
* the larger tuning models) is not mistaken for a slow or unavailable picker.
|
|
131
|
+
*/
|
|
132
|
+
export async function warmPicker(options = {}) {
|
|
133
|
+
const status = await requirePicker(options);
|
|
134
|
+
await jsonRequest("/api/generate", { ...options, timeoutMs: options.timeoutMs ?? PICKER_WARM_TIMEOUT_MS, body: { model: (options.model ?? PINNED).name, prompt: "", stream: false } });
|
|
110
135
|
return status;
|
|
111
136
|
}
|
|
112
137
|
|
|
@@ -144,7 +169,7 @@ export async function pickDocuments(input, options = {}) {
|
|
|
144
169
|
const payload = await jsonRequest("/api/chat", {
|
|
145
170
|
...timedOptions,
|
|
146
171
|
body: {
|
|
147
|
-
model:
|
|
172
|
+
model: (options.model ?? PINNED).name,
|
|
148
173
|
stream: false,
|
|
149
174
|
format: schema,
|
|
150
175
|
messages: [{ role: "user", content: pickerPrompt(input) }],
|
|
@@ -158,7 +183,7 @@ export async function pickDocuments(input, options = {}) {
|
|
|
158
183
|
if (!Array.isArray(result.documents) || result.documents.some((x) => typeof x !== "string" || !allowed.has(x)))
|
|
159
184
|
throw new CliError("PICKER_RESPONSE_INVALID", "Local Gather picker returned a document outside the trigger shortlist.", 1);
|
|
160
185
|
const required = input.triggers.filter(forced).map((x) => x.code);
|
|
161
|
-
return { documents: [...new Set([...result.documents, ...required])], model:
|
|
186
|
+
return { documents: [...new Set([...result.documents, ...required])], model: (options.model ?? PINNED).name, context: PICKER_CONTEXT };
|
|
162
187
|
} finally {
|
|
163
188
|
clearTimeout(deadline);
|
|
164
189
|
}
|
package/dist/harness.js
CHANGED
|
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
|
|
|
11
11
|
import { promisify } from "node:util";
|
|
12
12
|
import { CliError, devRequest, query } from "./core.js";
|
|
13
13
|
import { managedHookBundle, nodiiCommandArgs, writeManagedHookBundle } from "./managed-hooks.js";
|
|
14
|
+
import { nudgeSettingsSnippet } from "./nudges.js";
|
|
14
15
|
import { codexSafetyHooksSnippet, regroundingHooksSnippet, safetySettingsSnippet } from "./safety.js";
|
|
15
16
|
|
|
16
17
|
/** What this CLI wrote, per path: { mode, sha256 }, or for merge-json the hook entries it `added`. */
|
|
@@ -198,6 +199,7 @@ export function withSafetyHooks(harness) {
|
|
|
198
199
|
const parsed = JSON.parse(settings.content);
|
|
199
200
|
let hooks = addHookEntries(parsed.hooks, safetySettingsSnippet().hooks);
|
|
200
201
|
hooks = addHookEntries(hooks, regroundingHooksSnippet().hooks);
|
|
202
|
+
hooks = addHookEntries(hooks, nudgeSettingsSnippet().hooks);
|
|
201
203
|
assertNodiiHooks(hooks, ".claude/settings.json");
|
|
202
204
|
settings.content = JSON.stringify({ ...parsed, hooks });
|
|
203
205
|
settings.sha256 = sha(settings.content);
|
package/dist/managed-hooks.js
CHANGED
|
@@ -135,7 +135,12 @@ export async function dispatchManagedHook(event, payloadText, deps = {}) {
|
|
|
135
135
|
}
|
|
136
136
|
const structured = messages.map((message) => message.hookSpecificOutput?.additionalContext).filter(Boolean);
|
|
137
137
|
const plain = successful.map((result) => responseOf(result.stdout) ? "" : result.stdout.trim()).filter(Boolean);
|
|
138
|
-
|
|
138
|
+
// A Stop nudge asks the session to continue once; any other Stop output is dropped.
|
|
139
|
+
if (event === "Stop") {
|
|
140
|
+
const block = messages.find((message) => message.decision === "block" && typeof message.reason === "string");
|
|
141
|
+
return block ? `${JSON.stringify({ decision: "block", reason: block.reason })}\n` : "{}\n";
|
|
142
|
+
}
|
|
143
|
+
if (event === "SubagentStop") return "{}\n";
|
|
139
144
|
return [...structured, ...plain].join("\n") + ([...structured, ...plain].length ? "\n" : "");
|
|
140
145
|
} catch {
|
|
141
146
|
return emptyResponse(event);
|
package/dist/nudges.js
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// Guideline nudge hooks (AGENT-HARNESS-21, agent-harness-design.md "Hooks").
|
|
2
|
+
//
|
|
3
|
+
// A nudge reminds a lane of a rule at the moment it is about to break it, and
|
|
4
|
+
// says nothing when the rule is already met. Nothing here blocks a tool call;
|
|
5
|
+
// the one hard stop in a session (Stop) is asked at most once. Every entry
|
|
6
|
+
// point fails open: an error, an unknown payload or no lane yields no nudge.
|
|
7
|
+
// Each nudge names the clause it protects, so an unused one can be retired.
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { isFigmaTool, isReadTool } from "./hooks.js";
|
|
13
|
+
import { patchPaths } from "./safety.js";
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"]);
|
|
17
|
+
const SHELL_TOOLS = new Set(["Bash", "shell", "exec_command"]);
|
|
18
|
+
export const LANE_KNOWLEDGE = "nodii-lane-knowledge.json";
|
|
19
|
+
export const SUBMIT_CHECKS = "nodii-submit-checks.json";
|
|
20
|
+
export const SUBMIT_CHECK_FLAGS = ["unused-properties", "stale-placeholders", "dialog-body"];
|
|
21
|
+
const OPEN_MD = "docs/figma-ux-drive/qa-with-abhi/open.md";
|
|
22
|
+
const DS_SOURCE = /["'](@cognion-nucleus\/nodii-(?:design-system|blocks)(?:\/[^"']*)?)["']/;
|
|
23
|
+
const MAX_LISTED = 6;
|
|
24
|
+
|
|
25
|
+
// A file a nudge cannot read is the same as one that is not there.
|
|
26
|
+
async function optional(path) {
|
|
27
|
+
try { return await readFile(path, "utf8"); }
|
|
28
|
+
catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function git(cwd, ...args) {
|
|
32
|
+
return (await execFileAsync("git", args, { cwd, encoding: "utf8" })).stdout.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const privatePath = (cwd, name) => git(cwd, "rev-parse", "--path-format=absolute", "--git-path", name);
|
|
36
|
+
|
|
37
|
+
async function readPrivateJson(cwd, name) {
|
|
38
|
+
try {
|
|
39
|
+
const text = await optional(await privatePath(cwd, name));
|
|
40
|
+
return text ? JSON.parse(text) : {};
|
|
41
|
+
} catch { return {}; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function writePrivateJson(cwd, name, update) {
|
|
45
|
+
const path = await privatePath(cwd, name);
|
|
46
|
+
const before = await optional(path).then((text) => (text ? JSON.parse(text) : {})).catch(() => ({}));
|
|
47
|
+
const next = update(before);
|
|
48
|
+
await mkdir(dirname(path), { recursive: true });
|
|
49
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
50
|
+
await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
51
|
+
await rename(temporary, path);
|
|
52
|
+
return next;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What the lane has already read. Gather writes `guidelines` (the lane's
|
|
57
|
+
* gathered set) and `deltaGathered` (paths a Delta gather covered); reading a
|
|
58
|
+
* guideline with `design guideline show` adds to `guidelinesRead`.
|
|
59
|
+
*/
|
|
60
|
+
export const laneKnowledge = (cwd) => readPrivateJson(cwd, LANE_KNOWLEDGE);
|
|
61
|
+
|
|
62
|
+
/** What `lane gather` covered: the components asked for, and on a Delta gather the paths it saw. */
|
|
63
|
+
export const recordGathered = (cwd, { guidelines = [], deltaGathered = [] }) =>
|
|
64
|
+
writePrivateJson(cwd, LANE_KNOWLEDGE, (state) => ({
|
|
65
|
+
...state,
|
|
66
|
+
guidelines: [...new Set([...(state.guidelines ?? []), ...guidelines])],
|
|
67
|
+
deltaGathered: [...new Set([...(state.deltaGathered ?? []), ...deltaGathered])],
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
/** The paths this worktree changes against origin/main, committed or not. */
|
|
71
|
+
export async function changedPaths(cwd) {
|
|
72
|
+
const base = await git(cwd, "merge-base", "origin/main", "HEAD");
|
|
73
|
+
const lines = [await git(cwd, "diff", "--name-only", base), await git(cwd, "ls-files", "--others", "--exclude-standard", "--full-name")];
|
|
74
|
+
return [...new Set(lines.join("\n").split("\n").map((line) => line.trim()).filter(Boolean))].slice(0, 5000);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const recordGuidelineRead = (cwd, key) =>
|
|
78
|
+
writePrivateJson(cwd, LANE_KNOWLEDGE, (state) => ({ ...state, guidelinesRead: [...new Set([...(state.guidelinesRead ?? []), key])] }));
|
|
79
|
+
|
|
80
|
+
/** The checks attested for one Figma change set before its submit. */
|
|
81
|
+
export const recordSubmitChecks = (cwd, changeSet, checks) =>
|
|
82
|
+
writePrivateJson(cwd, SUBMIT_CHECKS, (state) => ({ ...state, [changeSet]: { ...state[changeSet], ...Object.fromEntries(checks.map((check) => [check, new Date().toISOString()])) } }));
|
|
83
|
+
|
|
84
|
+
// ─── reading the edit ──────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
const cleanPath = (path) => String(path).split("\\").join("/").replace(/^\.\//, "");
|
|
87
|
+
|
|
88
|
+
function editedPaths(payload) {
|
|
89
|
+
const input = payload.tool_input ?? {};
|
|
90
|
+
if (payload.tool_name === "apply_patch") return patchPaths(input.command ?? input.input);
|
|
91
|
+
return [input.file_path, input.path, input.notebook_path].filter((x) => typeof x === "string" && x);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const lf = (text) => String(text).replace(/\r\n/g, "\n");
|
|
95
|
+
const replaceOnce = (text, from, to, all = false) => {
|
|
96
|
+
if (!from || !text.includes(from)) return null;
|
|
97
|
+
return all ? text.split(from).join(to) : text.replace(from, () => to);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** One v4A patch section applied to `text`; a hunk that does not apply adds its new lines at the end. */
|
|
101
|
+
function patchedText(text, section) {
|
|
102
|
+
let result = text;
|
|
103
|
+
for (const hunk of section.split(/^@@.*$/m)) {
|
|
104
|
+
const oldLines = [];
|
|
105
|
+
const newLines = [];
|
|
106
|
+
const lines = hunk.split("\n");
|
|
107
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
if (line === "") { oldLines.push(""); newLines.push(""); }
|
|
110
|
+
else if (line.startsWith("+")) newLines.push(line.slice(1));
|
|
111
|
+
else if (line.startsWith("-")) oldLines.push(line.slice(1));
|
|
112
|
+
else if (line.startsWith(" ")) { oldLines.push(line.slice(1)); newLines.push(line.slice(1)); }
|
|
113
|
+
}
|
|
114
|
+
if (!oldLines.length && !newLines.length) continue;
|
|
115
|
+
const next = oldLines.length ? replaceOnce(result, oldLines.join("\n"), newLines.join("\n")) : null;
|
|
116
|
+
result = next ?? `${result}\n${newLines.join("\n")}`;
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Each file the call writes, before and after it, as best the payload shows. */
|
|
122
|
+
async function editedFiles(payload, cwd) {
|
|
123
|
+
const input = payload.tool_input ?? {};
|
|
124
|
+
const absolute = (path) => (isAbsolute(path) ? path : resolve(cwd, path));
|
|
125
|
+
if (payload.tool_name === "apply_patch") {
|
|
126
|
+
const sections = String(input.command ?? input.input ?? "").split(/^\*\*\* (?=(?:Add|Update|Delete) File: )/m).slice(1);
|
|
127
|
+
return Promise.all(sections.map(async (section) => {
|
|
128
|
+
const [head, ...body] = section.split("\n");
|
|
129
|
+
const [, kind, name] = /^(Add|Update|Delete) File: (.+)$/.exec(head) ?? [];
|
|
130
|
+
const from = absolute(name?.trim() ?? "");
|
|
131
|
+
const moved = /^\*\*\* Move to: (.+)$/m.exec(body.join("\n"))?.[1]?.trim();
|
|
132
|
+
const path = moved ? absolute(moved) : from;
|
|
133
|
+
const disk = await optional(from);
|
|
134
|
+
const before = kind === "Add" ? "" : lf(disk ?? "");
|
|
135
|
+
const text = body.join("\n");
|
|
136
|
+
const after = kind === "Add" ? text.split("\n").filter((line) => line.startsWith("+")).map((line) => line.slice(1)).join("\n") : patchedText(before, lf(text));
|
|
137
|
+
return { kind, path, existed: (moved ? await optional(path) : disk) !== null, before, after };
|
|
138
|
+
})).then((files) => files.filter((file) => file.kind !== "Delete"));
|
|
139
|
+
}
|
|
140
|
+
const paths = editedPaths(payload).map(absolute).slice(0, 1);
|
|
141
|
+
return Promise.all(paths.map(async (path) => {
|
|
142
|
+
const disk = await optional(path);
|
|
143
|
+
const before = lf(disk ?? "");
|
|
144
|
+
let after;
|
|
145
|
+
if (typeof input.content === "string") after = lf(input.content);
|
|
146
|
+
else if (typeof input.new_source === "string") after = lf(input.new_source);
|
|
147
|
+
else if (Array.isArray(input.edits)) after = input.edits.reduce((text, edit) => replaceOnce(text, lf(edit?.old_string ?? ""), lf(edit?.new_string ?? ""), edit?.replace_all) ?? `${text}\n${lf(edit?.new_string ?? "")}`, before);
|
|
148
|
+
else after = replaceOnce(before, lf(input.old_string ?? ""), lf(input.new_string ?? ""), input.replace_all) ?? `${before}\n${lf(input.new_string ?? "")}`;
|
|
149
|
+
return { path, existed: disk !== null, before, after };
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const lineCounts = (text) => {
|
|
154
|
+
const counts = new Map();
|
|
155
|
+
for (const line of String(text).split("\n")) counts.set(line.trim(), (counts.get(line.trim()) ?? 0) + 1);
|
|
156
|
+
return counts;
|
|
157
|
+
};
|
|
158
|
+
/** Lines of `after` that `before` does not have. */
|
|
159
|
+
function changedLines(before, after) {
|
|
160
|
+
const had = lineCounts(before);
|
|
161
|
+
return String(after).split("\n").filter((line) => {
|
|
162
|
+
const key = line.trim();
|
|
163
|
+
if (!key) return false;
|
|
164
|
+
if (had.get(key) > 0) { had.set(key, had.get(key) - 1); return false; }
|
|
165
|
+
return true;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const kebab = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
|
|
170
|
+
|
|
171
|
+
/** Local name → DS export, for PascalCase components imported from the DS or its blocks. */
|
|
172
|
+
export function dsImports(text) {
|
|
173
|
+
const out = new Map();
|
|
174
|
+
for (const match of String(text).matchAll(/import\s+(?!type\b)\{([^}]*)\}\s+from\s+(["'][^"']+["'])/g)) {
|
|
175
|
+
if (!DS_SOURCE.test(match[2])) continue;
|
|
176
|
+
for (const part of match[1].split(",")) {
|
|
177
|
+
const spec = part.trim();
|
|
178
|
+
if (!spec || /^type\s/.test(spec)) continue;
|
|
179
|
+
const [imported, local = imported] = spec.split(/\s+as\s+/);
|
|
180
|
+
if (/^[A-Z]/.test(imported) && !/(?:Props|Variants?|Context)$/.test(imported)) out.set(local.trim(), imported.trim());
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Every `<Tag …>` opening tag: its name, its source (`raw`), and the attribute
|
|
188
|
+
* text outside `{…}` and quotes. An expression is skipped by brace depth alone,
|
|
189
|
+
* so an apostrophe or a regex inside one cannot swallow the rest of the file.
|
|
190
|
+
*/
|
|
191
|
+
export function jsxOpenTags(text) {
|
|
192
|
+
const tags = [];
|
|
193
|
+
const source = String(text);
|
|
194
|
+
for (const match of source.matchAll(/<([A-Z][\w.]*)(?=[\s/>])/g)) {
|
|
195
|
+
let depth = 0;
|
|
196
|
+
let quote = null;
|
|
197
|
+
let attrs = "";
|
|
198
|
+
let end = source.length;
|
|
199
|
+
for (let at = match.index + match[0].length; at < source.length; at += 1) {
|
|
200
|
+
const char = source[at];
|
|
201
|
+
if (quote) { attrs += char === quote ? char : " "; if (char === quote) quote = null; continue; }
|
|
202
|
+
if (char === "{") { depth += 1; attrs += " "; continue; }
|
|
203
|
+
if (char === "}") { depth = Math.max(0, depth - 1); continue; }
|
|
204
|
+
if (depth > 0) continue;
|
|
205
|
+
if (char === '"' || char === "'") { quote = char; attrs += char; continue; }
|
|
206
|
+
if (char === ">") { end = at; break; }
|
|
207
|
+
attrs += char;
|
|
208
|
+
}
|
|
209
|
+
tags.push({ tag: match[1], attrs, raw: source.slice(match.index, end + 1) });
|
|
210
|
+
}
|
|
211
|
+
return tags;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Opening tags in `after` that `before` does not already have verbatim. */
|
|
215
|
+
function changedTags(before, after) {
|
|
216
|
+
const had = new Map();
|
|
217
|
+
for (const { raw } of jsxOpenTags(before)) had.set(raw, (had.get(raw) ?? 0) + 1);
|
|
218
|
+
return jsxOpenTags(after).filter(({ raw }) => {
|
|
219
|
+
if (had.get(raw) > 0) { had.set(raw, had.get(raw) - 1); return false; }
|
|
220
|
+
return true;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const withoutTags = (text) => jsxOpenTags(text).reduce((out, { raw }) => out.replace(raw, " "), String(text));
|
|
225
|
+
const uiFile = (path) => /\.(?:tsx|jsx)$/.test(path) && !/\.(?:test|spec|stories)\.[jt]sx?$/.test(path);
|
|
226
|
+
|
|
227
|
+
// ─── the nudges ────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
// A part's guideline is its root's: `dialog` covers `dialog-content`, but `alert` does not cover `alert-dialog`.
|
|
230
|
+
const PARTS = /^(?:content|header|footer|title|description|trigger|item|items|list|group|label|separator|body|row|cell|head|caption|action|cancel|close|overlay|portal|value|indicator|viewport|sub|text|root)$/;
|
|
231
|
+
const covers = (known, key) => known.some((entry) => key === entry || (key.startsWith(`${entry}-`) && key.slice(entry.length + 1).split("-").every((part) => PARTS.test(part))));
|
|
232
|
+
const listed = (items) => items.slice(0, MAX_LISTED).join(", ") + (items.length > MAX_LISTED ? `, +${items.length - MAX_LISTED} more` : "");
|
|
233
|
+
const PAGE_NOISE = /^(?:index|types?|utils?|constants?|use-.*|.*-(?:types?|utils?|constants?|mocks?|fixtures?|helpers?|hooks?))$/;
|
|
234
|
+
|
|
235
|
+
/** DS component or product page used or edited without its guideline in the lane's gathered set. */
|
|
236
|
+
export function guidelineNudges({ rel, before, after }, knowledge) {
|
|
237
|
+
const known = [...(knowledge.guidelines ?? []), ...(knowledge.guidelinesRead ?? [])].map(kebab);
|
|
238
|
+
const keys = new Set();
|
|
239
|
+
const component = /(?:^|\/)packages\/design-system\/src\/components\/([a-z0-9-]+)\.tsx$/.exec(rel);
|
|
240
|
+
if (component) keys.add(component[1]);
|
|
241
|
+
const page = /(?:^|\/)product-pages\/(?:.*\/)?([a-z0-9-]+?)(?:\.figma)?\.(?:tsx|ts)$/.exec(rel);
|
|
242
|
+
if (page && page[1].includes("-") && !PAGE_NOISE.test(page[1])) keys.add(page[1]);
|
|
243
|
+
if (uiFile(rel)) {
|
|
244
|
+
const imports = dsImports(after);
|
|
245
|
+
for (const { tag } of changedTags(before, after)) {
|
|
246
|
+
const root = tag.split(".")[0];
|
|
247
|
+
if (imports.has(root)) keys.add(kebab(imports.get(root)));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const missing = [...keys].filter((key) => !covers(known, key));
|
|
251
|
+
if (!missing.length) return [];
|
|
252
|
+
return [`nodii-dev nudge: no guideline for ${listed(missing)} in this lane's gathered set. Read it before using or editing it: ${missing.slice(0, 3).map((key) => `\`nodii-dev design guideline show ${key}\``).join(", ")} (U07-19, DEC-0010).`];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** className or style passed to a DS component (U07-19). */
|
|
256
|
+
export function restyleNudges({ rel, before, after }) {
|
|
257
|
+
if (!uiFile(rel)) return [];
|
|
258
|
+
const imports = dsImports(after);
|
|
259
|
+
const hits = new Set();
|
|
260
|
+
for (const { tag, attrs } of changedTags(before, after)) {
|
|
261
|
+
if (!imports.has(tag.split(".")[0])) continue;
|
|
262
|
+
for (const prop of ["className", "style"]) if (new RegExp(`(?:^|\\s)${prop}\\s*=`).test(attrs)) hits.add(`<${tag} ${prop}>`);
|
|
263
|
+
}
|
|
264
|
+
if (!hits.size) return [];
|
|
265
|
+
return [`nodii-dev nudge: ${listed([...hits])}: a consumer MUST NOT pass className or style to a DS component (U07-19). Set only props, variants, slots and children; a missing capability is a DS gap (U07-20), filed as a DS issue.`];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const PERSON_FIELD = /(?<!\$)\{\s*[\w$.?]+\??\.(?:email|phone|mobile|displayName|fullName|firstName|lastName|personName)\s*\}/;
|
|
269
|
+
const MASKING = /\bmask(?:ed)?(?:Name|Email|Phone|Pii|Person)?\s*\(|\b[A-Za-z]\*{3,}/;
|
|
270
|
+
|
|
271
|
+
/** Code that renders a person: through PiiText, never masking the viewer (R5, U05-25). */
|
|
272
|
+
export function selfPiiNudges({ rel, before, after }) {
|
|
273
|
+
if (!uiFile(rel)) return [];
|
|
274
|
+
const notes = [];
|
|
275
|
+
for (const { tag, raw } of changedTags(before, after))
|
|
276
|
+
if (tag === "PiiText" && /\bkind\s*=\s*\{?["']?(?:name|email|phone)/.test(raw) && !/\bself\b/.test(raw))
|
|
277
|
+
notes.push("PiiText shows a person without `self`: if this can be the viewer pass `self={isSelf}`");
|
|
278
|
+
if (PERSON_FIELD.test(changedLines(withoutTags(before), withoutTags(after)).join("\n"))) notes.push("a person's name, email or phone is rendered without PiiText");
|
|
279
|
+
if (MASKING.test(changedLines(before, after).join("\n"))) notes.push("the page masks a value itself (a page never masks or reveals; PiiText does)");
|
|
280
|
+
if (!notes.length) return [];
|
|
281
|
+
return [`nodii-dev nudge: ${notes.join("; ")}. Ask: is this the viewer? Self is never masked (R5, D967, U05-25); every other person goes through PiiText.`];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** `when` values of a Drizzle journal, in file order. */
|
|
285
|
+
const journalWhens = (text) => (JSON.parse(text).entries ?? []).map((entry) => entry.when);
|
|
286
|
+
|
|
287
|
+
/** A new migration, or a journal edit that breaks the `when` rule. */
|
|
288
|
+
export async function migrationNudges({ path, rel, existed, after }, knowledge) {
|
|
289
|
+
const notes = [];
|
|
290
|
+
if (/(?:^|\/)(?:migrations|drizzle)\/(?:.*\/)?[^/]+\.sql$/.test(rel) && !existed) {
|
|
291
|
+
const covered = (knowledge.deltaGathered ?? []).map((item) => cleanPath(item).replace(/\/$/, ""));
|
|
292
|
+
if (!covered.some((item) => rel === item || rel.startsWith(`${item}/`))) {
|
|
293
|
+
let newest = "";
|
|
294
|
+
try { const whens = journalWhens(await optional(resolve(dirname(path), "meta/_journal.json")) ?? "{}"); if (whens.length) newest = ` (the journal's newest is ${Math.max(...whens)}, so the next \`when\` is ${Math.max(...whens) + 1})`; } catch {}
|
|
295
|
+
notes.push(`${rel} is a new migration. Its journal entry needs when = max(existing when) + 1${newest} with idx and tag strictly increasing (never drizzle-kit's now()); then run \`nodii-dev lane gather --delta\` (scope drift)`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (/(?:^|\/)meta\/_journal\.json$/.test(rel)) {
|
|
299
|
+
try {
|
|
300
|
+
const whens = journalWhens(after);
|
|
301
|
+
const bad = whens.findIndex((when, at) => at > 0 && !(when > whens[at - 1]));
|
|
302
|
+
if (bad !== -1) notes.push(`${rel}: journal entry ${bad} has when ${whens[bad]}, not above the one before (${whens[bad - 1]}); Drizzle silently skips a migration whose when is not newer`);
|
|
303
|
+
} catch {}
|
|
304
|
+
}
|
|
305
|
+
return notes.map((note) => `nodii-dev nudge: ${note}.`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const NODE_ID_TOOLS = /(?:get_design_context|get_metadata|get_screenshot|get_variable_defs|get_motion_context)$/;
|
|
309
|
+
|
|
310
|
+
/** A code lane reading Figma by node id (MW8-20). */
|
|
311
|
+
export function figmaReadNudges(payload, lane) {
|
|
312
|
+
const name = payload.tool_name ?? "";
|
|
313
|
+
if (!isFigmaTool(name) || !isReadTool(name) || !NODE_ID_TOOLS.test(name)) return [];
|
|
314
|
+
if (/figma|design/i.test(`${lane.kind ?? ""} ${lane.side ?? ""}`)) return [];
|
|
315
|
+
const input = payload.tool_input ?? {};
|
|
316
|
+
const byUrl = typeof input.url === "string" && /[?&]node-id=/.test(input.url);
|
|
317
|
+
if (!(typeof input.nodeId === "string" || typeof input.node_id === "string" || byUrl)) return [];
|
|
318
|
+
return ["nodii-dev nudge: this code lane reads Figma by node id. Find the surface by its published component-set key (`figma:keys:refresh`), never a node id; a node id is not a stable reference across hops (MW8-20, MW8-17)."];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const SUBMIT_COMMAND = /(?:nodii-dev|@nodii\/dev(?:@\S+)?)\s+figma\s+submit\b([^;&|\n]*)/;
|
|
322
|
+
const CHECKED_COMMAND = /(?:nodii-dev|@nodii\/dev(?:@\S+)?)\s+figma\s+checked\s+(CS-[\w-]+)([^;&|\n]*)/g;
|
|
323
|
+
|
|
324
|
+
/** `nodii-dev figma submit CS-n`: the hand checks Dev's gate cannot see. */
|
|
325
|
+
export async function submitNudges(command, { cwd }) {
|
|
326
|
+
const text = String(command ?? "");
|
|
327
|
+
const submit = SUBMIT_COMMAND.exec(text);
|
|
328
|
+
const changeSet = /\b(CS-[\w-]+)/.exec(submit?.[1] ?? "")?.[1];
|
|
329
|
+
if (!changeSet) return [];
|
|
330
|
+
const done = { ...(await readPrivateJson(cwd, SUBMIT_CHECKS))[changeSet] };
|
|
331
|
+
// The same command line may record them first: `figma checked CS-n … && figma submit CS-n …`.
|
|
332
|
+
for (const [, id, flags] of text.matchAll(CHECKED_COMMAND)) if (id === changeSet) for (const check of SUBMIT_CHECK_FLAGS) if (flags.includes(`--${check}`)) done[check] = true;
|
|
333
|
+
const label = { "unused-properties": "every component property is used (F-34)", "stale-placeholders": 'every "Blocked on DS: <part>" placeholder is re-checked against the published library (CS-2453)', "dialog-body": "a Dialog with no body is an AlertDialog" };
|
|
334
|
+
const missing = SUBMIT_CHECK_FLAGS.filter((check) => !done[check]);
|
|
335
|
+
if (!missing.length) return [];
|
|
336
|
+
return [`nodii-dev nudge: before submitting ${changeSet}, check that ${missing.map((check) => label[check]).join("; ")}. Then record it: \`nodii-dev figma checked ${changeSet} ${missing.map((check) => `--${check}`).join(" ")}\` (DEC-0034 hand check).`];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** AskUserQuestion: the ask also lives as an item in open.md (R7). */
|
|
340
|
+
export async function askNudges(payload, { root }) {
|
|
341
|
+
if (payload.tool_name !== "AskUserQuestion") return [];
|
|
342
|
+
const questions = (payload.tool_input?.questions ?? []).map((item) => String(item?.question ?? "")).filter(Boolean);
|
|
343
|
+
const openMd = (await optional(resolve(root, OPEN_MD)))?.toLowerCase().replace(/\s+/g, " ");
|
|
344
|
+
const unrecorded = questions.filter((question) => !openMd?.includes(question.toLowerCase().replace(/\s+/g, " ").slice(0, 60)));
|
|
345
|
+
if (questions.length && !unrecorded.length) return [];
|
|
346
|
+
return [`nodii-dev nudge: a question for the operator is also an item in ${OPEN_MD} (R7), with its Q section; chat alone is not where an ask lives. Add it, and say "item #n in open.md" here.`];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const SIGNALS = /@cognion-nucleus|PiiText|\bmask\w*\(|\b[A-Za-z]\*{3,}|\.(?:email|phone|mobile|displayName|fullName|firstName|lastName|personName)\b/;
|
|
350
|
+
|
|
351
|
+
/** Whether any nudge could apply to this call, so most calls never resolve the lane. */
|
|
352
|
+
export async function nudgeCandidate(payload) {
|
|
353
|
+
const tool = payload.tool_name ?? "";
|
|
354
|
+
if (isFigmaTool(tool)) return isReadTool(tool) && NODE_ID_TOOLS.test(tool);
|
|
355
|
+
if (tool === "AskUserQuestion") return true;
|
|
356
|
+
if (SHELL_TOOLS.has(tool)) return /\bfigma\s+submit\b/.test(String(payload.tool_input?.command ?? ""));
|
|
357
|
+
if (!EDIT_TOOLS.has(tool)) return false;
|
|
358
|
+
const cwd = payload.cwd ?? process.cwd();
|
|
359
|
+
return (await editedFiles(payload, cwd)).some(({ path, before, after }) => {
|
|
360
|
+
const p = cleanPath(path);
|
|
361
|
+
if (/\.sql$|_journal\.json$|product-pages\/|design-system\/src\/components\//.test(p)) return true;
|
|
362
|
+
if (!uiFile(p)) return false;
|
|
363
|
+
return SIGNALS.test(changedLines(before, after).join("\n")) || (changedTags(before, after).length > 0 && SIGNALS.test(after));
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** PreToolUse nudges for one tool call. Returns [] outside a lane. */
|
|
368
|
+
export async function nudgePre(payload, context, { knowledge = laneKnowledge } = {}) {
|
|
369
|
+
const lane = context?.lane;
|
|
370
|
+
if (!lane) return [];
|
|
371
|
+
const tool = payload.tool_name ?? "";
|
|
372
|
+
const root = context.root;
|
|
373
|
+
const cwd = payload.cwd ?? context.cwd ?? root;
|
|
374
|
+
if (isFigmaTool(tool)) return figmaReadNudges(payload, lane);
|
|
375
|
+
if (tool === "AskUserQuestion") return askNudges(payload, { root });
|
|
376
|
+
if (SHELL_TOOLS.has(tool)) return submitNudges(payload.tool_input?.command, { cwd });
|
|
377
|
+
if (!EDIT_TOOLS.has(tool)) return [];
|
|
378
|
+
const known = await knowledge(cwd);
|
|
379
|
+
const out = [];
|
|
380
|
+
for (const file of await editedFiles(payload, cwd)) {
|
|
381
|
+
const edit = { ...file, rel: cleanPath(relative(root, file.path)) };
|
|
382
|
+
out.push(...guidelineNudges(edit, known), ...restyleNudges(edit), ...selfPiiNudges(edit), ...(await migrationNudges(edit, known)));
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─── Stop ──────────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
// A claim that the work is finished, judged a sentence at a time so "the docs are not done" cannot hide "it is done".
|
|
390
|
+
const DONE_CLAIM = /\b(?:all|everything|work|lane|task|ticket|issue|build|it|this|that|i'm|we're|i am|we are)\s*(?:is|are|'s|'m|'re)?\s*(?:now\s+)?(?:done|complete[d]?|finished)\b|^\W*(?:done|all done|complete)\b/i;
|
|
391
|
+
const SHOUTED_DONE = /\bDONE\b/;
|
|
392
|
+
const NOT_DONE = /\b(?:not|isn't|aren't|wasn't|never|until|nothing is|n't)\s+(?:yet\s+)?(?:done|complete[d]?|finished)\b|\b(?:unfinished|incomplete)\b/i;
|
|
393
|
+
const claimsDone = (text) => String(text).split(/(?<=[.!?\n])\s+/).some((sentence) => (DONE_CLAIM.test(sentence) || SHOUTED_DONE.test(sentence)) && !NOT_DONE.test(sentence));
|
|
394
|
+
const COULDNT = /\b(?:I|we)\s+(?:couldn't|could not|can't|cannot|was unable to|were unable to|wasn't able to|am unable to)\b/i;
|
|
395
|
+
const FILED = /\b[A-Z][A-Z0-9]+(?:-[A-Z0-9]+)*-\d+\b|\bfiled\b|\bissue\s+#?\d+|\bopen\.md\b/;
|
|
396
|
+
|
|
397
|
+
/** The last assistant message: the Stop payload's own, else the transcript's final one. */
|
|
398
|
+
export async function lastAssistantText(payload) {
|
|
399
|
+
if (typeof payload.last_assistant_message === "string") return payload.last_assistant_message;
|
|
400
|
+
if (!payload.transcript_path) return "";
|
|
401
|
+
try {
|
|
402
|
+
const lines = String(await optional(payload.transcript_path) ?? "").trim().split("\n");
|
|
403
|
+
for (const line of lines.reverse()) {
|
|
404
|
+
const entry = JSON.parse(line);
|
|
405
|
+
if ((entry.type ?? entry.message?.role) !== "assistant") continue;
|
|
406
|
+
const content = entry.message?.content;
|
|
407
|
+
const text = Array.isArray(content) ? content.filter((part) => part?.type === "text").map((part) => part.text).join("\n") : String(content ?? "");
|
|
408
|
+
if (text.trim()) return text;
|
|
409
|
+
}
|
|
410
|
+
} catch {}
|
|
411
|
+
return "";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const openUnconfirmed = (lane) => [lane.freeze?.unconfirmed, lane.unconfirmed].flat().filter(Boolean).filter((item) =>
|
|
415
|
+
typeof item === "string" || !(item?.resolvedAt || item?.confirmedAt || ["resolved", "confirmed", "dropped"].includes(item?.status)));
|
|
416
|
+
|
|
417
|
+
/** Stop nudges. A lane that has just been nudged once is left to stop (stop_hook_active). */
|
|
418
|
+
export async function nudgeStop(payload, context, { lastText = lastAssistantText, status = (root) => git(root, "status", "--porcelain") } = {}) {
|
|
419
|
+
const lane = context?.lane;
|
|
420
|
+
if (!lane || payload.stop_hook_active) return [];
|
|
421
|
+
const notes = [];
|
|
422
|
+
const text = await lastText(payload);
|
|
423
|
+
if (claimsDone(text) && lane.status !== "on_dev")
|
|
424
|
+
notes.push(`${lane.key} reads ${lane.status}, not on_dev: done means merged, deployed and verified live on dev (MW3). Run \`nodii-dev lane show ${lane.key}\`, and say what is left instead of "done"`);
|
|
425
|
+
if (COULDNT.test(text) && !FILED.test(text))
|
|
426
|
+
notes.push('you said you couldn\'t do something and cited no filed issue; file it (`nodii-dev issues create`, or a request or drift entry) so it is not lost');
|
|
427
|
+
const unconfirmed = openUnconfirmed(lane);
|
|
428
|
+
if (unconfirmed.length)
|
|
429
|
+
notes.push(`${unconfirmed.length} Unconfirmed item${unconfirmed.length === 1 ? " is" : "s are"} still open in ${lane.key}'s Freeze; confirm or drop each with a reason (\`nodii-dev lane freeze\`)`);
|
|
430
|
+
const dirty = (await status(context.root).catch(() => "")).split("\n").filter(Boolean);
|
|
431
|
+
if (dirty.length)
|
|
432
|
+
notes.push(`${dirty.length} uncommitted path${dirty.length === 1 ? "" : "s"} in ${context.root}: commit in your worktree (push on commit, so a death loses nothing)`);
|
|
433
|
+
return notes.map((note) => `nodii-dev nudge: ${note}.`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export const nudgeSettingsSnippet = () => ({
|
|
437
|
+
hooks: {
|
|
438
|
+
PreToolUse: [{ matcher: "^(AskUserQuestion|mcp__(?:figma|claude_ai_Figma|figma-dev-mode)__.*|Bash|Edit|Write|MultiEdit|NotebookEdit)$", hooks: [{ type: "command", command: "nodii-dev hook nudge-pre" }] }],
|
|
439
|
+
Stop: [{ hooks: [{ type: "command", command: "nodii-dev hook nudge-stop" }] }],
|
|
440
|
+
},
|
|
441
|
+
});
|
package/dist/tune.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { CliError, devRequest, encode, query } from "./core.js";
|
|
2
|
+
import { GATHER_TIMEOUT_MS, TUNING_MODELS, gatherText, pickDocuments, warmPicker } from "./gather.js";
|
|
3
|
+
|
|
4
|
+
// Dev's tuning matrix (AH-11): every case, every picker, five repeats. Finalize
|
|
5
|
+
// refuses anything short of the full matrix, so a picker that cannot run here is
|
|
6
|
+
// reported unavailable and sends nothing — a missing trial is never faked.
|
|
7
|
+
export const TUNING_PICKERS = ["shortlist-only", "lane-agent", "gemma-e4b", "gemma-12b", "gemma-26b"];
|
|
8
|
+
export const TUNING_REPEATS = 5;
|
|
9
|
+
const AUTOMATIC = TUNING_PICKERS.filter((picker) => picker !== "lane-agent");
|
|
10
|
+
const TRIGGERS = ["setup", "trigger_change", "document_change", "missed_trigger", "manual"];
|
|
11
|
+
const SECTION_LIMIT = 3;
|
|
12
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
|
+
|
|
14
|
+
const list = (value) => String(value ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
15
|
+
const bps = (value) => `${(value / 100).toFixed(1)}%`;
|
|
16
|
+
// Dev's rule-report token estimate: ceil(chars / 4).
|
|
17
|
+
const estimateTokens = (text) => Math.ceil(text.length / 4);
|
|
18
|
+
// Dev's clause gist: the first sentence, at most 160 characters.
|
|
19
|
+
const clauseGist = (text) => {
|
|
20
|
+
const sentence = /^(.+?[.!?])(?:\s|$)/.exec(text)?.[1] ?? text;
|
|
21
|
+
return sentence.length > 160 ? `${sentence.slice(0, 157)}...` : sentence;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function uuid(value, what) {
|
|
25
|
+
if (typeof value !== "string" || !UUID.test(value)) throw new CliError("USAGE", `gather tune needs ${what} as a UUID`, 2);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pickerFlag(value) {
|
|
30
|
+
if (!TUNING_PICKERS.includes(value)) throw new CliError("USAGE", `--picker must be one of ${TUNING_PICKERS.join(", ")}`, 2);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function iterationFlag(value) {
|
|
35
|
+
const n = Number(value);
|
|
36
|
+
if (!Number.isInteger(n) || n < 1 || n > TUNING_REPEATS) throw new CliError("USAGE", `--iteration needs a whole number from 1 to ${TUNING_REPEATS}`, 2);
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Paths a frozen diff touches, from its `diff --git a/X b/Y` headers (both sides of a rename). */
|
|
41
|
+
export function diffPaths(changes = []) {
|
|
42
|
+
const paths = new Set();
|
|
43
|
+
for (const change of changes)
|
|
44
|
+
for (const match of String(change.diff ?? "").matchAll(/^diff --git a\/(\S+) b\/(\S+)$/gm)) {
|
|
45
|
+
paths.add(match[1]);
|
|
46
|
+
paths.add(match[2]);
|
|
47
|
+
}
|
|
48
|
+
return [...paths].filter((path) => path.length <= 1000).slice(0, 5000);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What a picker sees: the case's objective, touched files and repos (no answer key). */
|
|
52
|
+
export function replayInput(replay) {
|
|
53
|
+
const objective = [replay.objective?.title, replay.objective?.body].filter((x) => typeof x === "string" && x.trim()).join("\n\n").slice(0, 20_000);
|
|
54
|
+
if (!objective.trim()) throw new CliError("TUNING_CASE_INVALID", `tuning case ${replay.id} has no objective`, 1);
|
|
55
|
+
return { objective, paths: diffPaths(replay.changes), repos: replay.repos ?? [] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The read-only corpus a trial assembles its Clause map from: documents with
|
|
60
|
+
* their current clauses, service feature documents per repo, and Lane bases.
|
|
61
|
+
* Cached for one command, because the corpus does not change mid-run.
|
|
62
|
+
*/
|
|
63
|
+
export function tuningCorpus(request) {
|
|
64
|
+
const documents = new Map();
|
|
65
|
+
const services = new Map();
|
|
66
|
+
let bases;
|
|
67
|
+
let all;
|
|
68
|
+
const document = (code) => {
|
|
69
|
+
if (!documents.has(code)) documents.set(code, request("GET", `/api/v1/documents/${encode(code)}`).then((x) => x.data));
|
|
70
|
+
return documents.get(code);
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
document,
|
|
74
|
+
async serviceDocuments(repo) {
|
|
75
|
+
if (!services.has(repo))
|
|
76
|
+
services.set(repo, request("GET", `/api/v1/documents${query({ section: "services", serviceId: repo, limit: 500 })}`).then((x) => {
|
|
77
|
+
if ((x.total ?? 0) > 500) throw new CliError("TUNING_CORPUS_TOO_LARGE", `${repo} has ${x.total} feature documents; tuning reads at most 500`, 1);
|
|
78
|
+
return (x.data ?? []).map((d) => d.code);
|
|
79
|
+
}));
|
|
80
|
+
return services.get(repo);
|
|
81
|
+
},
|
|
82
|
+
async laneBase(buildType, side) {
|
|
83
|
+
bases ??= request("GET", "/api/v1/rules/lane-bases").then((x) => x.data ?? []);
|
|
84
|
+
const sides = side === "both" ? ["backend", "frontend"] : [side];
|
|
85
|
+
return new Set((await bases).filter((base) => base.buildType === buildType && sides.includes(base.side)).flatMap((base) => base.clauseIds));
|
|
86
|
+
},
|
|
87
|
+
async allCodes() {
|
|
88
|
+
// Lane Gather scans every document for Lane base clauses; page through all of them.
|
|
89
|
+
all ??= (async () => {
|
|
90
|
+
const codes = [];
|
|
91
|
+
for (let offset = 0; ; offset += 500) {
|
|
92
|
+
const page = await request("GET", `/api/v1/documents${query({ limit: 500, offset })}`);
|
|
93
|
+
codes.push(...(page.data ?? []).map((d) => d.code));
|
|
94
|
+
if (!page.data?.length || codes.length >= (page.total ?? codes.length)) return codes;
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
return all;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* One trial, assembled the way Dev's lane Gather assembles a Clause map
|
|
104
|
+
* (picked documents + the lane repos' feature documents + Lane base, then a
|
|
105
|
+
* hybrid section search with three sections per document), but read-only:
|
|
106
|
+
* nothing is recorded against a lane.
|
|
107
|
+
*/
|
|
108
|
+
export async function runTrial(replay, picker, { request = devRequest, corpus = tuningCorpus(request), pick = pickDocuments, documents: agentDocuments, timeoutMs = GATHER_TIMEOUT_MS } = {}) {
|
|
109
|
+
const { objective, paths, repos } = replayInput(replay);
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const deadline = setTimeout(() => controller.abort(), timeoutMs);
|
|
112
|
+
const { signal } = controller;
|
|
113
|
+
// Corpus reads are cached across trials, so they are raced against this
|
|
114
|
+
// trial's deadline rather than aborted.
|
|
115
|
+
const within = (promise) => new Promise((resolvePromise, rejectPromise) => {
|
|
116
|
+
const abort = () => rejectPromise(new Error("aborted"));
|
|
117
|
+
if (signal.aborted) return abort();
|
|
118
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
119
|
+
promise.then(resolvePromise, rejectPromise).finally(() => signal.removeEventListener("abort", abort));
|
|
120
|
+
});
|
|
121
|
+
try {
|
|
122
|
+
const shortlist = await request("POST", "/api/v1/rules/shortlist", { objective, paths, repos, components: [] }, { signal });
|
|
123
|
+
const triggers = shortlist.data?.triggers ?? [];
|
|
124
|
+
const codes = triggers.map((trigger) => trigger.code);
|
|
125
|
+
const forced = triggers.filter((trigger) => trigger.forcedBy?.length).map((trigger) => trigger.code);
|
|
126
|
+
let picked;
|
|
127
|
+
if (picker === "shortlist-only") picked = codes;
|
|
128
|
+
else if (picker === "lane-agent") {
|
|
129
|
+
if (!agentDocuments?.length) throw new CliError("USAGE", "the lane-agent picker needs --documents CODE[,CODE]: the documents the agent picked from the replay's shortlist", 2);
|
|
130
|
+
const outside = agentDocuments.filter((code) => !codes.includes(code));
|
|
131
|
+
if (outside.length) throw new CliError("TUNING_PICK_INVALID", `lane-agent picked documents outside the shortlist: ${outside.join(", ")}`, 2);
|
|
132
|
+
picked = [...new Set([...agentDocuments, ...forced])];
|
|
133
|
+
} else {
|
|
134
|
+
if (!triggers.length) throw new CliError("TUNING_SHORTLIST_EMPTY", `the trigger shortlist for case ${replay.id} is empty`, 1);
|
|
135
|
+
const result = await pick({
|
|
136
|
+
objective,
|
|
137
|
+
files: paths,
|
|
138
|
+
triggers: triggers.map((trigger) => ({ code: trigger.code, trigger: trigger.sentence, ...(trigger.forcedBy ? { forcedBy: trigger.forcedBy } : {}) })),
|
|
139
|
+
}, { model: TUNING_MODELS[picker], signal });
|
|
140
|
+
picked = result.documents;
|
|
141
|
+
}
|
|
142
|
+
// An empty pick is recorded as the picker's (zero-recall) result rather than
|
|
143
|
+
// refused as lane Gather does: the matrix needs every trial, and a picker
|
|
144
|
+
// that picks nothing is exactly what tuning has to see.
|
|
145
|
+
const service = (await within(Promise.all(repos.map((repo) => corpus.serviceDocuments(repo))))).flat();
|
|
146
|
+
const baseIds = await within(corpus.laneBase(replay.buildType, replay.side));
|
|
147
|
+
let documentCodes = [...new Set([...picked, ...service])];
|
|
148
|
+
if (baseIds.size) {
|
|
149
|
+
const withBase = await within(corpus.allCodes().then((codes) => Promise.all(codes.filter((code) => !documentCodes.includes(code)).map(async (code) => ({ code, doc: await corpus.document(code) })))));
|
|
150
|
+
documentCodes = [...documentCodes, ...withBase.filter(({ doc }) => doc.clauses?.some((clause) => baseIds.has(clause.id))).map(({ code }) => code)];
|
|
151
|
+
}
|
|
152
|
+
const sections = [];
|
|
153
|
+
if (documentCodes.length) {
|
|
154
|
+
const ranked = await request("POST", "/api/v1/rules/search", {
|
|
155
|
+
query: [objective, ...paths].join("\n").slice(0, 20_000),
|
|
156
|
+
documentCodes: documentCodes.slice(0, 500),
|
|
157
|
+
limit: Math.min(500, documentCodes.length * Math.max(20, SECTION_LIMIT)),
|
|
158
|
+
}, { signal });
|
|
159
|
+
const seen = new Map();
|
|
160
|
+
for (const section of ranked.data?.sections ?? []) {
|
|
161
|
+
const count = seen.get(section.documentCode) ?? 0;
|
|
162
|
+
if (count >= SECTION_LIMIT) continue;
|
|
163
|
+
seen.set(section.documentCode, count + 1);
|
|
164
|
+
sections.push(section);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const sectionOf = new Map(sections.flatMap((section) => section.clauseIds.map((id) => [`${section.documentCode}\u0000${id}`, section])));
|
|
168
|
+
const clauses = [];
|
|
169
|
+
const fetched = await within(Promise.all(documentCodes.map((code) => corpus.document(code))));
|
|
170
|
+
for (const [at, code] of documentCodes.entries()) {
|
|
171
|
+
const doc = fetched[at];
|
|
172
|
+
for (const clause of doc.clauses ?? []) {
|
|
173
|
+
const section = sectionOf.get(`${code}\u0000${clause.id}`);
|
|
174
|
+
if (!section && !baseIds.has(clause.id)) continue;
|
|
175
|
+
// A Lane base clause outside the ranked sections is shown under its own
|
|
176
|
+
// section heading; Dev's map uses the indexed breadcrumb, which only
|
|
177
|
+
// shifts the map's size estimate slightly.
|
|
178
|
+
clauses.push({
|
|
179
|
+
documentCode: code,
|
|
180
|
+
documentVersion: doc.currentVersion,
|
|
181
|
+
sectionId: section?.sectionId ?? `${code}:${clause.section}`,
|
|
182
|
+
breadcrumb: section?.breadcrumb ?? clause.section,
|
|
183
|
+
clauseId: clause.id,
|
|
184
|
+
gist: clauseGist(clause.text),
|
|
185
|
+
counterInstinct: clause.counterInstinct ?? false,
|
|
186
|
+
repos,
|
|
187
|
+
steps: clause.appliesTo?.step ?? ["*"],
|
|
188
|
+
laneBase: baseIds.has(clause.id),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
clauses.sort((a, b) => a.documentCode.localeCompare(b.documentCode) || a.breadcrumb.localeCompare(b.breadcrumb) || a.clauseId.localeCompare(b.clauseId));
|
|
193
|
+
// The same Lane contract and notices Dev puts on a lane's map, so the size is the map a lane gets.
|
|
194
|
+
const laneContract = [
|
|
195
|
+
`Lane ${replay.issueKey} · ${replay.buildType}/${replay.side}`,
|
|
196
|
+
`Objective: ${objective}`,
|
|
197
|
+
`Repositories: ${repos.join(", ") || "none"}`,
|
|
198
|
+
`Paths: ${paths.join(", ") || "not supplied"}`,
|
|
199
|
+
"Gather: full",
|
|
200
|
+
].join("\n");
|
|
201
|
+
const notices = [...(shortlist.data?.notice ? [shortlist.data.notice] : []), 'Not sure something applies? nodii-dev rules find "<question>"'];
|
|
202
|
+
const map = gatherText({ laneContract, notices, clauses }, objective);
|
|
203
|
+
return {
|
|
204
|
+
caseId: replay.id,
|
|
205
|
+
picker,
|
|
206
|
+
selectedClauseIds: [...new Set(clauses.map((clause) => clause.clauseId))],
|
|
207
|
+
selectedDocuments: documentCodes,
|
|
208
|
+
mapTokens: estimateTokens(map),
|
|
209
|
+
};
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (signal.aborted && !(error instanceof CliError && error.code === "PICKER_UNAVAILABLE"))
|
|
212
|
+
throw new CliError("GATHER_TIMEOUT", `the ${picker} trial for case ${replay.id} exceeded its ${Math.round(timeoutMs / 1000)}-second Gather deadline`, 1);
|
|
213
|
+
throw error;
|
|
214
|
+
} finally {
|
|
215
|
+
clearTimeout(deadline);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function runCases(request, id) {
|
|
220
|
+
const runs = (await request("GET", "/api/v1/gather/tuning/runs")).data ?? [];
|
|
221
|
+
const run = runs.find((x) => x.id === id);
|
|
222
|
+
if (!run) throw new CliError("TUNING_RUN_NOT_FOUND", `tuning run ${id} was not found`, 1);
|
|
223
|
+
if (run.state !== "running") throw new CliError("TUNING_RUN_NOT_RUNNING", `tuning run ${id} is ${run.state}; only a running run accepts trials`, 1);
|
|
224
|
+
const cases = Array.isArray(run.settings?.caseIds) ? run.settings.caseIds : [];
|
|
225
|
+
if (!cases.length) throw new CliError("TUNING_RUN_INVALID", `tuning run ${id} has no frozen case cohort`, 1);
|
|
226
|
+
return { run, cases };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const putTrial = (request, runId, trial, iteration) => request("PUT", `/api/v1/gather/tuning/runs/${encode(runId)}/trials`, {
|
|
230
|
+
caseId: trial.caseId,
|
|
231
|
+
picker: trial.picker,
|
|
232
|
+
iteration,
|
|
233
|
+
selectedClauseIds: trial.selectedClauseIds,
|
|
234
|
+
selectedDocuments: trial.selectedDocuments,
|
|
235
|
+
mapTokens: trial.mapTokens,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
function execText(payload) {
|
|
239
|
+
const d = payload.data;
|
|
240
|
+
const lines = [`Tuning run ${d.runId}: ${d.cases} case${d.cases === 1 ? "" : "s"} × ${TUNING_REPEATS} repeats`];
|
|
241
|
+
for (const p of d.pickers) {
|
|
242
|
+
if (p.status === "recorded") lines.push(` ${p.picker.padEnd(15)}recorded ${p.recorded}/${p.expected}`);
|
|
243
|
+
else lines.push(` ${p.picker.padEnd(15)}${p.status.toUpperCase()}${p.recorded ? ` after ${p.recorded}/${p.expected}` : ""}: ${p.reason}`);
|
|
244
|
+
}
|
|
245
|
+
lines.push(d.complete
|
|
246
|
+
? `All requested pickers recorded. Finalize once every picker is in: nodii-dev gather tune finalize ${d.runId}`
|
|
247
|
+
: "The matrix is not complete; Dev's finalize refuses a partial run. Nothing was faked for the pickers above.");
|
|
248
|
+
return lines.join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function summaryText(run) {
|
|
252
|
+
const lines = [`Tuning run ${run.id}: ${run.state}${run.summary ? ` · ${run.summary.trials} trials` : ""}`];
|
|
253
|
+
for (const [picker, s] of Object.entries(run.summary?.perPicker ?? {}))
|
|
254
|
+
lines.push(` ${picker.padEnd(15)}recall ${bps(s.recallBps)} ⚑ recall ${bps(s.flaggedRecallBps)} noise ${bps(s.noiseBps)} ~${s.averageMapTokens} tokens consistency ${bps(s.consistencyBps)}${s.flaggedMissedTrials ? ` ⚑ missed in ${s.flaggedMissedTrials} trials` : ""}`);
|
|
255
|
+
return lines.join("\n");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* `nodii-dev gather tune cases|replay|start|trial|exec|finalize|runs`: the
|
|
260
|
+
* client for Dev's Gather tuning API (AH-11). Scoring is Dev's; the CLI only
|
|
261
|
+
* runs the pickers and assembles each trial's Clause map read-only.
|
|
262
|
+
*/
|
|
263
|
+
export async function tuneCommand(action, args, flags, { request = devRequest, pick = pickDocuments, warm = warmPicker, corpus } = {}) {
|
|
264
|
+
if (action === "cases") {
|
|
265
|
+
const payload = await request("GET", "/api/v1/gather/tuning/cases");
|
|
266
|
+
return { payload, human: (p) => (p.data ?? []).length
|
|
267
|
+
? p.data.map((c) => `${c.id}\t${c.issueKey}\t${c.state}\t${c.buildType}/${c.side}\t${c.repos.join(",")}\t${c.answerCount} answers${c.disputeCount ? `, ${c.disputeCount} disputed` : ""}\t${c.objectiveTitle}`).join("\n")
|
|
268
|
+
: "No tuning cases. Harvest them with: nodii-dev api harvestGatherTuningCases" };
|
|
269
|
+
}
|
|
270
|
+
if (action === "runs") {
|
|
271
|
+
const payload = await request("GET", "/api/v1/gather/tuning/runs");
|
|
272
|
+
return { payload, human: (p) => (p.data ?? []).length ? p.data.map(summaryText).join("\n") : "No tuning runs." };
|
|
273
|
+
}
|
|
274
|
+
if (action === "replay") {
|
|
275
|
+
const id = uuid(args[0], "<case-id>");
|
|
276
|
+
const replay = (await request("GET", `/api/v1/gather/tuning/cases/${encode(id)}/replay`)).data;
|
|
277
|
+
const input = replayInput(replay);
|
|
278
|
+
const shortlist = (await request("POST", "/api/v1/rules/shortlist", { ...input, components: [] })).data;
|
|
279
|
+
const payload = { data: { case: { id: replay.id, issueKey: replay.issueKey, buildType: replay.buildType, side: replay.side }, ...input, shortlist } };
|
|
280
|
+
return { payload, human: (p) => [
|
|
281
|
+
`Case ${p.data.case.issueKey} (${p.data.case.buildType}/${p.data.case.side}) · repos ${p.data.repos.join(", ") || "none"}`,
|
|
282
|
+
`Objective: ${p.data.objective}`,
|
|
283
|
+
`Files: ${p.data.paths.length ? p.data.paths.join(", ") : "none"}`,
|
|
284
|
+
"Trigger shortlist:",
|
|
285
|
+
...(p.data.shortlist.triggers ?? []).map((t) => ` ${t.code}: ${t.sentence}${t.forcedBy?.length ? ` [required by ${t.forcedBy.join(", ")}]` : ""}`),
|
|
286
|
+
`Record the lane-agent pick: nodii-dev gather tune trial <run-id> --case ${p.data.case.id} --picker lane-agent --iteration N --documents CODE,CODE`,
|
|
287
|
+
].join("\n") };
|
|
288
|
+
}
|
|
289
|
+
if (action === "start") {
|
|
290
|
+
const trigger = flags.trigger ?? "manual";
|
|
291
|
+
if (!TRIGGERS.includes(trigger)) throw new CliError("USAGE", `--trigger must be one of ${TRIGGERS.join(", ")}`, 2);
|
|
292
|
+
if (typeof flags["corpus-ref"] !== "string" || !flags["corpus-ref"].trim()) throw new CliError("USAGE", "gather tune start needs --corpus-ref REF (the rule corpus the run is scored against)", 2);
|
|
293
|
+
if (typeof flags.cases !== "string") throw new CliError("USAGE", "gather tune start needs --cases all|ID,ID", 2);
|
|
294
|
+
let caseIds;
|
|
295
|
+
if (flags.cases === "all") {
|
|
296
|
+
caseIds = ((await request("GET", "/api/v1/gather/tuning/cases")).data ?? []).filter((c) => c.state === "challenged" && !c.disputeCount && !c.omissionCount).map((c) => c.id);
|
|
297
|
+
if (!caseIds.length) throw new CliError("TUNING_CASE_NOT_CHALLENGED", "no tuning case has an undisputed, independently challenged answer key yet", 1);
|
|
298
|
+
} else caseIds = list(flags.cases).map((id) => uuid(id, "every --cases entry"));
|
|
299
|
+
let settings = {};
|
|
300
|
+
if (flags.settings !== undefined) {
|
|
301
|
+
try { settings = JSON.parse(flags.settings); }
|
|
302
|
+
catch { throw new CliError("USAGE", "--settings must be a JSON object", 2); }
|
|
303
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) throw new CliError("USAGE", "--settings must be a JSON object", 2);
|
|
304
|
+
}
|
|
305
|
+
const payload = await request("POST", "/api/v1/gather/tuning/runs", { trigger, caseIds, corpusRef: flags["corpus-ref"].trim(), settings });
|
|
306
|
+
return { payload, human: (p) => `Started tuning run ${p.data.id}: ${p.data.caseCount} case${p.data.caseCount === 1 ? "" : "s"} × ${p.data.pickers.length} pickers × ${p.data.repeats} repeats.\nRun the automatic pickers: nodii-dev gather tune exec ${p.data.id}` };
|
|
307
|
+
}
|
|
308
|
+
if (action === "trial") {
|
|
309
|
+
const runId = uuid(args[0], "<run-id>");
|
|
310
|
+
const caseId = uuid(flags.case, "--case");
|
|
311
|
+
const picker = pickerFlag(flags.picker);
|
|
312
|
+
const iteration = iterationFlag(flags.iteration);
|
|
313
|
+
if (picker !== "lane-agent" && flags.documents !== undefined) throw new CliError("USAGE", "--documents is the lane-agent's own pick; other pickers choose for themselves", 2);
|
|
314
|
+
const { cases } = await runCases(request, runId);
|
|
315
|
+
if (!cases.includes(caseId)) throw new CliError("TUNING_CASE_OUTSIDE_RUN", `case ${caseId} is not in run ${runId}`, 1);
|
|
316
|
+
const replay = (await request("GET", `/api/v1/gather/tuning/cases/${encode(caseId)}/replay`)).data;
|
|
317
|
+
if (TUNING_MODELS[picker]) await warm({ model: TUNING_MODELS[picker] });
|
|
318
|
+
const trial = await runTrial(replay, picker, { request, pick, corpus: corpus ?? tuningCorpus(request), documents: list(flags.documents) });
|
|
319
|
+
const payload = await putTrial(request, runId, trial, iteration);
|
|
320
|
+
return { payload, human: (p) => `Recorded ${picker} #${iteration} for case ${caseId}: recall ${bps(p.data.recallBps)}, ⚑ recall ${bps(p.data.flaggedRecallBps)}, noise ${bps(p.data.noiseBps)}, ~${p.data.mapTokens} tokens.` };
|
|
321
|
+
}
|
|
322
|
+
if (action === "exec") {
|
|
323
|
+
const runId = uuid(args[0], "<run-id>");
|
|
324
|
+
const pickers = flags.pickers === undefined ? AUTOMATIC : list(flags.pickers).map(pickerFlag);
|
|
325
|
+
if (pickers.includes("lane-agent")) throw new CliError("USAGE", "the lane-agent picker is the agent itself: record each pick with `gather tune trial --picker lane-agent`", 2);
|
|
326
|
+
const { cases } = await runCases(request, runId);
|
|
327
|
+
const shared = corpus ?? tuningCorpus(request);
|
|
328
|
+
const replays = new Map();
|
|
329
|
+
const replayOf = async (caseId) => {
|
|
330
|
+
if (!replays.has(caseId)) replays.set(caseId, (await request("GET", `/api/v1/gather/tuning/cases/${encode(caseId)}/replay`)).data);
|
|
331
|
+
return replays.get(caseId);
|
|
332
|
+
};
|
|
333
|
+
const results = [];
|
|
334
|
+
for (const picker of pickers) {
|
|
335
|
+
const result = { picker, status: "recorded", recorded: 0, expected: cases.length * TUNING_REPEATS };
|
|
336
|
+
results.push(result);
|
|
337
|
+
try {
|
|
338
|
+
if (TUNING_MODELS[picker]) await warm({ model: TUNING_MODELS[picker] });
|
|
339
|
+
for (const caseId of cases)
|
|
340
|
+
for (let iteration = 1; iteration <= TUNING_REPEATS; iteration++) {
|
|
341
|
+
const trial = await runTrial(await replayOf(caseId), picker, { request, pick, corpus: shared });
|
|
342
|
+
await putTrial(request, runId, trial, iteration);
|
|
343
|
+
result.recorded++;
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
// A Gemma picker this Mac cannot run is reported, not filled in; any
|
|
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;
|
|
349
|
+
Object.assign(result, { status: "unavailable", code: error.code, reason: error.message });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const complete = results.every((r) => r.status === "recorded");
|
|
353
|
+
return { payload: { data: { runId, cases: cases.length, complete, pickers: results } }, human: execText, exitCode: complete ? 0 : 1 };
|
|
354
|
+
}
|
|
355
|
+
if (action === "finalize") {
|
|
356
|
+
const runId = uuid(args[0], "<run-id>");
|
|
357
|
+
const payload = await request("POST", `/api/v1/gather/tuning/runs/${encode(runId)}/finalize`, {});
|
|
358
|
+
return { payload, human: (p) => summaryText(p.data) };
|
|
359
|
+
}
|
|
360
|
+
throw new CliError("USAGE", "gather tune cases|replay <case-id>|start --cases all|ID,ID --corpus-ref REF|trial <run-id> --case ID --picker P --iteration N [--documents A,B]|exec <run-id> [--pickers P,Q]|finalize <run-id>|runs", 2);
|
|
361
|
+
}
|