@nodii/dev 0.6.29 → 0.6.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/dist/cli.js +15 -4
- package/dist/core.js +37 -14
- package/dist/devops-openapi.json +1 -1
- package/dist/doctor.js +18 -1
- package/dist/freeze.js +328 -0
- package/dist/gather.js +241 -0
- package/dist/hooks.js +16 -42
- package/dist/lanes.js +170 -9
- package/dist/nodii-dev.js +12 -2
- package/dist/setup.js +23 -6
- package/dist/starting.js +52 -23
- package/dist/upload.js +11 -1
- package/package.json +1 -1
package/dist/lanes.js
CHANGED
|
@@ -4,9 +4,12 @@ import { promisify } from "node:util";
|
|
|
4
4
|
import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./core.js";
|
|
5
5
|
import { launchRecovery } from "./runtime-launcher.js";
|
|
6
6
|
import { recordReviewReceipt, reviewCommitContext } from "./safety.js";
|
|
7
|
+
import { GATHER_TIMEOUT_MS, gatherText, pickDocuments } from "./gather.js";
|
|
8
|
+
import { storeGatherFreeze } from "./freeze.js";
|
|
7
9
|
|
|
8
10
|
const execFileAsync = promisify(execFile);
|
|
9
11
|
const via = { via: "nodii-dev" };
|
|
12
|
+
const GATHER_ACTIVE = new Set(["queued", "building"]);
|
|
10
13
|
// heartbeatLane's note limit.
|
|
11
14
|
const BLOCK_NOTE_MAX = 500;
|
|
12
15
|
|
|
@@ -118,7 +121,34 @@ export async function branchCommits(lane) {
|
|
|
118
121
|
}
|
|
119
122
|
}
|
|
120
123
|
|
|
121
|
-
|
|
124
|
+
async function currentGatherLane(request, config, gitContext) {
|
|
125
|
+
const c = await config();
|
|
126
|
+
const context = await gitContext();
|
|
127
|
+
const lanes = [];
|
|
128
|
+
let cursor;
|
|
129
|
+
do {
|
|
130
|
+
const page = await request("GET", `/api/v1/lanes${query({ agent: c.agentId, cursor, limit: 200 })}`);
|
|
131
|
+
lanes.push(...(page.data ?? []));
|
|
132
|
+
cursor = page.nextCursor ?? undefined;
|
|
133
|
+
} while (cursor);
|
|
134
|
+
const active = lanes.filter((lane) => GATHER_ACTIVE.has(lane.status));
|
|
135
|
+
const repositories = (lane) => lane.repositories?.length
|
|
136
|
+
? lane.repositories
|
|
137
|
+
: lane.freeze?.boundaries?.repos?.length
|
|
138
|
+
? lane.freeze.boundaries.repos
|
|
139
|
+
: [{ repo: lane.repos?.[0], branch: lane.branch, worktree: lane.worktree }];
|
|
140
|
+
const exact = active.filter((lane) => repositories(lane).some((repository) => repository.worktree === context.worktree));
|
|
141
|
+
const branch = active.filter((lane) => repositories(lane).some((repository) =>
|
|
142
|
+
repository.branch === context.branch && repoName(repository.repo ?? "") === context.repo));
|
|
143
|
+
const matches = exact.length ? exact : branch;
|
|
144
|
+
if (matches.length !== 1)
|
|
145
|
+
throw new CliError("CURRENT_LANE_AMBIGUOUS", matches.length
|
|
146
|
+
? `This worktree matches ${matches.length} active lanes (${matches.map((lane) => lane.key).join(", ")}); pass one key explicitly.`
|
|
147
|
+
: "No active lane matches this agent and worktree; pass the lane key explicitly.", 2);
|
|
148
|
+
return matches[0].key;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt, picker = pickDocuments, changedPaths = laneChangedPaths, gatherTimeoutMs = GATHER_TIMEOUT_MS, gatherSignal, storeFreeze = storeGatherFreeze, launch = launchRecovery } = {}) {
|
|
122
152
|
if (action === "list") {
|
|
123
153
|
const c = flags.mine ? await config() : null;
|
|
124
154
|
const payload = await request("GET", `/api/v1/lanes${query({ kind: flags.kind, status: flags.status, machine: flags.machine, agent: flags.mine ? c.agentId : flags.agent, "waiting-on": flags["waiting-on"], stuck: flags.stuck, cursor: flags.cursor, limit: flags.limit })}`);
|
|
@@ -142,12 +172,138 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
142
172
|
const payload = await request("POST", `/api/v1/lanes/${encode(key)}/start`, via);
|
|
143
173
|
return { payload, human: (p) => `Started ${laneLine(p.data)}` };
|
|
144
174
|
}
|
|
145
|
-
const ref = need(args[0], `lane ${action} needs <key>`);
|
|
175
|
+
const ref = need(args[0] ?? (action === "gather" ? await currentGatherLane(request, config, gitContext) : undefined), `lane ${action} needs <key>`);
|
|
146
176
|
const id = ref;
|
|
147
177
|
if (action === "show") {
|
|
148
178
|
const payload = await request("GET", `/api/v1/lanes/${encode(id)}`);
|
|
149
179
|
return { payload, human: (p) => detailText(p.data) };
|
|
150
180
|
}
|
|
181
|
+
if (action === "gather") {
|
|
182
|
+
const controller = gatherSignal ? null : new AbortController();
|
|
183
|
+
const deadline = controller ? setTimeout(() => controller.abort(), gatherTimeoutMs) : null;
|
|
184
|
+
const signal = gatherSignal ?? controller.signal;
|
|
185
|
+
try {
|
|
186
|
+
const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`, undefined, { signal })).data;
|
|
187
|
+
if (flags.objective !== undefined && (typeof flags.objective !== "string" || !flags.objective.trim()))
|
|
188
|
+
throw new CliError("USAGE", "lane gather --objective needs non-empty text", 2);
|
|
189
|
+
if (flags["done-check"] !== undefined && (typeof flags["done-check"] !== "string" || !flags["done-check"].trim()))
|
|
190
|
+
throw new CliError("USAGE", "lane gather --done-check needs non-empty text", 2);
|
|
191
|
+
const durableObjective = [lane.objective, lane.freeze?.objective?.ask].find((x) => typeof x === "string" && x.trim());
|
|
192
|
+
const explicitObjective = typeof flags.objective === "string" ? flags.objective.trim() : undefined;
|
|
193
|
+
if (explicitObjective && durableObjective && explicitObjective !== durableObjective.trim())
|
|
194
|
+
throw new CliError("GATHER_OBJECTIVE_CONFLICT", `${ref} already has a different objective. Use lane amend; Gather cannot rewrite the durable contract.`, 1);
|
|
195
|
+
const issueObjective = typeof lane.issue?.bodyMd === "string" && lane.issue.bodyMd.trim() ? lane.issue.bodyMd : undefined;
|
|
196
|
+
const objective = durableObjective ?? explicitObjective ?? issueObjective;
|
|
197
|
+
if (!objective) throw new CliError("GATHER_OBJECTIVE_MISSING", `${ref} has no recorded objective. Amend the lane or pass --objective; the CLI does not invent one.`, 1);
|
|
198
|
+
if (objective.length > 20_000) throw new CliError("USAGE", "The recorded Gather objective exceeds Dev's 20,000-character limit.", 2);
|
|
199
|
+
const durableDoneCheck = [lane.doneCheck, lane.freeze?.objective?.doneCheck].find((x) => typeof x === "string" && x.trim());
|
|
200
|
+
const explicitDoneCheck = typeof flags["done-check"] === "string" ? flags["done-check"].trim() : undefined;
|
|
201
|
+
if (explicitDoneCheck && durableDoneCheck && explicitDoneCheck !== durableDoneCheck.trim())
|
|
202
|
+
throw new CliError("GATHER_DONE_CHECK_CONFLICT", `${ref} already has a different done check. Use lane amend; Gather cannot rewrite the durable contract.`, 1);
|
|
203
|
+
const doneCheck = durableDoneCheck ?? explicitDoneCheck;
|
|
204
|
+
if (!doneCheck) throw new CliError("GATHER_DONE_CHECK_MISSING", `${ref} has no recorded done check. Pass --done-check before Gather records anything.`, 1);
|
|
205
|
+
if (doneCheck.length > 10_000) throw new CliError("USAGE", "The Gather done check exceeds Dev's 10,000-character limit.", 2);
|
|
206
|
+
const legacyRepository = {
|
|
207
|
+
repo: lane.repos?.[0],
|
|
208
|
+
branch: lane.branch,
|
|
209
|
+
worktree: lane.worktree,
|
|
210
|
+
ownedPaths: lane.ownedPaths ?? [],
|
|
211
|
+
};
|
|
212
|
+
const sourceRepositories = lane.repositories?.length
|
|
213
|
+
? lane.repositories
|
|
214
|
+
: lane.freeze?.boundaries?.repos?.length
|
|
215
|
+
? lane.freeze.boundaries.repos
|
|
216
|
+
: [legacyRepository];
|
|
217
|
+
const repositories = sourceRepositories.map((repository) => Object.fromEntries(
|
|
218
|
+
["repo", "branch", "worktree", "ownedPaths"]
|
|
219
|
+
.map((key) => [key, repository[key]])
|
|
220
|
+
.filter(([, value]) => value !== undefined),
|
|
221
|
+
));
|
|
222
|
+
if (!repositories.length || repositories.some((repository) => !repository.repo || !repository.branch || !repository.worktree))
|
|
223
|
+
throw new CliError("GATHER_BOUNDARIES_MISSING", `${ref} has an incomplete repository boundary. Repair its repo, branch, and worktree before Gather records anything.`, 1);
|
|
224
|
+
const limit = flags.limit === undefined ? undefined : Number(flags.limit);
|
|
225
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 500))
|
|
226
|
+
throw new CliError("USAGE", "lane gather --limit needs a whole number from 1 to 500", 2);
|
|
227
|
+
const sectionLimit = flags["section-limit-per-document"] === undefined ? undefined : Number(flags["section-limit-per-document"]);
|
|
228
|
+
if (sectionLimit !== undefined && (!Number.isInteger(sectionLimit) || sectionLimit < 1 || sectionLimit > 20))
|
|
229
|
+
throw new CliError("USAGE", "lane gather --section-limit-per-document needs a whole number from 1 to 20", 2);
|
|
230
|
+
const recordedPaths = [
|
|
231
|
+
...(lane.repositories ?? []).flatMap((repository) => repository.ownedPaths ?? []),
|
|
232
|
+
...(lane.freeze?.boundaries?.repos ?? []).flatMap((repository) => repository.ownedPaths ?? []),
|
|
233
|
+
...(lane.ownedPaths ?? []),
|
|
234
|
+
];
|
|
235
|
+
const driftPaths = await changedPaths(lane, { signal });
|
|
236
|
+
if (driftPaths === null)
|
|
237
|
+
throw new CliError("GATHER_PATHS_UNAVAILABLE", "Gather could not read every recorded worktree. Repair the lane boundary or make its repositories available before continuing.", 1);
|
|
238
|
+
const paths = flags.paths ? list(flags.paths) : [...new Set([...recordedPaths, ...driftPaths])];
|
|
239
|
+
if (paths.length > 5000) throw new CliError("GATHER_SCOPE_TOO_LARGE", `Gather found ${paths.length} paths; Dev accepts at most 5,000. Narrow the lane's recorded scope before continuing.`, 1);
|
|
240
|
+
const repoRefs = [
|
|
241
|
+
...(lane.repositories ?? []).map((repository) => repository.repo),
|
|
242
|
+
...(lane.freeze?.boundaries?.repos ?? []).map((repository) => repository.repo),
|
|
243
|
+
...(Array.isArray(lane.repos) ? lane.repos.map((repo) => typeof repo === "string" ? repo : repo.repo) : []),
|
|
244
|
+
];
|
|
245
|
+
const repos = [...new Set(repoRefs.filter(Boolean).map(repoName))];
|
|
246
|
+
const components = flags.components ? list(flags.components) : [
|
|
247
|
+
...(lane.freeze?.boundaries?.figma ?? []).flatMap((file) => file.componentSetKeys ?? []),
|
|
248
|
+
...(lane.launch?.figmaScopes ?? []).flatMap((file) => file.componentSets ?? []),
|
|
249
|
+
...(lane.components ?? []),
|
|
250
|
+
];
|
|
251
|
+
const shortlistBody = {
|
|
252
|
+
objective,
|
|
253
|
+
paths,
|
|
254
|
+
repos,
|
|
255
|
+
components,
|
|
256
|
+
...(limit !== undefined ? { limit } : {}),
|
|
257
|
+
};
|
|
258
|
+
const shortlist = await request("POST", "/api/v1/rules/shortlist", shortlistBody, { signal });
|
|
259
|
+
const triggers = shortlist.data?.triggers ?? [];
|
|
260
|
+
const picked = await picker({
|
|
261
|
+
objective,
|
|
262
|
+
files: paths,
|
|
263
|
+
triggers: triggers.map((trigger) => ({
|
|
264
|
+
code: trigger.code,
|
|
265
|
+
trigger: trigger.sentence,
|
|
266
|
+
...(trigger.forcedBy ? { forcedBy: trigger.forcedBy } : {}),
|
|
267
|
+
})),
|
|
268
|
+
}, { signal });
|
|
269
|
+
const payload = await request("POST", `/api/v1/lanes/${encode(id)}/gather`, {
|
|
270
|
+
objective,
|
|
271
|
+
documentCodes: picked.documents,
|
|
272
|
+
paths,
|
|
273
|
+
components,
|
|
274
|
+
mode: flags.delta ? "delta" : "full",
|
|
275
|
+
...(sectionLimit !== undefined ? { sectionLimitPerDocument: sectionLimit } : {}),
|
|
276
|
+
}, { signal });
|
|
277
|
+
const figma = lane.freeze?.boundaries?.figma ?? (lane.launch?.figmaScopes ?? []).map((file) => ({
|
|
278
|
+
fileKey: file.fileKey,
|
|
279
|
+
...(file.componentSets?.length ? { componentSetKeys: file.componentSets } : {}),
|
|
280
|
+
}));
|
|
281
|
+
const issue = lane.issue ? { key: lane.issue.key, title: lane.issue.title } : null;
|
|
282
|
+
const laneNotes = [
|
|
283
|
+
lane.launch?.notes,
|
|
284
|
+
lane.launch?.narrowingNote && `Narrowing: ${lane.launch.narrowingNote}`,
|
|
285
|
+
lane.launch?.narrowingRemainder && `Narrowing remainder: ${lane.launch.narrowingRemainder}`,
|
|
286
|
+
].filter(Boolean);
|
|
287
|
+
const freezeRequest = (method, path, body, options = {}) => request(method, path, body, { ...options, signal });
|
|
288
|
+
await storeFreeze(id, {
|
|
289
|
+
gather: payload.data,
|
|
290
|
+
boundaries: { repos: repositories, ...(figma.length ? { figma } : {}) },
|
|
291
|
+
issue,
|
|
292
|
+
laneNotes,
|
|
293
|
+
doneCheck,
|
|
294
|
+
}, { request: freezeRequest });
|
|
295
|
+
const reasons = Object.fromEntries(triggers.map((trigger) => [
|
|
296
|
+
trigger.code,
|
|
297
|
+
Array.isArray(trigger.forcedBy) && trigger.forcedBy.length ? `forced by ${trigger.forcedBy.join(", ")}` : trigger.sentence,
|
|
298
|
+
]));
|
|
299
|
+
return { payload, human: (p) => gatherText(p, objective, reasons) };
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded its ${Math.round(gatherTimeoutMs / 1000)}-second deadline; nothing was retried.`, 1);
|
|
302
|
+
throw error;
|
|
303
|
+
} finally {
|
|
304
|
+
if (deadline) clearTimeout(deadline);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
151
307
|
if (action === "step") {
|
|
152
308
|
const selected = ["start", "done", "block"].filter((x) => flags[x]);
|
|
153
309
|
if (selected.length !== 1) throw new CliError("USAGE", "lane step needs exactly one of --start, --done, or --block.", 2);
|
|
@@ -303,21 +459,26 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
303
459
|
/**
|
|
304
460
|
* The files a lane touches, for path-tagged rules (M05-32): its branch and
|
|
305
461
|
* working-tree changes against origin/main, plus untracked files, read from
|
|
306
|
-
* its recorded worktree. Null when
|
|
307
|
-
*
|
|
462
|
+
* its recorded worktree. Null when any worktree cannot be read here; Gather
|
|
463
|
+
* refuses that unknown scope rather than silently omit path-triggered rules.
|
|
308
464
|
*/
|
|
309
|
-
export async function laneChangedPaths(lane) {
|
|
465
|
+
export async function laneChangedPaths(lane, { signal, exec = execFileAsync } = {}) {
|
|
310
466
|
try {
|
|
311
|
-
const repositories = lane.repositories?.length
|
|
467
|
+
const repositories = lane.repositories?.length
|
|
468
|
+
? lane.repositories
|
|
469
|
+
: lane.freeze?.boundaries?.repos?.length
|
|
470
|
+
? lane.freeze.boundaries.repos
|
|
471
|
+
: [{ repo: lane.repos?.[0], worktree: lane.worktree, baseRef: "origin/main" }];
|
|
312
472
|
const changed = await Promise.all(repositories.map(async (repository) => {
|
|
313
|
-
const run = async (...args) => (await
|
|
473
|
+
const run = async (...args) => (await exec("git", ["-C", repository.worktree, ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024, signal })).stdout;
|
|
314
474
|
const base = (await run("merge-base", repository.baseRef ?? "origin/main", "HEAD")).trim();
|
|
315
475
|
const tracked = (await run("diff", "--name-only", base)).split("\n");
|
|
316
476
|
const untracked = (await run("ls-files", "--others", "--exclude-standard")).split("\n");
|
|
317
477
|
return [...tracked, ...untracked].map((path) => path.trim()).filter(Boolean);
|
|
318
478
|
}));
|
|
319
|
-
return [...new Set(changed.flat())]
|
|
320
|
-
} catch {
|
|
479
|
+
return [...new Set(changed.flat())];
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (signal?.aborted) throw error;
|
|
321
482
|
return null;
|
|
322
483
|
}
|
|
323
484
|
}
|
package/dist/nodii-dev.js
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { fail, main } from "./cli.js";
|
|
3
|
+
import { regroundCurrentLane, shouldReground } from "./freeze.js";
|
|
3
4
|
|
|
4
5
|
// Hooks run in the agent's environment, which may be incomplete or may not be
|
|
5
6
|
// a git worktree. An unavailable hook must never turn into a refused tool call.
|
|
6
7
|
// Intentional policy denials are handled inside the hook command before they
|
|
7
8
|
// reach this boundary.
|
|
8
|
-
|
|
9
|
+
const argv = process.argv.slice(2);
|
|
10
|
+
|
|
11
|
+
async function run() {
|
|
12
|
+
// AGENT-HARNESS-19: this is the executable boundary, so every ordinary CLI
|
|
13
|
+
// invocation re-grounds without adding hidden requests to library callers.
|
|
14
|
+
if (shouldReground(argv)) await regroundCurrentLane();
|
|
15
|
+
await main(argv);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
run().catch((error) => {
|
|
9
19
|
const denial = String(error?.code ?? "").startsWith("SAFETY_") ||
|
|
10
20
|
["FIGMA_FILE_KEY_MISSING", "FIGMA_LOCK_DENIED", "WRITE_OUT_OF_SCOPE"].includes(error?.code);
|
|
11
|
-
if (
|
|
21
|
+
if (argv[0] === "hook" && !denial) {
|
|
12
22
|
process.exitCode = 0;
|
|
13
23
|
return;
|
|
14
24
|
}
|
package/dist/setup.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
3
3
|
import { CliError, checkLogin } from "./core.js";
|
|
4
4
|
import { codexHooksFromClaude } from "./harness.js";
|
|
5
5
|
import { regroundingHooksSnippet, safetySettingsSnippet } from "./safety.js";
|
|
6
|
+
import { PICKER_MODEL, setupOllama } from "./gather.js";
|
|
6
7
|
|
|
7
8
|
export const SKILL_PATH = ".claude/skills/nodii-build-workflow/SKILL.md";
|
|
8
9
|
const START = "<!-- nodii-dev build workflow:start -->";
|
|
@@ -118,21 +119,37 @@ export async function setupPlan(root = process.cwd()) {
|
|
|
118
119
|
return changes;
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
export async function setupCommand(flags, { root = process.cwd(), authCheck = checkLogin } = {}) {
|
|
122
|
+
export async function setupCommand(flags, { root = process.cwd(), authCheck = checkLogin, pickerSetup = setupOllama } = {}) {
|
|
123
|
+
if (flags.yes && flags.check) throw new CliError("USAGE", "setup accepts either --yes or --check, not both.", 2);
|
|
122
124
|
const changes = await setupPlan(root);
|
|
123
125
|
let authenticated = true;
|
|
124
126
|
try { await authCheck(); } catch { authenticated = false; }
|
|
127
|
+
let pickerReady = true;
|
|
128
|
+
let pickerStatus;
|
|
129
|
+
try { pickerStatus = await pickerSetup({ check: !flags.yes }); }
|
|
130
|
+
catch (error) {
|
|
131
|
+
pickerReady = false;
|
|
132
|
+
pickerStatus = { code: error?.code ?? "PICKER_UNAVAILABLE", message: error?.message ?? String(error) };
|
|
133
|
+
}
|
|
125
134
|
if (flags.check) {
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
const drift = [
|
|
136
|
+
...(changes.length ? [`Workflow harness drift: ${changes.map((x) => x.relative).join(", ")}`] : []),
|
|
137
|
+
...(!pickerReady ? [`Gather picker: ${pickerStatus.message}`] : []),
|
|
138
|
+
];
|
|
139
|
+
if (drift.length) throw new CliError("SETUP_DRIFT", drift.join("; "), 1);
|
|
140
|
+
return { payload: { ok: true, changed: [], authenticated, picker: pickerStatus }, human: authenticated ? "Workflow harness and Gather picker are current." : "Workflow harness and Gather picker are current. Not logged in; run `nodii-dev login`." };
|
|
128
141
|
}
|
|
129
142
|
if (!flags.yes) {
|
|
130
|
-
const preview =
|
|
131
|
-
|
|
143
|
+
const preview = [
|
|
144
|
+
...(changes.length ? changes.map((x) => `${x.before === null ? "+ create" : "~ update"} ${x.relative}`) : ["No workflow changes."]),
|
|
145
|
+
pickerReady ? `Gather picker: ${PICKER_MODEL} is ready.` : `+ install Ollama and pull ${PICKER_MODEL}`,
|
|
146
|
+
].join("\n");
|
|
147
|
+
return { payload: { dryRun: true, changed: [...changes.map((x) => x.relative), ...(!pickerReady ? [`ollama:${PICKER_MODEL}`] : [])], authenticated, picker: pickerStatus }, human: `${preview}\n${authenticated ? "Authentication: ready." : "Authentication: not logged in; run `nodii-dev login`."}\nRun \`nodii-dev setup --yes\` to apply.` };
|
|
132
148
|
}
|
|
149
|
+
if (!pickerReady) throw new CliError(pickerStatus.code, pickerStatus.message, 1);
|
|
133
150
|
for (const change of changes) {
|
|
134
151
|
await mkdir(dirname(change.path), { recursive: true });
|
|
135
152
|
await writeFile(change.path, change.content);
|
|
136
153
|
}
|
|
137
|
-
return { payload: { applied: true, changed: changes.map((x) => x.relative), authenticated }, human: `${changes.length ? `Applied: ${changes.map((x) => x.relative).join(", ")}` : "Workflow harness already current."}\n${authenticated ? "Authentication: ready." : "Not logged in; run `nodii-dev login`."}` };
|
|
154
|
+
return { payload: { applied: true, changed: [...changes.map((x) => x.relative), ...(pickerStatus.changed ?? []).map((x) => `ollama:${x}`)], authenticated, picker: pickerStatus }, human: `${changes.length ? `Applied: ${changes.map((x) => x.relative).join(", ")}` : "Workflow harness already current."}\nGather picker: ${PICKER_MODEL} ready on Ollama ${pickerStatus.version}.\n${authenticated ? "Authentication: ready." : "Not logged in; run `nodii-dev login`."}` };
|
|
138
155
|
}
|
package/dist/starting.js
CHANGED
|
@@ -4,12 +4,26 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
4
4
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { CliError, configPath, devRequest, encode, query, readConfig, requireFlag } from "./core.js";
|
|
7
|
-
import {
|
|
7
|
+
import { GATHER_TIMEOUT_MS } from "./gather.js";
|
|
8
|
+
import { laneCommand, nextCommand } from "./lanes.js";
|
|
8
9
|
|
|
9
10
|
const execFile = promisify(execFileCallback);
|
|
10
11
|
const via = { via: "nodii-dev" };
|
|
11
12
|
const values = (value) => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
12
13
|
|
|
14
|
+
const withSignal = (promise, signal) => {
|
|
15
|
+
if (!signal) return promise;
|
|
16
|
+
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
|
|
17
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
18
|
+
const abort = () => rejectPromise(signal.reason ?? new Error("aborted"));
|
|
19
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
20
|
+
Promise.resolve(promise).then(
|
|
21
|
+
(value) => { signal.removeEventListener("abort", abort); resolvePromise(value); },
|
|
22
|
+
(error) => { signal.removeEventListener("abort", abort); rejectPromise(error); },
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
|
|
13
27
|
function pairs(value, flag) {
|
|
14
28
|
const out = new Map();
|
|
15
29
|
for (const raw of values(value)) {
|
|
@@ -380,27 +394,42 @@ export async function amendmentGuard(argv, { request = devRequest, marker = curr
|
|
|
380
394
|
throw new CliError("LANE_AMENDMENT_ACK_REQUIRED", `Lane ${lane.laneKey} changed:\n${detail}\nDelta gather required: run nodii-dev lane gather ${lane.laneKey} --delta.`, 1, { amendments: pending.data });
|
|
381
395
|
}
|
|
382
396
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
397
|
+
const runGather = (key, flags, { request, signal }) => laneCommand("gather", [key], flags, { request, gatherSignal: signal });
|
|
398
|
+
|
|
399
|
+
export async function gatherLaneCommand(key, flags, { request = devRequest, marker = currentMarker, gather = runGather, acknowledge = nextCommand, gatherTimeoutMs = GATHER_TIMEOUT_MS } = {}) {
|
|
400
|
+
const controller = new AbortController();
|
|
401
|
+
const deadline = setTimeout(() => controller.abort(), gatherTimeoutMs);
|
|
402
|
+
const signal = controller.signal;
|
|
403
|
+
const timedRequest = (method, path, body, options = {}) => withSignal(request(method, path, body, { ...options, signal }), signal);
|
|
404
|
+
try {
|
|
405
|
+
const lane = await withSignal(marker(), signal);
|
|
406
|
+
key ??= lane?.laneKey;
|
|
407
|
+
if (!key) throw new CliError("USAGE", "lane gather needs <key> outside a launched lane worktree.", 2);
|
|
408
|
+
if (!flags.delta) return await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
409
|
+
if (lane && lane.laneKey !== key) throw new CliError("LANE_RUN_REQUIRED", "Delta gather for another lane cannot run inside this lane's registered worktree.", 1);
|
|
410
|
+
if (!lane) return await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
411
|
+
const pending = await timedRequest("GET", `/api/v1/lanes/${encode(key)}/amendments${query({ runId: lane.runId })}`);
|
|
412
|
+
if (!pending.data.length) {
|
|
413
|
+
const gathered = await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
414
|
+
return { ...gathered, human: `${typeof gathered.human === "string" ? gathered.human : "Gather complete."}\nNo pending amendments.` };
|
|
415
|
+
}
|
|
416
|
+
const version = Math.max(...pending.data.map((item) => item.version));
|
|
417
|
+
const gathered = await withSignal(gather(key, flags, { request: timedRequest, signal }), signal);
|
|
418
|
+
const acknowledgement = await withSignal(acknowledge(key, {
|
|
419
|
+
...flags,
|
|
420
|
+
deltaContext: { runId: lane.runId, amendmentVersion: version },
|
|
421
|
+
}, { request: timedRequest }), signal);
|
|
422
|
+
const acknowledgedVersion = acknowledgement.payload?.rules?.amendmentAcknowledgement?.version;
|
|
423
|
+
if (acknowledgedVersion !== version)
|
|
424
|
+
throw new CliError("LANE_AMENDMENT_ACK_FAILED", `Delta gather did not acknowledge amendment version ${version}; the lane remains blocked.`, 1);
|
|
425
|
+
return {
|
|
426
|
+
payload: { ...gathered.payload, rules: acknowledgement.payload?.rules, amendments: pending.data, acknowledgedVersion },
|
|
427
|
+
human: `${pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n")}\n${typeof gathered.human === "string" ? gathered.human : "Gather complete."}\nAcknowledged amendment version ${version}.`,
|
|
428
|
+
};
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded its ${Math.round(gatherTimeoutMs / 1000)}-second deadline; nothing was retried.`, 1);
|
|
431
|
+
throw error;
|
|
432
|
+
} finally {
|
|
433
|
+
clearTimeout(deadline);
|
|
393
434
|
}
|
|
394
|
-
const version = Math.max(...pending.data.map((item) => item.version));
|
|
395
|
-
const gathered = await gather(key, {
|
|
396
|
-
...flags,
|
|
397
|
-
deltaContext: { runId: lane.runId, amendmentVersion: version },
|
|
398
|
-
}, { request });
|
|
399
|
-
const acknowledgedVersion = gathered.payload?.rules?.amendmentAcknowledgement?.version;
|
|
400
|
-
if (acknowledgedVersion !== version)
|
|
401
|
-
throw new CliError("LANE_AMENDMENT_ACK_FAILED", `Delta gather did not acknowledge amendment version ${version}; the lane remains blocked.`, 1);
|
|
402
|
-
return {
|
|
403
|
-
payload: { ...gathered.payload, amendments: pending.data, acknowledgedVersion },
|
|
404
|
-
human: `${pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n")}\n${typeof gathered.human === "string" ? gathered.human : "Gather complete."}\nAcknowledged amendment version ${version}.`,
|
|
405
|
-
};
|
|
406
435
|
}
|
package/dist/upload.js
CHANGED
|
@@ -8,6 +8,7 @@ import { join, relative, resolve } from "node:path";
|
|
|
8
8
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
9
9
|
import { CliError, devRequest, devTextRequest, encode, query } from "./core.js";
|
|
10
10
|
import { CHANGE_CLASSES, validateDocument } from "./docs.js";
|
|
11
|
+
import { rulesFindText } from "./gather.js";
|
|
11
12
|
|
|
12
13
|
/** Exit code when Dev is waiting on the operator to acknowledge parked items. */
|
|
13
14
|
export const EXIT_PARKED = 4;
|
|
@@ -307,6 +308,15 @@ const pad = (v, n) => String(v).padStart(n);
|
|
|
307
308
|
* core, the delivery report, and the operator's core token cap.
|
|
308
309
|
*/
|
|
309
310
|
export async function rulesCommand(action, args, flags, { request = devRequest } = {}) {
|
|
311
|
+
if (action === "find") {
|
|
312
|
+
const question = args.join(" ").trim();
|
|
313
|
+
if (!question) throw new CliError("USAGE", 'rules find needs a question, for example: nodii-dev rules find "may this route use offset paging?"', 2);
|
|
314
|
+
if (question.length > 20_000) throw new CliError("USAGE", "rules find questions are at most 20,000 characters", 2);
|
|
315
|
+
const limit = flags.limit === undefined ? 10 : Number(flags.limit);
|
|
316
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new CliError("USAGE", "rules find --limit needs a whole number from 1 to 100", 2);
|
|
317
|
+
const payload = await request("GET", `/api/v1/rules/find${query({ question, limit })}`);
|
|
318
|
+
return { payload, human: rulesFindText };
|
|
319
|
+
}
|
|
310
320
|
if (action === "core") {
|
|
311
321
|
// Dev refuses a core over the cap with CORE_OVER_CAP; that error is the output.
|
|
312
322
|
const payload = await request("GET", "/api/v1/rules/core");
|
|
@@ -337,5 +347,5 @@ export async function rulesCommand(action, args, flags, { request = devRequest }
|
|
|
337
347
|
const payload = await request("PUT", "/api/v1/rules/core/cap", { cap });
|
|
338
348
|
return { payload, human: (p) => `Core cap: ${p.data.previous} -> ${p.data.cap} tokens.` };
|
|
339
349
|
}
|
|
340
|
-
throw new CliError("USAGE", "rules core | rules report [--section rulebook|services|all] [--lane KEY] | rules cap TOKENS
|
|
350
|
+
throw new CliError("USAGE", 'rules find "QUESTION" [--limit N] | rules core | rules report [--section rulebook|services|all] [--lane KEY] | rules cap TOKENS', 2);
|
|
341
351
|
}
|