@nodii/dev 0.6.31 → 0.6.33
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 +6 -0
- package/dist/cli.js +24 -0
- package/dist/core.js +1 -1
- package/dist/gather.js +33 -8
- package/dist/lanes.js +85 -9
- package/dist/starting.js +8 -2
- 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
|
package/dist/cli.js
CHANGED
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
conformanceLines,
|
|
53
53
|
} from "./figma.js";
|
|
54
54
|
export { figmaLock, submitMode } from "./figma.js";
|
|
55
|
+
import { tuneCommand } from "./tune.js";
|
|
55
56
|
import { docsCommand, rulebookCommand, rulesCommand } from "./upload.js";
|
|
56
57
|
import { changeSetCommand, guidelineCommand } from "./guidelines.js";
|
|
57
58
|
import { designCheck, designCheckText, designRoles, designRolesText } from "./design.js";
|
|
@@ -155,6 +156,15 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
155
156
|
rules report [--section S] [--lane KEY] # delivery totals per document; rules served per lane
|
|
156
157
|
rules find "QUESTION" [--limit N] # answer an ad-hoc rules question from the indexed sections
|
|
157
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
|
|
158
168
|
|
|
159
169
|
coordinator start --machine NAME # register this machine's one watched coordinator; refuses a live second
|
|
160
170
|
lane launch <key> --done-check TEXT --kind KIND --side SIDE
|
|
@@ -168,6 +178,10 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
168
178
|
lane amend <key> --objective|--scope|--rules TEXT --reason TEXT
|
|
169
179
|
lane gather [<key>] [--delta] [--objective TEXT] [--done-check TEXT] [--components a,b] [--paths a,b]
|
|
170
180
|
# infers a launched worktree; explicit contract inputs support manual lane start
|
|
181
|
+
lane gather [<key>] --picker agent [--shortlist ID --documents CODE[,CODE]]
|
|
182
|
+
# the lane agent picks instead of local Gemma: alone it prints the
|
|
183
|
+
# recorded trigger shortlist; with --shortlist/--documents it records
|
|
184
|
+
# the pick (picker: agent) and the Clause map
|
|
171
185
|
run [--kind build|test_run|subagent|deploy] [--name TEXT] -- <command> [args...]
|
|
172
186
|
# watched command: registers with NODII_DEV_RUN_ID,
|
|
173
187
|
# heartbeats, and records exit code + redacted log tail
|
|
@@ -175,6 +189,8 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
175
189
|
lane start <key> --title TEXT --kind KIND --side SIDE [--repo REPO] [--issue ISSUE]
|
|
176
190
|
lane step <key> --side SIDE --start|--done|--block <step> [--reason TEXT]
|
|
177
191
|
# --block --reason on a blocked step records its new reason
|
|
192
|
+
lane step <key> --side SIDE --not-applicable gather --reason-code pre_harness_lane [--note TEXT]
|
|
193
|
+
# coordinator only: Gather never applied (pre-Gather lane); never "done"
|
|
178
194
|
lane evidence <key> --pr|--commit|--figma|--deploy|--dev-check REF
|
|
179
195
|
lane subagent-quota <key> <n> # coordinator assigns 0-100 subagent slots
|
|
180
196
|
lane heartbeat|resume|land|verify|prioritize|deprioritize|stop <key>
|
|
@@ -568,6 +584,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
568
584
|
if (command === "docs") return emitCommand(await docsCommand(action, rest, f));
|
|
569
585
|
if (command === "rulebook" && ["list", "show", "diff"].includes(action)) return emitCommand(await rulebookCommand(action, rest, f));
|
|
570
586
|
if (command === "rules") return emitCommand(await rulesCommand(action, rest, f));
|
|
587
|
+
if (command === "gather" && action === "tune") {
|
|
588
|
+
const result = await tuneCommand(rest[0], rest.slice(1), f);
|
|
589
|
+
emit(result.payload, json, result.human);
|
|
590
|
+
if (result.exitCode) process.exitCode = result.exitCode;
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
571
593
|
// Guideline change sets: approving or rejecting one is a person's action in Dev.
|
|
572
594
|
if (command === "design" && action === "guideline") {
|
|
573
595
|
const result = await guidelineCommand(rest[0], rest.slice(1), f);
|
|
@@ -593,6 +615,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
593
615
|
if (command === "lane" && action === "amend") return emitCommand(await amendLaneCommand(rest[0], f));
|
|
594
616
|
if (command === "lane" && action === "gather") {
|
|
595
617
|
const result = await gatherLaneCommand(rest[0], f);
|
|
618
|
+
// An agent picker's shortlist step records nothing yet.
|
|
619
|
+
if (result.payload?.data?.recorded === false) return emitCommand(result);
|
|
596
620
|
// What a gather covered is the lane's gathered set for the nudge hooks.
|
|
597
621
|
const asked = (value) => (typeof value === "string" ? value.split(",").map((item) => item.trim()).filter(Boolean) : []);
|
|
598
622
|
await (async () => recordGathered(process.cwd(), { guidelines: asked(f.components), deltaGathered: f.delta ? (f.paths ? asked(f.paths) : await changedPaths(process.cwd())) : [] }))().catch(() => {});
|
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.33";
|
|
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/lanes.js
CHANGED
|
@@ -4,7 +4,7 @@ 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 } from "./gather.js";
|
|
8
8
|
import { storeGatherFreeze } from "./freeze.js";
|
|
9
9
|
|
|
10
10
|
const execFileAsync = promisify(execFile);
|
|
@@ -46,7 +46,65 @@ export async function inferGitContext() {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const laneLine = (x) => `${x.key}\t${x.status}\t${x.kind}/${x.side}\t${x.title}${x.stuck ? "\tSTUCK" : ""}`;
|
|
49
|
-
|
|
49
|
+
// GATHER-NA: a step the coordinator ruled not applicable is never shown as done.
|
|
50
|
+
const stepState = (s) => s.state === "not_applicable" ? `not applicable: ${s.notApplicableReason ?? "(no reason recorded)"}` : s.state;
|
|
51
|
+
const detailText = (x) => [laneLine(x), ...(x.steps ?? []).map((s) => ` ${s.side}\t${stepState(s)}\t${s.stepKey}\t${s.label}`)].join("\n");
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* GATHER-NA: `--picker agent` makes the lane agent Gather's picker. Without
|
|
55
|
+
* --shortlist it only shortlists and prints the triggers to pick from; with
|
|
56
|
+
* --shortlist ID --documents A,B it records that pick against the recorded
|
|
57
|
+
* shortlist: Dev reuses its objective, paths and components, refuses a pick
|
|
58
|
+
* it did not offer, and adds its forced triggers. Gather never records a
|
|
59
|
+
* pick with no shortlist behind it.
|
|
60
|
+
*/
|
|
61
|
+
function agentPickerFlags(flags) {
|
|
62
|
+
if (flags.picker === undefined) {
|
|
63
|
+
if (flags.shortlist !== undefined || flags.documents !== undefined)
|
|
64
|
+
throw new CliError("USAGE", "--shortlist and --documents go with --picker agent.", 2);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (flags.picker !== "agent")
|
|
68
|
+
throw new CliError("USAGE", `--picker accepts only agent; without it the local ${PICKER_MODEL} picks.`, 2);
|
|
69
|
+
const shortlist = typeof flags.shortlist === "string" ? flags.shortlist.trim() : undefined;
|
|
70
|
+
const documents = typeof flags.documents === "string" ? list(flags.documents) : undefined;
|
|
71
|
+
if ((flags.shortlist !== undefined && !shortlist) || (flags.documents !== undefined && !documents?.length))
|
|
72
|
+
throw new CliError("USAGE", "--shortlist needs an id and --documents needs one or more codes.", 2);
|
|
73
|
+
if (Boolean(shortlist) !== Boolean(documents))
|
|
74
|
+
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);
|
|
75
|
+
return { shortlist, documents };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// hub-58: the reason is a fixed code (Dev stores its canonical text); a note may add context.
|
|
79
|
+
export const NOT_APPLICABLE_REASON_CODES = ["pre_harness_lane"];
|
|
80
|
+
function notApplicableRuling(flags) {
|
|
81
|
+
const code = flags["reason-code"];
|
|
82
|
+
if (!NOT_APPLICABLE_REASON_CODES.includes(code))
|
|
83
|
+
throw new CliError("USAGE", `--not-applicable needs --reason-code ${NOT_APPLICABLE_REASON_CODES.join("|")}.`, 2);
|
|
84
|
+
if (flags.note !== undefined && (typeof flags.note !== "string" || !flags.note.trim()))
|
|
85
|
+
throw new CliError("USAGE", "--note needs non-empty text.", 2);
|
|
86
|
+
return { reasonCode: code, ...(flags.note !== undefined ? { note: flags.note.trim() } : {}) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const shellQuote = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
90
|
+
// The follow-up repeats the flags that shaped this Gather, so it records the same one.
|
|
91
|
+
const agentFollowUp = (ref, shortlistId, flags) => [
|
|
92
|
+
`nodii-dev lane gather ${ref} --picker agent --shortlist ${shortlistId} --documents CODE[,CODE]`,
|
|
93
|
+
...(flags.delta ? ["--delta"] : []),
|
|
94
|
+
// --paths/--components are not resent to Dev (it reuses the shortlist's),
|
|
95
|
+
// but the local gathered set for the nudge hooks reads them.
|
|
96
|
+
...["objective", "done-check", "section-limit-per-document", "paths", "components"]
|
|
97
|
+
.filter((flag) => typeof flags[flag] === "string")
|
|
98
|
+
.map((flag) => `--${flag} ${shellQuote(flags[flag])}`),
|
|
99
|
+
].join(" ");
|
|
100
|
+
const agentShortlistText = (ref, data, flags) => [
|
|
101
|
+
`Trigger shortlist ${data.shortlistId} · ${JSON.stringify(data.objective)}`,
|
|
102
|
+
"You are Gather's picker (picker: agent). Nothing is recorded yet.",
|
|
103
|
+
...data.triggers.map((trigger) => ` ${trigger.code}\t${trigger.title ?? ""}: ${trigger.sentence ?? ""}${trigger.forcedBy?.length ? ` [forced by ${trigger.forcedBy.join(", ")}]` : ""}`),
|
|
104
|
+
"",
|
|
105
|
+
"Pick every document that applies (forced ones are added for you), then record the pick:",
|
|
106
|
+
` ${agentFollowUp(ref, data.shortlistId, flags)}`,
|
|
107
|
+
].join("\n");
|
|
50
108
|
|
|
51
109
|
function evidence(flags) {
|
|
52
110
|
const selected = ["pr", "commit", "figma", "deploy", "dev-check"].filter((x) => flags[x] !== undefined);
|
|
@@ -183,6 +241,7 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
183
241
|
const deadline = controller ? setTimeout(() => controller.abort(), gatherTimeoutMs) : null;
|
|
184
242
|
const signal = gatherSignal ?? controller.signal;
|
|
185
243
|
try {
|
|
244
|
+
const byAgent = agentPickerFlags(flags);
|
|
186
245
|
const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`, undefined, { signal })).data;
|
|
187
246
|
if (flags.objective !== undefined && (typeof flags.objective !== "string" || !flags.objective.trim()))
|
|
188
247
|
throw new CliError("USAGE", "lane gather --objective needs non-empty text", 2);
|
|
@@ -255,9 +314,15 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
255
314
|
components,
|
|
256
315
|
...(limit !== undefined ? { limit } : {}),
|
|
257
316
|
};
|
|
258
|
-
|
|
317
|
+
// An agent pick records against the shortlist it was made from.
|
|
318
|
+
const shortlist = byAgent?.shortlist
|
|
319
|
+
? { data: { shortlistId: byAgent.shortlist, triggers: [] } }
|
|
320
|
+
: await request("POST", "/api/v1/rules/shortlist", shortlistBody, { signal });
|
|
321
|
+
const shortlistId = shortlist.data?.shortlistId;
|
|
259
322
|
const triggers = shortlist.data?.triggers ?? [];
|
|
260
|
-
|
|
323
|
+
if (byAgent && !byAgent.shortlist)
|
|
324
|
+
return { payload: { data: { recorded: false, picker: "agent", shortlistId, objective, triggers } }, human: (p) => agentShortlistText(ref, p.data, flags) };
|
|
325
|
+
const picked = byAgent ? { documents: byAgent.documents } : await picker({
|
|
261
326
|
objective,
|
|
262
327
|
files: paths,
|
|
263
328
|
triggers: triggers.map((trigger) => ({
|
|
@@ -269,10 +334,12 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
269
334
|
const payload = await request("POST", `/api/v1/lanes/${encode(id)}/gather`, {
|
|
270
335
|
objective,
|
|
271
336
|
documentCodes: picked.documents,
|
|
272
|
-
paths
|
|
273
|
-
components,
|
|
337
|
+
// An agent pick answers the shortlist's own paths and components.
|
|
338
|
+
...(byAgent ? {} : { paths, components }),
|
|
274
339
|
mode: flags.delta ? "delta" : "full",
|
|
275
340
|
...(sectionLimit !== undefined ? { sectionLimitPerDocument: sectionLimit } : {}),
|
|
341
|
+
shortlistId,
|
|
342
|
+
picker: byAgent ? "agent" : picked.model ?? PICKER_MODEL,
|
|
276
343
|
}, { signal });
|
|
277
344
|
const figma = lane.freeze?.boundaries?.figma ?? (lane.launch?.figmaScopes ?? []).map((file) => ({
|
|
278
345
|
fileKey: file.fileKey,
|
|
@@ -305,15 +372,24 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
305
372
|
}
|
|
306
373
|
}
|
|
307
374
|
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 --
|
|
375
|
+
const selected = ["start", "done", "block", "not-applicable"].filter((x) => flags[x]);
|
|
376
|
+
if (selected.length !== 1) throw new CliError("USAGE", "lane step needs exactly one of --start, --done, --block, or --not-applicable.", 2);
|
|
310
377
|
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>");
|
|
378
|
+
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
379
|
const reason = typeof flags.reason === "string" ? flags.reason.trim() : "";
|
|
313
380
|
if (flags.reason !== undefined && (selected[0] !== "block" || !reason)) throw new CliError("USAGE", "--reason TEXT goes with --block.", 2);
|
|
381
|
+
const ruling = selected[0] === "not-applicable" ? notApplicableRuling(flags) : null;
|
|
382
|
+
if (!ruling && (flags["reason-code"] !== undefined || flags.note !== undefined)) throw new CliError("USAGE", "--reason-code and --note go with --not-applicable.", 2);
|
|
314
383
|
const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`)).data;
|
|
315
384
|
const step = lane.steps.find((x) => x.side === side && (x.id === stepRef || x.stepKey === stepRef));
|
|
316
385
|
if (!step) throw new CliError("STEP_NOT_FOUND", `No ${side} step ${stepRef} on ${ref}.`, 1);
|
|
386
|
+
// hub-58: Gather is the only step a coordinator may rule not applicable.
|
|
387
|
+
if (ruling && step.stepKey !== "gather") throw new CliError("USAGE", "--not-applicable covers only the gather step.", 2);
|
|
388
|
+
// GATHER-NA: coordinator only; Dev refuses a lane identity (COORDINATOR_REQUIRED).
|
|
389
|
+
if (ruling) {
|
|
390
|
+
const payload = await request("POST", `/api/v1/lanes/${encode(id)}/steps/${encode(step.id)}/not-applicable`, { ...ruling, ...via });
|
|
391
|
+
return { payload, human: (p) => `${ref}: ${side}/${step.stepKey} not applicable: ${p.data.notApplicableReason}${ruling.note ? ` (${ruling.note})` : ""}` };
|
|
392
|
+
}
|
|
317
393
|
// Dev's blockLaneStep stores no reason and refuses a step that is already
|
|
318
394
|
// blocked (INVALID_STEP_STATE), so the reason is a lane heartbeat note
|
|
319
395
|
// until Dev issue 125 lets the step carry it.
|
package/dist/starting.js
CHANGED
|
@@ -396,6 +396,9 @@ export async function amendmentGuard(argv, { request = devRequest, marker = curr
|
|
|
396
396
|
|
|
397
397
|
const runGather = (key, flags, { request, signal }) => laneCommand("gather", [key], flags, { request, gatherSignal: signal });
|
|
398
398
|
|
|
399
|
+
// Gather's human text is its Clause map (a function of the payload).
|
|
400
|
+
const gatheredText = (gathered) => typeof gathered.human === "function" ? gathered.human(gathered.payload) : gathered.human ?? "Gather complete.";
|
|
401
|
+
|
|
399
402
|
export async function gatherLaneCommand(key, flags, { request = devRequest, marker = currentMarker, gather = runGather, acknowledge = nextCommand, gatherTimeoutMs = GATHER_TIMEOUT_MS } = {}) {
|
|
400
403
|
const controller = new AbortController();
|
|
401
404
|
const deadline = setTimeout(() => controller.abort(), gatherTimeoutMs);
|
|
@@ -411,10 +414,13 @@ export async function gatherLaneCommand(key, flags, { request = devRequest, mark
|
|
|
411
414
|
const pending = await timedRequest("GET", `/api/v1/lanes/${encode(key)}/amendments${query({ runId: lane.runId })}`);
|
|
412
415
|
if (!pending.data.length) {
|
|
413
416
|
const gathered = await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
414
|
-
|
|
417
|
+
if (gathered.payload?.data?.recorded === false) return gathered;
|
|
418
|
+
return { ...gathered, human: `${gatheredText(gathered)}\nNo pending amendments.` };
|
|
415
419
|
}
|
|
416
420
|
const version = Math.max(...pending.data.map((item) => item.version));
|
|
417
421
|
const gathered = await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
422
|
+
// An agent picker's shortlist step records nothing, so it acknowledges nothing.
|
|
423
|
+
if (gathered.payload?.data?.recorded === false) return gathered;
|
|
418
424
|
const acknowledgement = await withSignal(acknowledge(key, {
|
|
419
425
|
...flags,
|
|
420
426
|
deltaContext: { runId: lane.runId, amendmentVersion: version },
|
|
@@ -424,7 +430,7 @@ export async function gatherLaneCommand(key, flags, { request = devRequest, mark
|
|
|
424
430
|
throw new CliError("LANE_AMENDMENT_ACK_FAILED", `Delta gather did not acknowledge amendment version ${version}; the lane remains blocked.`, 1);
|
|
425
431
|
return {
|
|
426
432
|
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${
|
|
433
|
+
human: `${pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n")}\n${gatheredText(gathered)}\nAcknowledged amendment version ${version}.`,
|
|
428
434
|
};
|
|
429
435
|
} catch (error) {
|
|
430
436
|
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
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
|
+
}
|