@vincemakes/kiso-subagent-ext 0.29.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.
@@ -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
- const manifestDir = join(sessionsDir, "subagent");
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: process.cwd(),
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
- if (role === "implementer") {
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, `implementer needs a git repository: ${msg(err)}`);
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 (the non-TTY path is a
187
- // line-oriented readline: newlines were turns, `exit` ended input).
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, "utf8");
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
- let text = `[subagent] ${role}: ${task}\n outcome: ${extraction.outcome}\n tools: ${extraction.toolCalls}`;
239
+ const lines = [];
194
240
  if (killed === "timeout") {
195
- text += `\n FAILED: timed out after ${timeout}ms (the child process group was killed)`;
241
+ lines.push(` FAILED: timed out after ${timeout}ms (the child process group was killed)`);
196
242
  } else if (killed === "abort") {
197
- text += "\n FAILED: aborted by the parent run (the child process group was killed)";
243
+ lines.push(" FAILED: aborted by the parent run (the child process group was killed)");
198
244
  } else if (code !== 0) {
199
- text += `\n FAILED: the child exited with code ${code}\n${stdout}`;
245
+ lines.push(` FAILED: the child exited with code ${code}\n${stdout}`);
200
246
  } else if (extraction.failed) {
201
- text += `\n FAILED: ${extraction.reason}${extraction.diag !== "" ? `\n${extraction.diag}` : ""}`;
247
+ lines.push(` FAILED: ${extraction.reason}${extraction.diag !== "" ? `\n${extraction.diag}` : ""}`);
202
248
  }
203
- if (extraction.text !== "") text += `\n${extraction.text}`;
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 this batch, so a
208
- // worktree with changes is always kept and named.
209
- const patchPath = join(manifestDir ?? tmpdir(), `${childId}.patch`);
210
- const col = await collectWorktree(worktree, baseRev, patchPath);
211
- if (col.kind === "collected") {
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
- text += `\n diff:\n${col.stat}\n patch: ${patchPath}`;
214
- if (col.bytes <= INLINE_PATCH_BYTES) text += `\n${readFileSync(patchPath, "utf8")}`;
215
- else text += `\n (patch is ${col.bytes} bytes — read it with the shell: cat ${patchPath}, or git -C ${worktree} diff ${baseRev})`;
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
- text += `\n FAILED: collecting the worktree's changes: ${col.reason}${col.partialPath !== undefined ? ` (partial patch at ${col.partialPath})` : ""}\n worktree kept at: ${worktree}`;
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 runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal) {
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, [bin, "chat", childId, "--task-file", taskPath], {
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
- return `export default { name: "subagent-${role}", approvals: [{
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.29.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.29.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"