@nodii/dev 0.6.28 → 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 +74 -1
- package/dist/api.js +4 -1
- package/dist/cli.js +49 -5
- package/dist/core.js +38 -15
- package/dist/devops-openapi.json +1 -1
- package/dist/doctor.js +19 -2
- package/dist/freeze.js +328 -0
- package/dist/gather.js +241 -0
- package/dist/hooks.js +25 -25
- package/dist/lanes.js +224 -17
- package/dist/nodii-dev.js +12 -2
- package/dist/runtime-launcher.js +270 -0
- package/dist/safety.js +19 -0
- package/dist/setup.js +25 -8
- package/dist/starting.js +435 -0
- package/dist/upload.js +11 -1
- package/dist/watchdog.js +308 -0
- package/package.json +3 -2
package/dist/lanes.js
CHANGED
|
@@ -2,10 +2,14 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./core.js";
|
|
5
|
+
import { launchRecovery } from "./runtime-launcher.js";
|
|
5
6
|
import { recordReviewReceipt, reviewCommitContext } from "./safety.js";
|
|
7
|
+
import { GATHER_TIMEOUT_MS, gatherText, pickDocuments } from "./gather.js";
|
|
8
|
+
import { storeGatherFreeze } from "./freeze.js";
|
|
6
9
|
|
|
7
10
|
const execFileAsync = promisify(execFile);
|
|
8
11
|
const via = { via: "nodii-dev" };
|
|
12
|
+
const GATHER_ACTIVE = new Set(["queued", "building"]);
|
|
9
13
|
// heartbeatLane's note limit.
|
|
10
14
|
const BLOCK_NOTE_MAX = 500;
|
|
11
15
|
|
|
@@ -106,14 +110,45 @@ const MAX_COMMITS = 200;
|
|
|
106
110
|
/** The lane's branch commits not on origin/main (as last fetched), from its recorded worktree. */
|
|
107
111
|
export async function branchCommits(lane) {
|
|
108
112
|
try {
|
|
109
|
-
const
|
|
110
|
-
|
|
113
|
+
const repositories = lane.repositories?.length ? lane.repositories : [{ worktree: lane.worktree, branch: lane.branch, baseRef: "origin/main" }];
|
|
114
|
+
const commits = await Promise.all(repositories.map(async (repository) => {
|
|
115
|
+
const out = (await execFileAsync("git", ["-C", repository.worktree, "rev-list", `${repository.baseRef ?? "origin/main"}..${repository.branch}`], { encoding: "utf8" })).stdout.trim();
|
|
116
|
+
return out ? out.split("\n") : [];
|
|
117
|
+
}));
|
|
118
|
+
return [...new Set(commits.flat())];
|
|
111
119
|
} catch {
|
|
112
|
-
throw new CliError("GIT_REQUIRED", `Cannot read
|
|
120
|
+
throw new CliError("GIT_REQUIRED", `Cannot read every repository branch for this lane. Pass --commits SHA[,SHA], or --branch dropped:REASON.`, 2);
|
|
113
121
|
}
|
|
114
122
|
}
|
|
115
123
|
|
|
116
|
-
|
|
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 } = {}) {
|
|
117
152
|
if (action === "list") {
|
|
118
153
|
const c = flags.mine ? await config() : null;
|
|
119
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 })}`);
|
|
@@ -137,12 +172,138 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
137
172
|
const payload = await request("POST", `/api/v1/lanes/${encode(key)}/start`, via);
|
|
138
173
|
return { payload, human: (p) => `Started ${laneLine(p.data)}` };
|
|
139
174
|
}
|
|
140
|
-
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>`);
|
|
141
176
|
const id = ref;
|
|
142
177
|
if (action === "show") {
|
|
143
178
|
const payload = await request("GET", `/api/v1/lanes/${encode(id)}`);
|
|
144
179
|
return { payload, human: (p) => detailText(p.data) };
|
|
145
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
|
+
}
|
|
146
307
|
if (action === "step") {
|
|
147
308
|
const selected = ["start", "done", "block"].filter((x) => flags[x]);
|
|
148
309
|
if (selected.length !== 1) throw new CliError("USAGE", "lane step needs exactly one of --start, --done, or --block.", 2);
|
|
@@ -194,6 +355,40 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
194
355
|
);
|
|
195
356
|
return { payload, human: `${ref}: subagent quota set to ${quota}.` };
|
|
196
357
|
}
|
|
358
|
+
if (action === "resume") {
|
|
359
|
+
const payload = await request(
|
|
360
|
+
"POST",
|
|
361
|
+
`/api/v1/lanes/${encode(id)}/resume`,
|
|
362
|
+
via,
|
|
363
|
+
);
|
|
364
|
+
let launched;
|
|
365
|
+
try {
|
|
366
|
+
launched = await launch(payload);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
const reason = `local recovery launch failed: ${error instanceof Error ? error.message : String(error)}`.slice(0, 2_000);
|
|
369
|
+
const runId = payload?.data?.run?.id;
|
|
370
|
+
if (runId) {
|
|
371
|
+
await request(
|
|
372
|
+
"POST",
|
|
373
|
+
`/api/v1/lanes/${encode(id)}/resume/${encode(runId)}/launch-failed`,
|
|
374
|
+
{ reason, ...via },
|
|
375
|
+
).catch(() => {});
|
|
376
|
+
}
|
|
377
|
+
throw new CliError("RECOVERY_LAUNCH_FAILED", reason, 1);
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
payload,
|
|
381
|
+
human: (response) => {
|
|
382
|
+
const runId = response.data.run.id;
|
|
383
|
+
const next = response.data.lane.next?.[0];
|
|
384
|
+
const checkpoint = response.data.recovery.lastPushedCommit;
|
|
385
|
+
const continuation = next
|
|
386
|
+
? `${next.side}/${next.step}`
|
|
387
|
+
: "the lane's next unfinished step";
|
|
388
|
+
return `${ref}: resumed fresh run ${runId} as PID ${launched.pid}; continuing ${continuation} from ${checkpoint}.`;
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
|
197
392
|
if (action === "land") return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/land`, via), human: `${ref}: recorded on main.` };
|
|
198
393
|
if (action === "review") {
|
|
199
394
|
const body = await reviewBody(flags, read);
|
|
@@ -264,17 +459,26 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
|
|
|
264
459
|
/**
|
|
265
460
|
* The files a lane touches, for path-tagged rules (M05-32): its branch and
|
|
266
461
|
* working-tree changes against origin/main, plus untracked files, read from
|
|
267
|
-
* its recorded worktree. Null when
|
|
268
|
-
*
|
|
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.
|
|
269
464
|
*/
|
|
270
|
-
export async function laneChangedPaths(lane) {
|
|
465
|
+
export async function laneChangedPaths(lane, { signal, exec = execFileAsync } = {}) {
|
|
271
466
|
try {
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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" }];
|
|
472
|
+
const changed = await Promise.all(repositories.map(async (repository) => {
|
|
473
|
+
const run = async (...args) => (await exec("git", ["-C", repository.worktree, ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024, signal })).stdout;
|
|
474
|
+
const base = (await run("merge-base", repository.baseRef ?? "origin/main", "HEAD")).trim();
|
|
475
|
+
const tracked = (await run("diff", "--name-only", base)).split("\n");
|
|
476
|
+
const untracked = (await run("ls-files", "--others", "--exclude-standard")).split("\n");
|
|
477
|
+
return [...tracked, ...untracked].map((path) => path.trim()).filter(Boolean);
|
|
478
|
+
}));
|
|
479
|
+
return [...new Set(changed.flat())];
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (signal?.aborted) throw error;
|
|
278
482
|
return null;
|
|
279
483
|
}
|
|
280
484
|
}
|
|
@@ -295,11 +499,14 @@ export async function nextCommand(ref, flags, { request = devRequest, changedPat
|
|
|
295
499
|
const data = steps.map((step) => ({ side: step.side, step: step.stepKey, label: step.label, state: step.state, command: `nodii-dev lane step ${lane.key} --side ${step.side} --${step.state === "current" ? "done" : "start"} ${step.stepKey}` }));
|
|
296
500
|
// M05-32: the clauses Dev serves for this step. A failure here never hides the next step.
|
|
297
501
|
let rules = null;
|
|
298
|
-
if (data.length) {
|
|
502
|
+
if (data.length || flags.deltaContext) {
|
|
299
503
|
const paths = flags.paths ? list(flags.paths) : await changedPaths(lane);
|
|
300
|
-
const body = { ...(paths ? { paths } : {}), ...(flags.components ? { components: list(flags.components) } : {}), ...(flags.role ? { role: String(flags.role) } : {}) };
|
|
504
|
+
const body = { ...(paths ? { paths } : {}), ...(flags.components ? { components: list(flags.components) } : {}), ...(flags.role ? { role: String(flags.role) } : {}), ...(flags.deltaContext && typeof flags.deltaContext === "object" ? { delta: flags.deltaContext } : {}) };
|
|
301
505
|
try { rules = (await request("POST", `/api/v1/lanes/${encode(lane.key)}/rules`, body)).data; }
|
|
302
|
-
catch (error) {
|
|
506
|
+
catch (error) {
|
|
507
|
+
if (flags.deltaContext) throw error;
|
|
508
|
+
rules = { error: error.code ?? error.message };
|
|
509
|
+
}
|
|
303
510
|
}
|
|
304
511
|
const human = data.length
|
|
305
512
|
? [...data.map((x) => `${x.side}: ${x.label}\n ${x.command}`), ...rulesLines(rules, lane.key)].join("\n")
|
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
|
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fsPromises from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { configPath } from "./core.js";
|
|
6
|
+
import { recordWatchedRun } from "./watchdog.js";
|
|
7
|
+
|
|
8
|
+
const RUNTIMES = new Set(["codex", "claude"]);
|
|
9
|
+
|
|
10
|
+
const requiredText = (value, name) => {
|
|
11
|
+
if (typeof value !== "string" || !value.trim())
|
|
12
|
+
throw new TypeError(`${name} must be a non-empty string`);
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const absolutePath = (value, name) => {
|
|
17
|
+
requiredText(value, name);
|
|
18
|
+
if (!isAbsolute(value) || resolve(value) !== value)
|
|
19
|
+
throw new TypeError(`${name} must be an absolute, normalized path`);
|
|
20
|
+
return value;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const printable = (value) => {
|
|
24
|
+
if (value === undefined || value === null || value === "") return "(none)";
|
|
25
|
+
if (typeof value === "string") return value;
|
|
26
|
+
return JSON.stringify(value, null, 2);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const dataFrom = (payload) => {
|
|
30
|
+
if (!payload || typeof payload !== "object" || !payload.data || typeof payload.data !== "object")
|
|
31
|
+
throw new TypeError("recovery payload must contain data");
|
|
32
|
+
return payload.data;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function validateData(data) {
|
|
36
|
+
const runId = requiredText(data?.run?.id, "run.id");
|
|
37
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(runId))
|
|
38
|
+
throw new TypeError("run.id is not a safe state-directory name");
|
|
39
|
+
const laneKey = requiredText(data?.lane?.key, "lane.key");
|
|
40
|
+
const runtime = requiredText(data?.launch?.runtime, "launch.runtime");
|
|
41
|
+
if (!RUNTIMES.has(runtime))
|
|
42
|
+
throw new TypeError("launch.runtime must be codex or claude");
|
|
43
|
+
const model = requiredText(data?.launch?.model, "launch.model");
|
|
44
|
+
const cwd = absolutePath(data?.launch?.cwd, "launch.cwd");
|
|
45
|
+
const addDirs = data?.launch?.addDirs ?? [];
|
|
46
|
+
if (!Array.isArray(addDirs)) throw new TypeError("launch.addDirs must be an array");
|
|
47
|
+
for (const [index, path] of addDirs.entries())
|
|
48
|
+
absolutePath(path, `launch.addDirs[${index}]`);
|
|
49
|
+
return { runId, laneKey, runtime, model, cwd, addDirs };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateLaunch(options) {
|
|
53
|
+
const runId = requiredText(options?.runId, "runId");
|
|
54
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(runId))
|
|
55
|
+
throw new TypeError("runId is not a safe state-directory name");
|
|
56
|
+
const laneKey = requiredText(options?.laneKey, "laneKey");
|
|
57
|
+
const runtime = requiredText(options?.runtime, "runtime");
|
|
58
|
+
if (!RUNTIMES.has(runtime)) throw new TypeError("runtime must be codex or claude");
|
|
59
|
+
const model = requiredText(options?.model, "model");
|
|
60
|
+
const cwd = absolutePath(options?.cwd, "cwd");
|
|
61
|
+
const devConfigPath = absolutePath(options?.devConfigPath, "devConfigPath");
|
|
62
|
+
const addDirs = options?.addDirs ?? [];
|
|
63
|
+
if (!Array.isArray(addDirs)) throw new TypeError("addDirs must be an array");
|
|
64
|
+
for (const [index, path] of addDirs.entries()) absolutePath(path, `addDirs[${index}]`);
|
|
65
|
+
const prompt = requiredText(options?.prompt, "prompt");
|
|
66
|
+
return { runId, laneKey, runtime, model, cwd, devConfigPath, addDirs, prompt, profileHome: options.profileHome };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function profileVariable(runtime, profileHome, home) {
|
|
70
|
+
if (profileHome === undefined || profileHome === null) return {};
|
|
71
|
+
absolutePath(profileHome, "launch.profileHome");
|
|
72
|
+
absolutePath(home, "home");
|
|
73
|
+
const child = relative(home, profileHome);
|
|
74
|
+
const expected = runtime === "codex" ? /^\.codex(?:[-_.][A-Za-z0-9._-]+)?$/ : /^\.claude(?:[-_.][A-Za-z0-9._-]+)?$/;
|
|
75
|
+
if (!child || child.startsWith("..") || isAbsolute(child) || child.includes("/") || !expected.test(basename(profileHome)))
|
|
76
|
+
throw new TypeError(`launch.profileHome must be a safe ${runtime} profile directory directly under home`);
|
|
77
|
+
return runtime === "codex"
|
|
78
|
+
? { CODEX_HOME: profileHome }
|
|
79
|
+
: { CLAUDE_CONFIG_DIR: profileHome };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Build the complete recovery message passed to the fresh runtime over stdin. */
|
|
83
|
+
export function buildRecoveryPrompt(data) {
|
|
84
|
+
const { runId, laneKey } = validateData(data);
|
|
85
|
+
return [
|
|
86
|
+
`You are lane ${laneKey}, relaunched as fresh run ${runId} after an orphaned process.`,
|
|
87
|
+
"Continue from the recorded next step. Do not redo finished lane steps.",
|
|
88
|
+
"",
|
|
89
|
+
`Next step:\n${printable(data.lane.next)}`,
|
|
90
|
+
"",
|
|
91
|
+
`Last pushed commit:\n${printable(data.recovery?.lastPushedCommit)}`,
|
|
92
|
+
"",
|
|
93
|
+
`Freeze:\n${printable(data.recovery?.freeze)}`,
|
|
94
|
+
"",
|
|
95
|
+
`Dead run log tail:\n${printable(data.recovery?.logTail)}`,
|
|
96
|
+
"",
|
|
97
|
+
].join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Build an allowlisted, fresh headless runtime invocation. No prompt is put in argv. */
|
|
101
|
+
export function buildRecoveryInvocation(data, { env = process.env, home = homedir() } = {}) {
|
|
102
|
+
const { runId, laneKey, runtime, model, cwd, addDirs } = validateData(data);
|
|
103
|
+
const devConfigPath = absolutePath(data?.launch?.devConfigPath, "launch.devConfigPath");
|
|
104
|
+
return buildInvocation({ runId, laneKey, runtime, model, cwd, addDirs, profileHome: data.launch.profileHome, devConfigPath }, { env, home });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildInvocation(options, { env = process.env, home = homedir() } = {}) {
|
|
108
|
+
const { runId, laneKey, runtime, model, cwd, addDirs, profileHome, devConfigPath } = options;
|
|
109
|
+
const args = runtime === "codex"
|
|
110
|
+
? ["exec", "--json", "--cd", cwd, "--model", model, ...addDirs.flatMap((path) => ["--add-dir", path]), "-"]
|
|
111
|
+
: ["--print", "--output-format", "stream-json", "--model", model, ...addDirs.flatMap((path) => ["--add-dir", path])];
|
|
112
|
+
return {
|
|
113
|
+
command: runtime,
|
|
114
|
+
args,
|
|
115
|
+
cwd,
|
|
116
|
+
env: {
|
|
117
|
+
...env,
|
|
118
|
+
NODII_DEV_RUN_ID: runId,
|
|
119
|
+
NODII_DEV_LANE_KEY: laneKey,
|
|
120
|
+
NODII_DEV_CONFIG_PATH: devConfigPath,
|
|
121
|
+
...profileVariable(runtime, profileHome, home),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const closeQuietly = async (handle) => {
|
|
127
|
+
try {
|
|
128
|
+
await handle?.close?.();
|
|
129
|
+
} catch {}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export function recoveryStateRoot(path = configPath()) {
|
|
133
|
+
const configDir = dirname(path);
|
|
134
|
+
const nodiiDir = basename(configDir) === "agents" ? dirname(configDir) : configDir;
|
|
135
|
+
return join(nodiiDir, "runs");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function launchDetached({ invocation, prompt, runId, laneKey, devConfigPath }, {
|
|
139
|
+
spawnImpl,
|
|
140
|
+
fs,
|
|
141
|
+
stateRoot,
|
|
142
|
+
watch,
|
|
143
|
+
terminate,
|
|
144
|
+
}) {
|
|
145
|
+
absolutePath(stateRoot, "stateRoot");
|
|
146
|
+
const runDir = join(stateRoot, runId);
|
|
147
|
+
const logPath = join(runDir, "process.log");
|
|
148
|
+
const pidPath = join(runDir, "process.pid");
|
|
149
|
+
|
|
150
|
+
await fs.mkdir(runDir, { recursive: true, mode: 0o700 });
|
|
151
|
+
const log = await fs.open(logPath, "a", 0o600);
|
|
152
|
+
try {
|
|
153
|
+
await fs.chmod(logPath, 0o600);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
await closeQuietly(log);
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let child;
|
|
160
|
+
try {
|
|
161
|
+
child = spawnImpl(invocation.command, invocation.args, {
|
|
162
|
+
cwd: invocation.cwd,
|
|
163
|
+
env: invocation.env,
|
|
164
|
+
shell: false,
|
|
165
|
+
detached: true,
|
|
166
|
+
stdio: ["pipe", log.fd, log.fd],
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
await closeQuietly(log);
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return new Promise((resolveLaunch, rejectLaunch) => {
|
|
174
|
+
let settled = false;
|
|
175
|
+
child.once("error", (error) => {
|
|
176
|
+
if (settled) return;
|
|
177
|
+
settled = true;
|
|
178
|
+
closeQuietly(log).finally(() => rejectLaunch(error));
|
|
179
|
+
});
|
|
180
|
+
child.once("spawn", () => {
|
|
181
|
+
if (settled) return;
|
|
182
|
+
settled = true;
|
|
183
|
+
(async () => {
|
|
184
|
+
if (!Number.isInteger(child.pid) || child.pid <= 0)
|
|
185
|
+
throw new Error("runtime process spawned without a PID");
|
|
186
|
+
await fs.writeFile(pidPath, `${child.pid}\n`, { mode: 0o600, flag: "w" });
|
|
187
|
+
try {
|
|
188
|
+
await watch({ runId, pid: child.pid, configPath: devConfigPath, worktree: invocation.cwd });
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// This exact child has already been recorded in its private PID file.
|
|
191
|
+
// Do not leave an untracked runtime alive when registry persistence fails.
|
|
192
|
+
await terminate(child);
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
if (!child.stdin || typeof child.stdin.end !== "function")
|
|
196
|
+
throw new Error("runtime process has no stdin pipe");
|
|
197
|
+
child.stdin.end(prompt);
|
|
198
|
+
await closeQuietly(log);
|
|
199
|
+
child.unref();
|
|
200
|
+
resolveLaunch({
|
|
201
|
+
runId,
|
|
202
|
+
laneKey,
|
|
203
|
+
pid: child.pid,
|
|
204
|
+
logPath,
|
|
205
|
+
pidPath,
|
|
206
|
+
command: invocation.command,
|
|
207
|
+
args: invocation.args,
|
|
208
|
+
cwd: invocation.cwd,
|
|
209
|
+
});
|
|
210
|
+
})().catch(async (error) => {
|
|
211
|
+
await closeQuietly(log);
|
|
212
|
+
rejectLaunch(error);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const terminateChild = async (child) => {
|
|
219
|
+
if (typeof child.kill === "function") child.kill("SIGTERM");
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** Launch a newly registered lane with its lane-scoped Dev identity. */
|
|
223
|
+
export async function launchRuntime(options, {
|
|
224
|
+
spawnImpl = spawn,
|
|
225
|
+
fs = fsPromises,
|
|
226
|
+
stateRoot,
|
|
227
|
+
home = homedir(),
|
|
228
|
+
env = process.env,
|
|
229
|
+
watch = recordWatchedRun,
|
|
230
|
+
terminate = terminateChild,
|
|
231
|
+
} = {}) {
|
|
232
|
+
const launch = validateLaunch(options);
|
|
233
|
+
const invocation = buildInvocation(launch, { env, home });
|
|
234
|
+
return launchDetached({ invocation, prompt: launch.prompt, ...launch }, {
|
|
235
|
+
spawnImpl,
|
|
236
|
+
fs,
|
|
237
|
+
stateRoot: stateRoot ?? recoveryStateRoot(launch.devConfigPath),
|
|
238
|
+
watch,
|
|
239
|
+
terminate,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Launch a fresh recovery process after Dev has reserved its run.
|
|
245
|
+
*
|
|
246
|
+
* Files live outside the repository under stateRoot/<run-id>. The log is
|
|
247
|
+
* opened append-only and both files are private to the current user.
|
|
248
|
+
*/
|
|
249
|
+
export async function launchRecovery(payload, {
|
|
250
|
+
spawnImpl = spawn,
|
|
251
|
+
fs = fsPromises,
|
|
252
|
+
stateRoot,
|
|
253
|
+
home = homedir(),
|
|
254
|
+
env = process.env,
|
|
255
|
+
watch = recordWatchedRun,
|
|
256
|
+
terminate = terminateChild,
|
|
257
|
+
} = {}) {
|
|
258
|
+
const data = dataFrom(payload);
|
|
259
|
+
const { runId, laneKey } = validateData(data);
|
|
260
|
+
const devConfigPath = absolutePath(data?.launch?.devConfigPath, "launch.devConfigPath");
|
|
261
|
+
const prompt = buildRecoveryPrompt(data);
|
|
262
|
+
const invocation = buildRecoveryInvocation(data, { env, home });
|
|
263
|
+
return launchDetached({ invocation, prompt, runId, laneKey, devConfigPath }, {
|
|
264
|
+
spawnImpl,
|
|
265
|
+
fs,
|
|
266
|
+
stateRoot: stateRoot ?? recoveryStateRoot(devConfigPath),
|
|
267
|
+
watch,
|
|
268
|
+
terminate,
|
|
269
|
+
});
|
|
270
|
+
}
|
package/dist/safety.js
CHANGED
|
@@ -280,6 +280,18 @@ async function harnessGenerated(root) {
|
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
function laneOwnership(lane, root) {
|
|
283
|
+
const repository = lane?.repositories?.find((item) => {
|
|
284
|
+
const worktree = resolve(item.worktree);
|
|
285
|
+
return worktree === root;
|
|
286
|
+
});
|
|
287
|
+
if (repository) {
|
|
288
|
+
const paths = repository.ownedPaths.map((item) => {
|
|
289
|
+
if (!isAbsolute(item)) return cleanPath(item);
|
|
290
|
+
const rel = cleanPath(relative(root, item));
|
|
291
|
+
return rel.startsWith("../") ? item : rel;
|
|
292
|
+
});
|
|
293
|
+
return { known: true, paths };
|
|
294
|
+
}
|
|
283
295
|
const direct = lane && Object.hasOwn(lane, "ownedPaths");
|
|
284
296
|
const frozen = lane?.freeze?.boundaries && Object.hasOwn(lane.freeze.boundaries, "ownedPaths");
|
|
285
297
|
const candidates = direct ? lane.ownedPaths : frozen ? lane.freeze.boundaries.ownedPaths : [];
|
|
@@ -298,6 +310,13 @@ async function currentLane(cwd, request, config) {
|
|
|
298
310
|
const branch = await git(cwd, "branch", "--show-current");
|
|
299
311
|
const rootText = await git(cwd, "rev-parse", "--show-toplevel");
|
|
300
312
|
const root = await realpath(rootText).catch(() => resolve(rootText));
|
|
313
|
+
const markerText = await optional(await gitPrivatePath(cwd, "nodii-lane.json"));
|
|
314
|
+
if (markerText) {
|
|
315
|
+
const marker = JSON.parse(markerText);
|
|
316
|
+
const lane = (await request("GET", `/api/v1/lanes/${encode(marker.laneKey)}`)).data;
|
|
317
|
+
const repository = lane.repositories?.find((item) => item.branch === branch && resolve(item.worktree) === root);
|
|
318
|
+
if (lane.agentId === config.agentId && repository && ACTIVE.has(lane.status)) return lane;
|
|
319
|
+
}
|
|
301
320
|
const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
|
|
302
321
|
for (const lane of lanes) {
|
|
303
322
|
if (lane.agentId !== config.agentId || lane.branch !== branch || !ACTIVE.has(lane.status)) continue;
|