@vincemakes/kiso-subagent-ext 0.28.0 → 0.30.0
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/dist/kiso-subagent.mjs +418 -43
- package/package.json +2 -2
package/dist/kiso-subagent.mjs
CHANGED
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { spawn, execFileSync } from "node:child_process";
|
|
22
|
-
import { randomBytes } from "node:crypto";
|
|
23
|
-
import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
23
|
+
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
24
24
|
import { homedir, tmpdir } from "node:os";
|
|
25
|
-
import { join } from "node:path";
|
|
25
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
26
27
|
|
|
27
28
|
/** Default per-child timeout (ms) — a subagent must never hang the parent. */
|
|
28
29
|
const TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -45,6 +46,18 @@ const DELEGATE_PARAMETERS = {
|
|
|
45
46
|
properties: {
|
|
46
47
|
role: { type: "string", enum: ROLES },
|
|
47
48
|
task: { type: "string", minLength: 1 },
|
|
49
|
+
// DT-1a: the contract's inputs. scope = allowed WRITE paths (globs,
|
|
50
|
+
// relative to the worktree) — a scoped task has NO shell tool;
|
|
51
|
+
// acceptance names a configured check ({ check }) or a parent-held
|
|
52
|
+
// evaluator ({ evaluator: absolute path outside the project }) —
|
|
53
|
+
// never a command; model names a configured profile; after names
|
|
54
|
+
// a completed implementer's childId (tester only): the tester runs
|
|
55
|
+
// in that worktree.
|
|
56
|
+
scope: { type: "array", items: { type: "string", minLength: 1 }, maxItems: 32 },
|
|
57
|
+
acceptance: { type: "object", properties: { check: { type: "string", minLength: 1 }, evaluator: { type: "string", minLength: 1 } }, additionalProperties: false },
|
|
58
|
+
model: { type: "string", minLength: 1 },
|
|
59
|
+
after: { type: "string", minLength: 1 },
|
|
60
|
+
timeoutMs: { type: "integer", minimum: 1000 },
|
|
48
61
|
},
|
|
49
62
|
required: ["role", "task"],
|
|
50
63
|
additionalProperties: false,
|
|
@@ -79,8 +92,20 @@ export default async function createSubagentExtension() {
|
|
|
79
92
|
// identity — ToolContext carries none — so two invocations never
|
|
80
93
|
// share a child session, and the result is located by identity.
|
|
81
94
|
const delegationId = randomBytes(12).toString("hex");
|
|
82
|
-
|
|
95
|
+
// DT-1a: the artifact dir is PARENT-configured and stays under
|
|
96
|
+
// KISO_HOME — never a per-task free path.
|
|
97
|
+
const home = process.env.KISO_HOME ?? join(homedir(), ".kiso");
|
|
98
|
+
const manifestDir = artifactDir(home, sessionsDir);
|
|
99
|
+
if (manifestDir === null) return { content: "delegate: refused — KISO_SUBAGENT_ARTIFACTS must lie under KISO_HOME", isError: true };
|
|
83
100
|
mkdirSync(manifestDir, { recursive: true });
|
|
101
|
+
// DT-1a: every task is validated BEFORE any child runs — a refusal
|
|
102
|
+
// spawns nothing (acceptance never carries a model-supplied command).
|
|
103
|
+
const cfg = delegationConfig();
|
|
104
|
+
const parentCwd = process.cwd();
|
|
105
|
+
for (let i = 0; i < tasks.length; i += 1) {
|
|
106
|
+
const why = validateTask(tasks[i], cfg, parentCwd, manifestDir);
|
|
107
|
+
if (why !== null) return { content: `delegate: refused — task ${i + 1}: ${why}`, isError: true };
|
|
108
|
+
}
|
|
84
109
|
const sections = await runLimited(tasks, CONCURRENCY, (task, i) =>
|
|
85
110
|
runChild({
|
|
86
111
|
childId: `sub-${parentId}-${delegationId}-${i + 1}-${task.role}`,
|
|
@@ -88,11 +113,16 @@ export default async function createSubagentExtension() {
|
|
|
88
113
|
manifest: { parentId, delegationId, index: i + 1, role: task.role, startedAt: Date.now() },
|
|
89
114
|
role: task.role,
|
|
90
115
|
task: task.task,
|
|
116
|
+
scope: task.scope,
|
|
117
|
+
acceptance: task.acceptance,
|
|
118
|
+
model: task.model,
|
|
119
|
+
after: task.after,
|
|
120
|
+
cfg,
|
|
91
121
|
sessionsDir,
|
|
92
122
|
bin,
|
|
93
|
-
timeout,
|
|
123
|
+
timeout: task.timeoutMs ?? timeout,
|
|
94
124
|
signal: ctx.signal,
|
|
95
|
-
parentCwd
|
|
125
|
+
parentCwd,
|
|
96
126
|
}),
|
|
97
127
|
);
|
|
98
128
|
// Partial success is not overall failure — only ALL failed
|
|
@@ -153,19 +183,32 @@ function runLimited(items, limit, fn) {
|
|
|
153
183
|
return Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)).then(() => results);
|
|
154
184
|
}
|
|
155
185
|
|
|
156
|
-
async function runChild({ childId, role, task, sessionsDir, bin, timeout, signal, parentCwd, manifestDir, manifest }) {
|
|
157
|
-
// implementer isolation: a detached git worktree; the child works inside
|
|
158
|
-
// it and its diff comes back. Non-git parents fail the task HONESTLY.
|
|
186
|
+
async function runChild({ childId, role, task, scope, acceptance, model, after, cfg, sessionsDir, bin, timeout, signal, parentCwd, manifestDir, manifest }) {
|
|
159
187
|
// CX-1 F6: the manifest binds this invocation to its child session
|
|
160
188
|
// BEFORE anything runs — the durable record of "which run is mine".
|
|
189
|
+
// DT-1a: the contract's inputs ride in it, verbatim.
|
|
190
|
+
const startedAt = manifest?.startedAt ?? Date.now();
|
|
161
191
|
if (manifestDir !== undefined) {
|
|
162
|
-
writeFileSync(join(manifestDir, `${childId}.json`), `${JSON.stringify({ ...manifest, childId })}\n`, "utf8");
|
|
192
|
+
writeFileSync(join(manifestDir, `${childId}.json`), `${JSON.stringify({ ...manifest, childId, task, ...(scope !== undefined ? { scope } : {}), ...(acceptance !== undefined ? { acceptance } : {}), ...(model !== undefined ? { model } : {}), ...(after !== undefined ? { after } : {}) })}\n`, "utf8");
|
|
163
193
|
}
|
|
194
|
+
// Isolation (DT-1a R2.2): implementer → its own detached worktree from
|
|
195
|
+
// the parent's HEAD (the parent's UNCOMMITTED changes are not visible —
|
|
196
|
+
// the section says so); tester → the implementer's kept worktree when
|
|
197
|
+
// `after` names one, else a fresh worktree from HEAD; explorer and
|
|
198
|
+
// reviewer → the parent's tree under a read-only policy. Non-git
|
|
199
|
+
// parents fail the task HONESTLY.
|
|
164
200
|
let worktree = null;
|
|
165
201
|
let baseRev = null;
|
|
166
202
|
let childCwd = parentCwd;
|
|
167
|
-
|
|
203
|
+
let ownsWorktree = false;
|
|
204
|
+
if (role === "tester" && after !== undefined) {
|
|
205
|
+
const prior = readResultFile(manifestDir, after);
|
|
206
|
+
worktree = prior.worktree;
|
|
207
|
+
baseRev = prior.baseRev;
|
|
208
|
+
childCwd = worktree;
|
|
209
|
+
} else if (role === "implementer" || role === "tester") {
|
|
168
210
|
worktree = mkdtempSync(join(tmpdir(), "kiso-subagent-wt-"));
|
|
211
|
+
ownsWorktree = true;
|
|
169
212
|
try {
|
|
170
213
|
execFileSync("git", ["-C", parentCwd, "worktree", "add", "--detach", worktree], { stdio: "ignore" });
|
|
171
214
|
// CX-1 F2: the base revision — a child that COMMITS is compared
|
|
@@ -174,57 +217,338 @@ async function runChild({ childId, role, task, sessionsDir, bin, timeout, signal
|
|
|
174
217
|
childCwd = worktree;
|
|
175
218
|
} catch (err) {
|
|
176
219
|
rmSync(worktree, { recursive: true, force: true });
|
|
177
|
-
return failSection(childId, role, task,
|
|
220
|
+
return failSection(childId, role, task, `${role} needs a git repository: ${msg(err)}`);
|
|
178
221
|
}
|
|
179
222
|
}
|
|
180
223
|
// The child-only role policy: one .mjs in its own temp extensions dir.
|
|
224
|
+
// DT-1a R2.1: a scoped task's policy denies shell outright and checks
|
|
225
|
+
// every write target against the globs after path normalization.
|
|
181
226
|
const policyDir = mkdtempSync(join(tmpdir(), "kiso-subagent-policy-"));
|
|
182
|
-
writeFileSync(join(policyDir, "policy.mjs"), rolePolicyContent(role), "utf8");
|
|
227
|
+
writeFileSync(join(policyDir, "policy.mjs"), rolePolicyContent(role, scope !== undefined ? { root: childCwd, globs: scope } : undefined), "utf8");
|
|
183
228
|
let keepWorktree = false;
|
|
184
229
|
try {
|
|
185
230
|
// CX-1 F5 (audit F5): the task travels as a FILE the child reads into
|
|
186
|
-
// exactly one user turn — never as stdin lines
|
|
187
|
-
//
|
|
231
|
+
// exactly one user turn — never as stdin lines. DT-1a: it ends with
|
|
232
|
+
// the fixed UNRESOLVED instruction the result parser reads back.
|
|
188
233
|
const taskPath = join(manifestDir ?? policyDir, `${childId}.task`);
|
|
189
|
-
writeFileSync(taskPath, task
|
|
190
|
-
const { code, stdout, killed } = await runProcess(childId, bin, childCwd, policyDir, taskPath, timeout, signal);
|
|
234
|
+
writeFileSync(taskPath, `${task}\n\n${UNRESOLVED_INSTRUCTION}\n`, "utf8");
|
|
235
|
+
const { code, stdout, killed } = await runProcess(childId, bin, childCwd, policyDir, taskPath, timeout, signal, model);
|
|
191
236
|
const extraction = await extractChildResult(sessionsDir, childId, `exit ${code}\n${stdout}`);
|
|
237
|
+
const status = killed === "timeout" ? "timeout" : killed === "abort" ? "killed" : code !== 0 && extraction.outcome === "missing" ? "spawn-failed" : extraction.outcome;
|
|
192
238
|
let failed = code !== 0 || killed !== null || extraction.failed;
|
|
193
|
-
|
|
239
|
+
const lines = [];
|
|
194
240
|
if (killed === "timeout") {
|
|
195
|
-
|
|
241
|
+
lines.push(` FAILED: timed out after ${timeout}ms (the child process group was killed)`);
|
|
196
242
|
} else if (killed === "abort") {
|
|
197
|
-
|
|
243
|
+
lines.push(" FAILED: aborted by the parent run (the child process group was killed)");
|
|
198
244
|
} else if (code !== 0) {
|
|
199
|
-
|
|
245
|
+
lines.push(` FAILED: the child exited with code ${code}\n${stdout}`);
|
|
200
246
|
} else if (extraction.failed) {
|
|
201
|
-
|
|
247
|
+
lines.push(` FAILED: ${extraction.reason}${extraction.diag !== "" ? `\n${extraction.diag}` : ""}`);
|
|
202
248
|
}
|
|
203
|
-
|
|
249
|
+
const unresolved = parseUnresolved(extraction.text);
|
|
250
|
+
// the collection (implementers only — a tester's worktree is the
|
|
251
|
+
// implementer's or a throwaway; its changes are not its result)
|
|
252
|
+
let changedFiles = null;
|
|
253
|
+
let patchPath = null;
|
|
254
|
+
let patchBytes = null;
|
|
255
|
+
let collection = null;
|
|
204
256
|
if (role === "implementer") {
|
|
205
257
|
// CX-1 F2 (audit F2): tri-state collection. `collected` is earned
|
|
206
258
|
// (the patch file closed AND git exited 0); a failure PRESERVES the
|
|
207
|
-
// worktree and says so; "consumed" is undefined
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (
|
|
259
|
+
// worktree and says so; "consumed" is undefined, so a worktree
|
|
260
|
+
// with changes is always kept and named.
|
|
261
|
+
patchPath = join(manifestDir ?? tmpdir(), `${childId}.patch`);
|
|
262
|
+
collection = await collectWorktree(worktree, baseRev, patchPath);
|
|
263
|
+
if (collection.kind === "collected") {
|
|
212
264
|
keepWorktree = true;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
text += `\n worktree kept at: ${worktree}`;
|
|
217
|
-
} else if (col.kind === "failed") {
|
|
265
|
+
changedFiles = collection.changedFiles;
|
|
266
|
+
patchBytes = collection.bytes;
|
|
267
|
+
} else if (collection.kind === "failed") {
|
|
218
268
|
keepWorktree = true;
|
|
219
269
|
failed = true;
|
|
220
|
-
|
|
270
|
+
} else {
|
|
271
|
+
changedFiles = [];
|
|
272
|
+
patchPath = null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// DT-1a R2.3: acceptance — the PARENT runs the named check or the
|
|
276
|
+
// evaluator in the worktree, only after a COMPLETED child, with the
|
|
277
|
+
// child's timeout, an output cap, and the parent's abort.
|
|
278
|
+
let verification = null;
|
|
279
|
+
if (acceptance !== undefined) {
|
|
280
|
+
if (status !== "completed") verification = { skipped: status };
|
|
281
|
+
else verification = await runAcceptance(acceptance, cfg, worktree ?? childCwd, baseRev, timeout, signal);
|
|
282
|
+
if (verification.passed === false) failed = true;
|
|
283
|
+
}
|
|
284
|
+
const endedAt = Date.now();
|
|
285
|
+
const result = {
|
|
286
|
+
identity: { ...manifest, childId, role, startedAt, endedAt },
|
|
287
|
+
status,
|
|
288
|
+
task,
|
|
289
|
+
...(scope !== undefined ? { scope } : {}),
|
|
290
|
+
...(acceptance !== undefined ? { acceptance } : {}),
|
|
291
|
+
...(model !== undefined ? { model } : {}),
|
|
292
|
+
...(after !== undefined ? { after } : {}),
|
|
293
|
+
worktree,
|
|
294
|
+
baseRev,
|
|
295
|
+
changedFiles,
|
|
296
|
+
patchPath,
|
|
297
|
+
patchBytes,
|
|
298
|
+
verification,
|
|
299
|
+
unresolved,
|
|
300
|
+
answer: extraction.text,
|
|
301
|
+
usage: extraction.usage,
|
|
302
|
+
toolCalls: extraction.toolCalls,
|
|
303
|
+
failed,
|
|
304
|
+
diag: failed ? extraction.diag : "",
|
|
305
|
+
};
|
|
306
|
+
if (manifestDir !== undefined) writeFileSync(join(manifestDir, `${childId}.result.json`), `${JSON.stringify(result)}\n`, "utf8");
|
|
307
|
+
// The section (what the model reads) — rendered from the result.
|
|
308
|
+
const verdict = verification === null ? "none" : verification.skipped !== undefined ? `SKIPPED (${verification.skipped})` : verification.passed ? "PASSED" : "FAILED";
|
|
309
|
+
let text = `[subagent] ${role}: ${task}\n status: ${status} · verification: ${verdict}${changedFiles !== null ? ` · files changed: ${changedFiles.length}` : ""} · tools: ${extraction.toolCalls}`;
|
|
310
|
+
if (scope !== undefined) text += `\n scoped: no shell · writes only under ${scope.join(", ")}`;
|
|
311
|
+
if (baseRev !== null) text += `\n child saw HEAD ${baseRev.slice(0, 7)}; the parent's uncommitted changes were not visible`;
|
|
312
|
+
if (verification !== null && verification.skipped === undefined) {
|
|
313
|
+
text += `\n verification: ${verification.kind} ${verification.passed ? "PASSED" : "FAILED"} · exit ${verification.exitCode === null ? "killed" : verification.exitCode} · ${verification.durationMs}ms · ${verification.kind === "check" ? verification.command : verification.evaluator}`;
|
|
314
|
+
if (!verification.passed && verification.tail !== "") text += `\n${verification.tail}`;
|
|
315
|
+
}
|
|
316
|
+
for (const l of lines) text += `\n${l}`;
|
|
317
|
+
if (extraction.text !== "") text += `\n${extraction.text}`;
|
|
318
|
+
text += unresolved === null ? "\n unresolved: not reported" : unresolved.length === 0 ? "\n unresolved: none" : `\n unresolved:\n${unresolved.map((u) => ` - ${u}`).join("\n")}`;
|
|
319
|
+
if (collection !== null) {
|
|
320
|
+
if (collection.kind === "collected") {
|
|
321
|
+
text += `\n diff:\n${collection.stat}\n patch: ${patchPath}`;
|
|
322
|
+
if (collection.bytes <= INLINE_PATCH_BYTES) text += `\n${readFileSync(patchPath, "utf8")}`;
|
|
323
|
+
else text += `\n (patch is ${collection.bytes} bytes — read it with the shell: cat ${patchPath}, or git -C ${worktree} diff ${baseRev})`;
|
|
324
|
+
text += `\n worktree kept at: ${worktree}`;
|
|
325
|
+
} else if (collection.kind === "failed") {
|
|
326
|
+
text += `\n FAILED: collecting the worktree's changes: ${collection.reason}${collection.partialPath !== undefined ? ` (partial patch at ${collection.partialPath})` : ""}\n worktree kept at: ${worktree}`;
|
|
221
327
|
}
|
|
222
328
|
}
|
|
223
329
|
return { failed, text, toolCalls: extraction.toolCalls };
|
|
224
330
|
} finally {
|
|
225
331
|
rmSync(policyDir, { recursive: true, force: true });
|
|
226
|
-
if (worktree !== null && !keepWorktree) removeWorktree(parentCwd, worktree);
|
|
332
|
+
if (worktree !== null && ownsWorktree && !keepWorktree) removeWorktree(parentCwd, worktree);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** DT-1a: the fixed trailer every task file ends with — the parser reads the section back. */
|
|
337
|
+
export const UNRESOLVED_INSTRUCTION = 'When you finish, end your reply with a section titled UNRESOLVED listing what you could not do or verify, one item per line starting with "- ", or the single word none.';
|
|
338
|
+
|
|
339
|
+
/** DT-1a: what a delegated task may NAME — the parent CLI hands the configured
|
|
340
|
+
* checks and model profiles through the environment. Absent = nothing configured. */
|
|
341
|
+
function delegationConfig() {
|
|
342
|
+
try {
|
|
343
|
+
const raw = process.env.KISO_DELEGATION_CONFIG_JSON;
|
|
344
|
+
const parsed = raw === undefined ? {} : JSON.parse(raw);
|
|
345
|
+
return { checks: parsed.checks ?? {}, profiles: parsed.profiles ?? [] };
|
|
346
|
+
} catch {
|
|
347
|
+
return { checks: {}, profiles: [] };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** DT-1a: the artifact dir — the default under the sessions dir, or the
|
|
352
|
+
* parent-configured KISO_SUBAGENT_ARTIFACTS, which must lie under KISO_HOME. */
|
|
353
|
+
function artifactDir(home, sessionsDir) {
|
|
354
|
+
const configured = process.env.KISO_SUBAGENT_ARTIFACTS;
|
|
355
|
+
if (configured === undefined || configured === "") return join(sessionsDir, "subagent");
|
|
356
|
+
const abs = resolve(configured);
|
|
357
|
+
const homeAbs = resolve(home);
|
|
358
|
+
return abs === homeAbs || abs.startsWith(homeAbs + sep) ? abs : null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** DT-1a: every task's inputs are judged BEFORE any child runs; a string is the refusal. */
|
|
362
|
+
export function validateTask(task, cfg, parentCwd, manifestDir) {
|
|
363
|
+
if (task === null || typeof task !== "object") return "a task must be an object";
|
|
364
|
+
if (!ROLES.includes(task.role)) return `unknown role ${JSON.stringify(task.role)}`;
|
|
365
|
+
if (typeof task.task !== "string" || task.task.trim() === "") return "task must be a non-empty string";
|
|
366
|
+
if (task.scope !== undefined) {
|
|
367
|
+
if (!Array.isArray(task.scope) || task.scope.length === 0 || task.scope.some((g) => typeof g !== "string" || g === "" || isAbsolute(g) || g.split("/").includes(".."))) return "scope must be a non-empty list of relative globs (no absolute paths, no ..)";
|
|
368
|
+
if (task.role !== "implementer" && task.role !== "tester") return "scope applies to implementer and tester tasks only";
|
|
369
|
+
}
|
|
370
|
+
if (task.acceptance !== undefined) {
|
|
371
|
+
const a = task.acceptance;
|
|
372
|
+
const keys = a !== null && typeof a === "object" ? Object.keys(a) : [];
|
|
373
|
+
const one = keys.length === 1 && (keys[0] === "check" || keys[0] === "evaluator") && typeof a[keys[0]] === "string";
|
|
374
|
+
if (!one) return "refused: acceptance must name a configured check ({ check }) or an evaluator path ({ evaluator }) — never a command";
|
|
375
|
+
if (keys[0] === "check" && !Object.prototype.hasOwnProperty.call(cfg.checks, a.check)) return `refused: unknown check ${JSON.stringify(a.check)} (configured: ${Object.keys(cfg.checks).join(", ") || "none"})`;
|
|
376
|
+
if (keys[0] === "evaluator") {
|
|
377
|
+
if (!isAbsolute(a.evaluator) || !existsSync(a.evaluator)) return `refused: evaluator must be an existing absolute path: ${a.evaluator}`;
|
|
378
|
+
const real = realpathSync(a.evaluator);
|
|
379
|
+
const project = realpathSync(parentCwd);
|
|
380
|
+
if (real === project || real.startsWith(project + sep)) return `refused: evaluator must live OUTSIDE the project (the child could reach it): ${a.evaluator}`;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (task.model !== undefined && !cfg.profiles.includes(task.model)) return `refused: unknown model profile ${JSON.stringify(task.model)} (configured: ${cfg.profiles.join(", ") || "none"})`;
|
|
384
|
+
if (task.after !== undefined) {
|
|
385
|
+
if (task.role !== "tester") return "refused: `after` is for tester tasks";
|
|
386
|
+
let prior;
|
|
387
|
+
try {
|
|
388
|
+
prior = readResultFile(manifestDir, task.after);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
return `refused: \`after\` names no completed implementer result: ${task.after} (${msg(err)})`;
|
|
391
|
+
}
|
|
392
|
+
if (prior.identity?.role !== "implementer" || prior.status !== "completed" || typeof prior.worktree !== "string" || !existsSync(prior.worktree)) return `refused: \`after\` must name a COMPLETED implementer whose worktree was kept: ${task.after}`;
|
|
393
|
+
}
|
|
394
|
+
if (task.timeoutMs !== undefined && (!Number.isInteger(task.timeoutMs) || task.timeoutMs < 1000)) return "timeoutMs must be an integer ≥ 1000";
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function readResultFile(manifestDir, childId) {
|
|
399
|
+
if (!/^[A-Za-z0-9_-]+$/.test(childId)) throw new Error("not a child id");
|
|
400
|
+
return JSON.parse(readFileSync(join(manifestDir, `${childId}.result.json`), "utf8"));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** DT-1a: `**` crosses directories, `*` stays inside one, `?` is one char; everything else is literal. */
|
|
404
|
+
export function globToRegExp(glob) {
|
|
405
|
+
let re = "^";
|
|
406
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
407
|
+
const c = glob[i];
|
|
408
|
+
if (c === "*") {
|
|
409
|
+
if (glob[i + 1] === "*") {
|
|
410
|
+
re += ".*";
|
|
411
|
+
i += 1;
|
|
412
|
+
if (glob[i + 1] === "/") i += 1;
|
|
413
|
+
} else re += "[^/]*";
|
|
414
|
+
} else if (c === "?") re += "[^/]";
|
|
415
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
416
|
+
}
|
|
417
|
+
return new RegExp(`${re}$`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** DT-1a: is `target` (relative to `root`, or absolute) inside `root` AND under one of the globs —
|
|
421
|
+
* after `..` normalization and symlink resolution of the deepest existing ancestor? */
|
|
422
|
+
export function pathInScope(root, target, globs) {
|
|
423
|
+
const rootReal = realpathSync(root);
|
|
424
|
+
const abs = resolve(root, target);
|
|
425
|
+
// resolve symlinks along the existing prefix, keep the rest verbatim
|
|
426
|
+
let existing = abs;
|
|
427
|
+
const rest = [];
|
|
428
|
+
while (!existsSync(existing)) {
|
|
429
|
+
const parent = dirname(existing);
|
|
430
|
+
if (parent === existing) break;
|
|
431
|
+
rest.unshift(existing.slice(parent.length + 1));
|
|
432
|
+
existing = parent;
|
|
433
|
+
}
|
|
434
|
+
let real;
|
|
435
|
+
try {
|
|
436
|
+
real = join(realpathSync(existing), ...rest);
|
|
437
|
+
} catch {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
if (!(real === rootReal || real.startsWith(rootReal + sep))) return false;
|
|
441
|
+
const rel = relative(rootReal, real).split(sep).join("/");
|
|
442
|
+
return globs.some((g) => globToRegExp(g).test(rel));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** DT-1a: the child's trailing UNRESOLVED section → items, [] for `none`, null when absent. */
|
|
446
|
+
export function parseUnresolved(text) {
|
|
447
|
+
const m = /(?:^|\n)\s*(?:#+\s*)?UNRESOLVED\s*:?\s*\n([\s\S]*)$/i.exec(text ?? "");
|
|
448
|
+
if (m === null) return null;
|
|
449
|
+
const body = m[1].trim();
|
|
450
|
+
if (body === "" || /^none\.?$/i.test(body)) return [];
|
|
451
|
+
return body
|
|
452
|
+
.split("\n")
|
|
453
|
+
.map((l) => l.replace(/^\s*[-*•]\s*/, "").trim())
|
|
454
|
+
.filter((l) => l !== "");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** DT-1a: git's machine formats → explicit entries (renames as { from }, binaries as { binary }). */
|
|
458
|
+
export function parseChangedFiles(numstat, nameStatus) {
|
|
459
|
+
const counts = new Map();
|
|
460
|
+
for (const line of (numstat ?? "").split("\n")) {
|
|
461
|
+
if (line.trim() === "") continue;
|
|
462
|
+
const [a, r, ...pathParts] = line.split("\t");
|
|
463
|
+
let path = pathParts.join("\t");
|
|
464
|
+
const brace = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(path);
|
|
465
|
+
if (brace !== null) path = `${brace[1]}${brace[3]}${brace[4]}`;
|
|
466
|
+
else if (path.includes(" => ")) path = path.split(" => ").pop();
|
|
467
|
+
counts.set(path, a === "-" ? null : { added: Number(a), removed: Number(r) });
|
|
468
|
+
}
|
|
469
|
+
const out = [];
|
|
470
|
+
for (const line of (nameStatus ?? "").split("\n")) {
|
|
471
|
+
if (line.trim() === "") continue;
|
|
472
|
+
const parts = line.split("\t");
|
|
473
|
+
const status = parts[0][0];
|
|
474
|
+
const path = status === "R" || status === "C" ? parts[2] : parts[1];
|
|
475
|
+
const c = counts.get(path);
|
|
476
|
+
const entry = { path, status };
|
|
477
|
+
if (status === "R" || status === "C") entry.from = parts[1];
|
|
478
|
+
if (c === null) entry.binary = true;
|
|
479
|
+
else if (c !== undefined) {
|
|
480
|
+
entry.added = c.added;
|
|
481
|
+
entry.removed = c.removed;
|
|
482
|
+
}
|
|
483
|
+
out.push(entry);
|
|
227
484
|
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const ACCEPTANCE_OUTPUT_CAP = 64 * 1024;
|
|
489
|
+
const ACCEPTANCE_TAIL = 2 * 1024;
|
|
490
|
+
|
|
491
|
+
/** DT-1a R2.3: the parent runs the acceptance in the worktree — a configured check
|
|
492
|
+
* through /bin/sh (user-authored), or the evaluator binary with the worktree as
|
|
493
|
+
* its argument. Own process group (the abort and the timeout kill it whole), the
|
|
494
|
+
* output capped, the tail kept. The exit code proves the command RAN on the tree
|
|
495
|
+
* as the child left it (patchSha256 names that state); only an evaluator proves
|
|
496
|
+
* correctness — the child can edit a check's tests. */
|
|
497
|
+
export async function runAcceptance(acceptance, cfg, worktree, baseRev, timeout, signal) {
|
|
498
|
+
const kind = acceptance.check !== undefined ? "check" : "evaluator";
|
|
499
|
+
const command = kind === "check" ? cfg.checks[acceptance.check] : acceptance.evaluator;
|
|
500
|
+
const started = Date.now();
|
|
501
|
+
let patchSha256 = createHash("sha256").update("").digest("hex");
|
|
502
|
+
try {
|
|
503
|
+
execFileSync("git", ["-C", worktree, "add", "-N", "."], { stdio: "ignore" });
|
|
504
|
+
const patch = execFileSync("git", ["-C", worktree, "diff", baseRev ?? "HEAD"], { maxBuffer: 256 * 1024 * 1024 });
|
|
505
|
+
patchSha256 = createHash("sha256").update(patch).digest("hex");
|
|
506
|
+
} catch {
|
|
507
|
+
// a non-git worktree: the state hash stays the empty one
|
|
508
|
+
}
|
|
509
|
+
const child = kind === "check" ? spawn("/bin/sh", ["-c", command], { cwd: worktree, detached: true, stdio: ["ignore", "pipe", "pipe"] }) : spawn(command, [worktree], { cwd: worktree, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
510
|
+
let output = "";
|
|
511
|
+
const capture = (d) => {
|
|
512
|
+
if (output.length < ACCEPTANCE_OUTPUT_CAP) output += String(d).slice(0, ACCEPTANCE_OUTPUT_CAP - output.length);
|
|
513
|
+
};
|
|
514
|
+
child.stdout.on("data", capture);
|
|
515
|
+
child.stderr.on("data", capture);
|
|
516
|
+
let killed = null;
|
|
517
|
+
const killGroup = () => {
|
|
518
|
+
try {
|
|
519
|
+
process.kill(-child.pid, "SIGKILL");
|
|
520
|
+
} catch {
|
|
521
|
+
// already gone
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
const timer = setTimeout(() => {
|
|
525
|
+
killed = "timeout";
|
|
526
|
+
killGroup();
|
|
527
|
+
}, timeout);
|
|
528
|
+
const onAbort = () => {
|
|
529
|
+
killed = "abort";
|
|
530
|
+
killGroup();
|
|
531
|
+
};
|
|
532
|
+
if (signal?.aborted) onAbort();
|
|
533
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
534
|
+
const exitCode = await new Promise((resolveExit) => {
|
|
535
|
+
child.on("error", () => resolveExit(null));
|
|
536
|
+
child.on("exit", (code) => resolveExit(code));
|
|
537
|
+
});
|
|
538
|
+
clearTimeout(timer);
|
|
539
|
+
signal?.removeEventListener("abort", onAbort);
|
|
540
|
+
const code = killed !== null ? null : exitCode;
|
|
541
|
+
return {
|
|
542
|
+
kind,
|
|
543
|
+
...(kind === "check" ? { name: acceptance.check, command } : { evaluator: command }),
|
|
544
|
+
exitCode: code,
|
|
545
|
+
passed: code === 0,
|
|
546
|
+
...(killed !== null ? { killed } : {}),
|
|
547
|
+
tail: output.slice(-ACCEPTANCE_TAIL),
|
|
548
|
+
durationMs: Date.now() - started,
|
|
549
|
+
patchSha256,
|
|
550
|
+
baseRev,
|
|
551
|
+
};
|
|
228
552
|
}
|
|
229
553
|
|
|
230
554
|
/**
|
|
@@ -239,9 +563,15 @@ async function runChild({ childId, role, task, sessionsDir, bin, timeout, signal
|
|
|
239
563
|
* spawn the human just approved in the ask tier, so the provider
|
|
240
564
|
* credentials the parent was trusted with ride along.
|
|
241
565
|
*/
|
|
242
|
-
function
|
|
566
|
+
export function childArgs(bin, childId, taskPath, model) {
|
|
567
|
+
// DT-1a: a configured profile name rides as the CLI's own --model flag
|
|
568
|
+
// (the flag beats everything; the child shares the parent's config).
|
|
569
|
+
return [bin, ...(model !== undefined ? ["--model", model] : []), "chat", childId, "--task-file", taskPath];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal, model) {
|
|
243
573
|
const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
|
|
244
|
-
const child = spawn(process.execPath,
|
|
574
|
+
const child = spawn(process.execPath, childArgs(bin, childId, taskPath, model), {
|
|
245
575
|
cwd,
|
|
246
576
|
env: {
|
|
247
577
|
...process.env,
|
|
@@ -296,14 +626,36 @@ function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal) {
|
|
|
296
626
|
/** The role policy: read-only for explorer/reviewer, the full six for
|
|
297
627
|
* implementer/tester. Only allow/deny — NEVER ask (a headless child cannot
|
|
298
628
|
* answer an approval prompt; ask would deadlock). */
|
|
299
|
-
export function rolePolicyContent(role) {
|
|
629
|
+
export function rolePolicyContent(role, scope) {
|
|
300
630
|
const allowed = role === "implementer" || role === "tester" ? SIX_TOOLS : READ_ONLY;
|
|
301
|
-
|
|
631
|
+
if (scope === undefined) {
|
|
632
|
+
return `export default { name: "subagent-${role}", approvals: [{
|
|
302
633
|
decide(call) {
|
|
303
634
|
if (${JSON.stringify(allowed)}.includes(call.name)) return { action: "allow" };
|
|
304
635
|
return { action: "deny", reason: "not allowed for the ${role} role" };
|
|
305
636
|
}
|
|
306
637
|
}] };
|
|
638
|
+
`;
|
|
639
|
+
}
|
|
640
|
+
// DT-1a R2.1: a scoped task has NO shell (a shell writes anywhere; the
|
|
641
|
+
// worktree is the only filesystem boundary a shell respects), and every
|
|
642
|
+
// write target is checked after normalization — the helper is imported
|
|
643
|
+
// from this very module by absolute URL, so the child runs the same code.
|
|
644
|
+
const self = new URL(import.meta.url).href;
|
|
645
|
+
return `import { pathInScope } from ${JSON.stringify(self)};
|
|
646
|
+
const ROOT = ${JSON.stringify(scope.root)};
|
|
647
|
+
const GLOBS = ${JSON.stringify(scope.globs)};
|
|
648
|
+
export default { name: "subagent-${role}-scoped", approvals: [{
|
|
649
|
+
decide(call) {
|
|
650
|
+
if (!${JSON.stringify(allowed)}.includes(call.name)) return { action: "deny", reason: "not allowed for the ${role} role" };
|
|
651
|
+
if (call.name === "shell") return { action: "deny", reason: "scoped task: no shell (writes are limited to " + GLOBS.join(", ") + "; run the task unscoped for a shell)" };
|
|
652
|
+
if (call.name === "write_file" || call.name === "edit_file") {
|
|
653
|
+
const target = String((call.input && call.input.path) ?? "");
|
|
654
|
+
if (!pathInScope(ROOT, target, GLOBS)) return { action: "deny", reason: "outside the task scope: " + target + " (allowed: " + GLOBS.join(", ") + ")" };
|
|
655
|
+
}
|
|
656
|
+
return { action: "allow" };
|
|
657
|
+
}
|
|
658
|
+
}] };
|
|
307
659
|
`;
|
|
308
660
|
}
|
|
309
661
|
|
|
@@ -334,7 +686,7 @@ export async function extractChildResult(sessionsDir, childId, diag) {
|
|
|
334
686
|
// never by position. More than one run is ambiguous, and reported.
|
|
335
687
|
const runIds = new Set(records.map((r) => r.runId).filter((id) => typeof id === "string"));
|
|
336
688
|
if (runIds.size > 1) {
|
|
337
|
-
return { outcome: "ambiguous", toolCalls: 0, text: "", failed: true, reason: `child session ${childId} holds ${runIds.size} runs — the result cannot be located by identity`, diag };
|
|
689
|
+
return { outcome: "ambiguous", toolCalls: 0, text: "", usage: NO_USAGE, failed: true, reason: `child session ${childId} holds ${runIds.size} runs — the result cannot be located by identity`, diag };
|
|
338
690
|
}
|
|
339
691
|
events = records.map((r) => r.event ?? r);
|
|
340
692
|
} catch (err) {
|
|
@@ -344,11 +696,11 @@ export async function extractChildResult(sessionsDir, childId, diag) {
|
|
|
344
696
|
if (events === null || !events.some((e) => e.type === "terminal")) await new Promise((r) => setTimeout(r, 200));
|
|
345
697
|
}
|
|
346
698
|
if (events === null) {
|
|
347
|
-
return { outcome: "missing", toolCalls: 0, text: "", failed: true, reason: `child session JSONL missing: ${msg(lastErr)}`, diag };
|
|
699
|
+
return { outcome: "missing", toolCalls: 0, text: "", usage: NO_USAGE, failed: true, reason: `child session JSONL missing: ${msg(lastErr)}`, diag };
|
|
348
700
|
}
|
|
349
701
|
const terminal = events.find((e) => e.type === "terminal");
|
|
350
702
|
if (terminal === undefined) {
|
|
351
|
-
return { outcome: "no-terminal", toolCalls: countToolCalls(events), text: finalText(events), failed: true, reason: "child session has no terminal", diag };
|
|
703
|
+
return { outcome: "no-terminal", toolCalls: countToolCalls(events), text: finalText(events), usage: usageOf(events), failed: true, reason: "child session has no terminal", diag };
|
|
352
704
|
}
|
|
353
705
|
const outcome = terminal.outcome?.kind ?? "unknown";
|
|
354
706
|
const toolCalls = countToolCalls(events);
|
|
@@ -357,6 +709,7 @@ export async function extractChildResult(sessionsDir, childId, diag) {
|
|
|
357
709
|
outcome,
|
|
358
710
|
toolCalls,
|
|
359
711
|
text,
|
|
712
|
+
usage: usageOf(events),
|
|
360
713
|
failed: outcome !== "completed",
|
|
361
714
|
reason: outcome === "completed" ? "" : `child ended with ${outcome}`,
|
|
362
715
|
diag: outcome === "completed" ? "" : diag,
|
|
@@ -379,6 +732,23 @@ function countToolCalls(events) {
|
|
|
379
732
|
return committed(events).filter((e) => e.type === "tool_call_end").length;
|
|
380
733
|
}
|
|
381
734
|
|
|
735
|
+
const NO_USAGE = { completedResponses: null, abandonedAttempts: null, inputTokens: null, outputTokens: null, cacheRead: null };
|
|
736
|
+
|
|
737
|
+
/** DT-1a R2.4: the cost of the delegation, honestly — responses that carried a
|
|
738
|
+
* usage event (never a "requests" count: a failed request leaves none), the
|
|
739
|
+
* abandoned attempts counted separately, token sums over the former only. */
|
|
740
|
+
function usageOf(events) {
|
|
741
|
+
const usages = committed(events).filter((e) => e.type === "usage");
|
|
742
|
+
const sum = (k) => usages.reduce((n, e) => n + (typeof e[k] === "number" ? e[k] : 0), 0);
|
|
743
|
+
return {
|
|
744
|
+
completedResponses: usages.length,
|
|
745
|
+
abandonedAttempts: events.filter((e) => e.type === "model_output_abandoned").length,
|
|
746
|
+
inputTokens: sum("inputTokens"),
|
|
747
|
+
outputTokens: sum("outputTokens"),
|
|
748
|
+
cacheRead: sum("cacheRead"),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
382
752
|
/** Projection-equivalent: the assistant text since the last flush boundary,
|
|
383
753
|
* over the COMMITTED events only. */
|
|
384
754
|
function finalText(events) {
|
|
@@ -410,6 +780,11 @@ async function collectWorktree(worktree, baseRev, patchPath) {
|
|
|
410
780
|
const base = baseRev ?? "HEAD";
|
|
411
781
|
stat = execFileSync("git", ["-C", worktree, "diff", "--stat", base], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
412
782
|
if (stat === "") return { kind: "unchanged" };
|
|
783
|
+
// DT-1a R2.4: the machine formats — never a parsed --stat
|
|
784
|
+
var changedFiles = parseChangedFiles(
|
|
785
|
+
execFileSync("git", ["-C", worktree, "diff", "--numstat", "-M", base], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }),
|
|
786
|
+
execFileSync("git", ["-C", worktree, "diff", "--name-status", "-M", base], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }),
|
|
787
|
+
);
|
|
413
788
|
} catch (err) {
|
|
414
789
|
return { kind: "failed", reason: `git diff --stat: ${msg(err)}` };
|
|
415
790
|
}
|
|
@@ -428,7 +803,7 @@ async function collectWorktree(worktree, baseRev, patchPath) {
|
|
|
428
803
|
closeSync(fd);
|
|
429
804
|
fd = null;
|
|
430
805
|
if (code.c !== 0) return { kind: "failed", reason: `git diff exited ${code.c}: ${code.stderr.trim()}`, partialPath: patchPath };
|
|
431
|
-
return { kind: "collected", stat, bytes: statSync(patchPath).size };
|
|
806
|
+
return { kind: "collected", stat, bytes: statSync(patchPath).size, changedFiles };
|
|
432
807
|
} catch (err) {
|
|
433
808
|
if (fd !== null) closeSync(fd);
|
|
434
809
|
return { kind: "failed", reason: `git diff: ${msg(err)}`, partialPath: patchPath };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-subagent-ext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "kiso official subagent extension — child kiso processes with role policies, kernel untouched",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"test": "vitest run"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@vincemakes/kiso-core": "0.
|
|
26
|
+
"@vincemakes/kiso-core": "0.30.0",
|
|
27
27
|
"@types/node": "^26.1.2",
|
|
28
28
|
"typescript": "^5.7.2",
|
|
29
29
|
"vitest": "^3.0.0"
|