@vincemakes/kiso-subagent-ext 0.29.0 → 0.31.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;
@@ -33,7 +34,14 @@ const SIX_TOOLS = ["read_file", "list_dir", "search_text", "write_file", "edit_f
33
34
  const READ_ONLY = ["read_file", "list_dir", "search_text"];
34
35
  const ROLES = ["explorer", "implementer", "reviewer", "tester"];
35
36
 
36
- const DELEGATE_PARAMETERS = {
37
+ // DT1a-F2 (owner dogfood 2026-09-08): the model's first delegate call was
38
+ // refused twice — `scope` on an explorer, then an `acceptance` naming no
39
+ // configured check — because nothing in the schema said where scope applies
40
+ // or which checks exist. The field descriptions say so, and the configured
41
+ // check and profile names are written into the schema when the extension
42
+ // loads (the tool table is snapshotted per request — F7b — so this is the
43
+ // table the model reads).
44
+ const delegateParameters = (cfg) => ({
37
45
  type: "object",
38
46
  properties: {
39
47
  tasks: {
@@ -45,6 +53,28 @@ const DELEGATE_PARAMETERS = {
45
53
  properties: {
46
54
  role: { type: "string", enum: ROLES },
47
55
  task: { type: "string", minLength: 1 },
56
+ // DT-1a: the contract's inputs. scope = allowed WRITE paths (globs,
57
+ // relative to the worktree) — a scoped task has NO shell tool;
58
+ // acceptance names a configured check ({ check }) or a parent-held
59
+ // evaluator ({ evaluator: absolute path outside the project }) —
60
+ // never a command; model names a configured profile; after names
61
+ // a completed implementer's childId (tester only): the tester runs
62
+ // in that worktree.
63
+ scope: {
64
+ type: "array",
65
+ items: { type: "string", minLength: 1 },
66
+ maxItems: 32,
67
+ description: "implementer and tester tasks ONLY (an explorer or reviewer task with scope is refused): path globs the child may write; a scoped child has no shell and a write outside the scope is refused",
68
+ },
69
+ acceptance: {
70
+ type: "object",
71
+ properties: { check: { type: "string", minLength: 1 }, evaluator: { type: "string", minLength: 1 } },
72
+ additionalProperties: false,
73
+ description: `optional; implementer and tester only. Exactly one of: { check } naming a check configured by the user (configured now: ${Object.keys(cfg.checks).length ? Object.keys(cfg.checks).join(", ") : "none configured — omit acceptance"}), or { evaluator } — an absolute path to an evaluator script OUTSIDE the project. Never a command: the parent runs the acceptance after the child completes`,
74
+ },
75
+ model: { type: "string", minLength: 1, description: `a model profile configured by the user (configured now: ${cfg.profiles.length ? cfg.profiles.map((x) => (typeof x === "string" ? x : x.name ?? x.id ?? JSON.stringify(x))).join(", ") : "none — omit model"})` },
76
+ after: { type: "string", minLength: 1, description: "tester only: the earlier task (by its index, 1-based) whose worktree this tester runs in" },
77
+ timeoutMs: { type: "integer", minimum: 1000, description: "the child's wall-clock budget in milliseconds" },
48
78
  },
49
79
  required: ["role", "task"],
50
80
  additionalProperties: false,
@@ -53,7 +83,7 @@ const DELEGATE_PARAMETERS = {
53
83
  },
54
84
  required: ["tasks"],
55
85
  additionalProperties: false,
56
- };
86
+ });
57
87
 
58
88
  export default async function createSubagentExtension() {
59
89
  const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
@@ -64,10 +94,10 @@ export default async function createSubagentExtension() {
64
94
  {
65
95
  name: "delegate",
66
96
  description: "run subagent tasks (explorer/implementer/reviewer/tester) in child kiso processes",
67
- parameters: DELEGATE_PARAMETERS,
97
+ parameters: delegateParameters(delegationConfig()),
68
98
  execute: async (input, ctx) => {
69
99
  const tasks = ((input ?? {}).tasks ?? []).slice(0, 8);
70
- if (tasks.length === 0) return { content: "delegate: no tasks", isError: true };
100
+ if (tasks.length === 0) return { content: "delegate: no tasks", isError: true, errorKind: "precondition" };
71
101
  const sessionsDir = join(process.env.KISO_HOME ?? join(homedir(), ".kiso"), "sessions");
72
102
  // P3: the loop now threads the session id through
73
103
  // ToolContext.sessionId — the discovery heuristic below is
@@ -79,8 +109,24 @@ export default async function createSubagentExtension() {
79
109
  // identity — ToolContext carries none — so two invocations never
80
110
  // share a child session, and the result is located by identity.
81
111
  const delegationId = randomBytes(12).toString("hex");
82
- const manifestDir = join(sessionsDir, "subagent");
112
+ // DT-1a: the artifact dir is PARENT-configured and stays under
113
+ // KISO_HOME — never a per-task free path.
114
+ const home = process.env.KISO_HOME ?? join(homedir(), ".kiso");
115
+ const manifestDir = artifactDir(home, sessionsDir);
116
+ if (manifestDir === null) return { content: "delegate: refused — KISO_SUBAGENT_ARTIFACTS must lie under KISO_HOME", isError: true, errorKind: "precondition" };
83
117
  mkdirSync(manifestDir, { recursive: true });
118
+ // DT-1a: every task is validated BEFORE any child runs — a refusal
119
+ // spawns nothing (acceptance never carries a model-supplied command).
120
+ const cfg = delegationConfig();
121
+ const parentCwd = process.cwd();
122
+ for (let i = 0; i < tasks.length; i += 1) {
123
+ const why = validateTask(tasks[i], cfg, parentCwd, manifestDir);
124
+ // DT1a-F1 (owner dogfood 2026-09-08): a refusal BEFORE any child exists is a
125
+ // precondition — nothing ran, nothing could have partially applied — so the
126
+ // kernel must not append the non-idempotent "side effects may have partially
127
+ // applied" banner (loop.ts keys that banner on errorKind !== "precondition")
128
+ if (why !== null) return { content: `delegate: refused — task ${i + 1}: ${why}`, isError: true, errorKind: "precondition" };
129
+ }
84
130
  const sections = await runLimited(tasks, CONCURRENCY, (task, i) =>
85
131
  runChild({
86
132
  childId: `sub-${parentId}-${delegationId}-${i + 1}-${task.role}`,
@@ -88,11 +134,16 @@ export default async function createSubagentExtension() {
88
134
  manifest: { parentId, delegationId, index: i + 1, role: task.role, startedAt: Date.now() },
89
135
  role: task.role,
90
136
  task: task.task,
137
+ scope: task.scope,
138
+ acceptance: task.acceptance,
139
+ model: task.model,
140
+ after: task.after,
141
+ cfg,
91
142
  sessionsDir,
92
143
  bin,
93
- timeout,
144
+ timeout: task.timeoutMs ?? timeout,
94
145
  signal: ctx.signal,
95
- parentCwd: process.cwd(),
146
+ parentCwd,
96
147
  }),
97
148
  );
98
149
  // Partial success is not overall failure — only ALL failed
@@ -153,19 +204,32 @@ function runLimited(items, limit, fn) {
153
204
  return Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)).then(() => results);
154
205
  }
155
206
 
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.
207
+ async function runChild({ childId, role, task, scope, acceptance, model, after, cfg, sessionsDir, bin, timeout, signal, parentCwd, manifestDir, manifest }) {
159
208
  // CX-1 F6: the manifest binds this invocation to its child session
160
209
  // BEFORE anything runs — the durable record of "which run is mine".
210
+ // DT-1a: the contract's inputs ride in it, verbatim.
211
+ const startedAt = manifest?.startedAt ?? Date.now();
161
212
  if (manifestDir !== undefined) {
162
- writeFileSync(join(manifestDir, `${childId}.json`), `${JSON.stringify({ ...manifest, childId })}\n`, "utf8");
213
+ 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
214
  }
215
+ // Isolation (DT-1a R2.2): implementer → its own detached worktree from
216
+ // the parent's HEAD (the parent's UNCOMMITTED changes are not visible —
217
+ // the section says so); tester → the implementer's kept worktree when
218
+ // `after` names one, else a fresh worktree from HEAD; explorer and
219
+ // reviewer → the parent's tree under a read-only policy. Non-git
220
+ // parents fail the task HONESTLY.
164
221
  let worktree = null;
165
222
  let baseRev = null;
166
223
  let childCwd = parentCwd;
167
- if (role === "implementer") {
224
+ let ownsWorktree = false;
225
+ if (role === "tester" && after !== undefined) {
226
+ const prior = readResultFile(manifestDir, after);
227
+ worktree = prior.worktree;
228
+ baseRev = prior.baseRev;
229
+ childCwd = worktree;
230
+ } else if (role === "implementer" || role === "tester") {
168
231
  worktree = mkdtempSync(join(tmpdir(), "kiso-subagent-wt-"));
232
+ ownsWorktree = true;
169
233
  try {
170
234
  execFileSync("git", ["-C", parentCwd, "worktree", "add", "--detach", worktree], { stdio: "ignore" });
171
235
  // CX-1 F2: the base revision — a child that COMMITS is compared
@@ -174,57 +238,338 @@ async function runChild({ childId, role, task, sessionsDir, bin, timeout, signal
174
238
  childCwd = worktree;
175
239
  } catch (err) {
176
240
  rmSync(worktree, { recursive: true, force: true });
177
- return failSection(childId, role, task, `implementer needs a git repository: ${msg(err)}`);
241
+ return failSection(childId, role, task, `${role} needs a git repository: ${msg(err)}`);
178
242
  }
179
243
  }
180
244
  // The child-only role policy: one .mjs in its own temp extensions dir.
245
+ // DT-1a R2.1: a scoped task's policy denies shell outright and checks
246
+ // every write target against the globs after path normalization.
181
247
  const policyDir = mkdtempSync(join(tmpdir(), "kiso-subagent-policy-"));
182
- writeFileSync(join(policyDir, "policy.mjs"), rolePolicyContent(role), "utf8");
248
+ writeFileSync(join(policyDir, "policy.mjs"), rolePolicyContent(role, scope !== undefined ? { root: childCwd, globs: scope } : undefined), "utf8");
183
249
  let keepWorktree = false;
184
250
  try {
185
251
  // 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).
252
+ // exactly one user turn — never as stdin lines. DT-1a: it ends with
253
+ // the fixed UNRESOLVED instruction the result parser reads back.
188
254
  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);
255
+ writeFileSync(taskPath, `${task}\n\n${UNRESOLVED_INSTRUCTION}\n`, "utf8");
256
+ const { code, stdout, killed } = await runProcess(childId, bin, childCwd, policyDir, taskPath, timeout, signal, model);
191
257
  const extraction = await extractChildResult(sessionsDir, childId, `exit ${code}\n${stdout}`);
258
+ const status = killed === "timeout" ? "timeout" : killed === "abort" ? "killed" : code !== 0 && extraction.outcome === "missing" ? "spawn-failed" : extraction.outcome;
192
259
  let failed = code !== 0 || killed !== null || extraction.failed;
193
- let text = `[subagent] ${role}: ${task}\n outcome: ${extraction.outcome}\n tools: ${extraction.toolCalls}`;
260
+ const lines = [];
194
261
  if (killed === "timeout") {
195
- text += `\n FAILED: timed out after ${timeout}ms (the child process group was killed)`;
262
+ lines.push(` FAILED: timed out after ${timeout}ms (the child process group was killed)`);
196
263
  } else if (killed === "abort") {
197
- text += "\n FAILED: aborted by the parent run (the child process group was killed)";
264
+ lines.push(" FAILED: aborted by the parent run (the child process group was killed)");
198
265
  } else if (code !== 0) {
199
- text += `\n FAILED: the child exited with code ${code}\n${stdout}`;
266
+ lines.push(` FAILED: the child exited with code ${code}\n${stdout}`);
200
267
  } else if (extraction.failed) {
201
- text += `\n FAILED: ${extraction.reason}${extraction.diag !== "" ? `\n${extraction.diag}` : ""}`;
268
+ lines.push(` FAILED: ${extraction.reason}${extraction.diag !== "" ? `\n${extraction.diag}` : ""}`);
202
269
  }
203
- if (extraction.text !== "") text += `\n${extraction.text}`;
270
+ const unresolved = parseUnresolved(extraction.text);
271
+ // the collection (implementers only — a tester's worktree is the
272
+ // implementer's or a throwaway; its changes are not its result)
273
+ let changedFiles = null;
274
+ let patchPath = null;
275
+ let patchBytes = null;
276
+ let collection = null;
204
277
  if (role === "implementer") {
205
278
  // CX-1 F2 (audit F2): tri-state collection. `collected` is earned
206
279
  // (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") {
280
+ // worktree and says so; "consumed" is undefined, so a worktree
281
+ // with changes is always kept and named.
282
+ patchPath = join(manifestDir ?? tmpdir(), `${childId}.patch`);
283
+ collection = await collectWorktree(worktree, baseRev, patchPath);
284
+ if (collection.kind === "collected") {
212
285
  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") {
286
+ changedFiles = collection.changedFiles;
287
+ patchBytes = collection.bytes;
288
+ } else if (collection.kind === "failed") {
218
289
  keepWorktree = true;
219
290
  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}`;
291
+ } else {
292
+ changedFiles = [];
293
+ patchPath = null;
294
+ }
295
+ }
296
+ // DT-1a R2.3: acceptance — the PARENT runs the named check or the
297
+ // evaluator in the worktree, only after a COMPLETED child, with the
298
+ // child's timeout, an output cap, and the parent's abort.
299
+ let verification = null;
300
+ if (acceptance !== undefined) {
301
+ if (status !== "completed") verification = { skipped: status };
302
+ else verification = await runAcceptance(acceptance, cfg, worktree ?? childCwd, baseRev, timeout, signal);
303
+ if (verification.passed === false) failed = true;
304
+ }
305
+ const endedAt = Date.now();
306
+ const result = {
307
+ identity: { ...manifest, childId, role, startedAt, endedAt },
308
+ status,
309
+ task,
310
+ ...(scope !== undefined ? { scope } : {}),
311
+ ...(acceptance !== undefined ? { acceptance } : {}),
312
+ ...(model !== undefined ? { model } : {}),
313
+ ...(after !== undefined ? { after } : {}),
314
+ worktree,
315
+ baseRev,
316
+ changedFiles,
317
+ patchPath,
318
+ patchBytes,
319
+ verification,
320
+ unresolved,
321
+ answer: extraction.text,
322
+ usage: extraction.usage,
323
+ toolCalls: extraction.toolCalls,
324
+ failed,
325
+ diag: failed ? extraction.diag : "",
326
+ };
327
+ if (manifestDir !== undefined) writeFileSync(join(manifestDir, `${childId}.result.json`), `${JSON.stringify(result)}\n`, "utf8");
328
+ // The section (what the model reads) — rendered from the result.
329
+ const verdict = verification === null ? "none" : verification.skipped !== undefined ? `SKIPPED (${verification.skipped})` : verification.passed ? "PASSED" : "FAILED";
330
+ let text = `[subagent] ${role}: ${task}\n status: ${status} · verification: ${verdict}${changedFiles !== null ? ` · files changed: ${changedFiles.length}` : ""} · tools: ${extraction.toolCalls}`;
331
+ if (scope !== undefined) text += `\n scoped: no shell · writes only under ${scope.join(", ")}`;
332
+ if (baseRev !== null) text += `\n child saw HEAD ${baseRev.slice(0, 7)}; the parent's uncommitted changes were not visible`;
333
+ if (verification !== null && verification.skipped === undefined) {
334
+ 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}`;
335
+ if (!verification.passed && verification.tail !== "") text += `\n${verification.tail}`;
336
+ }
337
+ for (const l of lines) text += `\n${l}`;
338
+ if (extraction.text !== "") text += `\n${extraction.text}`;
339
+ text += unresolved === null ? "\n unresolved: not reported" : unresolved.length === 0 ? "\n unresolved: none" : `\n unresolved:\n${unresolved.map((u) => ` - ${u}`).join("\n")}`;
340
+ if (collection !== null) {
341
+ if (collection.kind === "collected") {
342
+ text += `\n diff:\n${collection.stat}\n patch: ${patchPath}`;
343
+ if (collection.bytes <= INLINE_PATCH_BYTES) text += `\n${readFileSync(patchPath, "utf8")}`;
344
+ else text += `\n (patch is ${collection.bytes} bytes — read it with the shell: cat ${patchPath}, or git -C ${worktree} diff ${baseRev})`;
345
+ text += `\n worktree kept at: ${worktree}`;
346
+ } else if (collection.kind === "failed") {
347
+ text += `\n FAILED: collecting the worktree's changes: ${collection.reason}${collection.partialPath !== undefined ? ` (partial patch at ${collection.partialPath})` : ""}\n worktree kept at: ${worktree}`;
221
348
  }
222
349
  }
223
350
  return { failed, text, toolCalls: extraction.toolCalls };
224
351
  } finally {
225
352
  rmSync(policyDir, { recursive: true, force: true });
226
- if (worktree !== null && !keepWorktree) removeWorktree(parentCwd, worktree);
353
+ if (worktree !== null && ownsWorktree && !keepWorktree) removeWorktree(parentCwd, worktree);
354
+ }
355
+ }
356
+
357
+ /** DT-1a: the fixed trailer every task file ends with — the parser reads the section back. */
358
+ 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.';
359
+
360
+ /** DT-1a: what a delegated task may NAME — the parent CLI hands the configured
361
+ * checks and model profiles through the environment. Absent = nothing configured. */
362
+ function delegationConfig() {
363
+ try {
364
+ const raw = process.env.KISO_DELEGATION_CONFIG_JSON;
365
+ const parsed = raw === undefined ? {} : JSON.parse(raw);
366
+ return { checks: parsed.checks ?? {}, profiles: parsed.profiles ?? [] };
367
+ } catch {
368
+ return { checks: {}, profiles: [] };
369
+ }
370
+ }
371
+
372
+ /** DT-1a: the artifact dir — the default under the sessions dir, or the
373
+ * parent-configured KISO_SUBAGENT_ARTIFACTS, which must lie under KISO_HOME. */
374
+ function artifactDir(home, sessionsDir) {
375
+ const configured = process.env.KISO_SUBAGENT_ARTIFACTS;
376
+ if (configured === undefined || configured === "") return join(sessionsDir, "subagent");
377
+ const abs = resolve(configured);
378
+ const homeAbs = resolve(home);
379
+ return abs === homeAbs || abs.startsWith(homeAbs + sep) ? abs : null;
380
+ }
381
+
382
+ /** DT-1a: every task's inputs are judged BEFORE any child runs; a string is the refusal. */
383
+ export function validateTask(task, cfg, parentCwd, manifestDir) {
384
+ if (task === null || typeof task !== "object") return "a task must be an object";
385
+ if (!ROLES.includes(task.role)) return `unknown role ${JSON.stringify(task.role)}`;
386
+ if (typeof task.task !== "string" || task.task.trim() === "") return "task must be a non-empty string";
387
+ if (task.scope !== undefined) {
388
+ 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 ..)";
389
+ if (task.role !== "implementer" && task.role !== "tester") return "scope applies to implementer and tester tasks only";
390
+ }
391
+ if (task.acceptance !== undefined) {
392
+ const a = task.acceptance;
393
+ const keys = a !== null && typeof a === "object" ? Object.keys(a) : [];
394
+ const one = keys.length === 1 && (keys[0] === "check" || keys[0] === "evaluator") && typeof a[keys[0]] === "string";
395
+ if (!one) return "refused: acceptance must name a configured check ({ check }) or an evaluator path ({ evaluator }) — never a command";
396
+ 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"})`;
397
+ if (keys[0] === "evaluator") {
398
+ if (!isAbsolute(a.evaluator) || !existsSync(a.evaluator)) return `refused: evaluator must be an existing absolute path: ${a.evaluator}`;
399
+ const real = realpathSync(a.evaluator);
400
+ const project = realpathSync(parentCwd);
401
+ if (real === project || real.startsWith(project + sep)) return `refused: evaluator must live OUTSIDE the project (the child could reach it): ${a.evaluator}`;
402
+ }
403
+ }
404
+ if (task.model !== undefined && !cfg.profiles.includes(task.model)) return `refused: unknown model profile ${JSON.stringify(task.model)} (configured: ${cfg.profiles.join(", ") || "none"})`;
405
+ if (task.after !== undefined) {
406
+ if (task.role !== "tester") return "refused: `after` is for tester tasks";
407
+ let prior;
408
+ try {
409
+ prior = readResultFile(manifestDir, task.after);
410
+ } catch (err) {
411
+ return `refused: \`after\` names no completed implementer result: ${task.after} (${msg(err)})`;
412
+ }
413
+ 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}`;
414
+ }
415
+ if (task.timeoutMs !== undefined && (!Number.isInteger(task.timeoutMs) || task.timeoutMs < 1000)) return "timeoutMs must be an integer ≥ 1000";
416
+ return null;
417
+ }
418
+
419
+ function readResultFile(manifestDir, childId) {
420
+ if (!/^[A-Za-z0-9_-]+$/.test(childId)) throw new Error("not a child id");
421
+ return JSON.parse(readFileSync(join(manifestDir, `${childId}.result.json`), "utf8"));
422
+ }
423
+
424
+ /** DT-1a: `**` crosses directories, `*` stays inside one, `?` is one char; everything else is literal. */
425
+ export function globToRegExp(glob) {
426
+ let re = "^";
427
+ for (let i = 0; i < glob.length; i += 1) {
428
+ const c = glob[i];
429
+ if (c === "*") {
430
+ if (glob[i + 1] === "*") {
431
+ re += ".*";
432
+ i += 1;
433
+ if (glob[i + 1] === "/") i += 1;
434
+ } else re += "[^/]*";
435
+ } else if (c === "?") re += "[^/]";
436
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
437
+ }
438
+ return new RegExp(`${re}$`);
439
+ }
440
+
441
+ /** DT-1a: is `target` (relative to `root`, or absolute) inside `root` AND under one of the globs —
442
+ * after `..` normalization and symlink resolution of the deepest existing ancestor? */
443
+ export function pathInScope(root, target, globs) {
444
+ const rootReal = realpathSync(root);
445
+ const abs = resolve(root, target);
446
+ // resolve symlinks along the existing prefix, keep the rest verbatim
447
+ let existing = abs;
448
+ const rest = [];
449
+ while (!existsSync(existing)) {
450
+ const parent = dirname(existing);
451
+ if (parent === existing) break;
452
+ rest.unshift(existing.slice(parent.length + 1));
453
+ existing = parent;
454
+ }
455
+ let real;
456
+ try {
457
+ real = join(realpathSync(existing), ...rest);
458
+ } catch {
459
+ return false;
460
+ }
461
+ if (!(real === rootReal || real.startsWith(rootReal + sep))) return false;
462
+ const rel = relative(rootReal, real).split(sep).join("/");
463
+ return globs.some((g) => globToRegExp(g).test(rel));
464
+ }
465
+
466
+ /** DT-1a: the child's trailing UNRESOLVED section → items, [] for `none`, null when absent. */
467
+ export function parseUnresolved(text) {
468
+ const m = /(?:^|\n)\s*(?:#+\s*)?UNRESOLVED\s*:?\s*\n([\s\S]*)$/i.exec(text ?? "");
469
+ if (m === null) return null;
470
+ const body = m[1].trim();
471
+ if (body === "" || /^none\.?$/i.test(body)) return [];
472
+ return body
473
+ .split("\n")
474
+ .map((l) => l.replace(/^\s*[-*•]\s*/, "").trim())
475
+ .filter((l) => l !== "");
476
+ }
477
+
478
+ /** DT-1a: git's machine formats → explicit entries (renames as { from }, binaries as { binary }). */
479
+ export function parseChangedFiles(numstat, nameStatus) {
480
+ const counts = new Map();
481
+ for (const line of (numstat ?? "").split("\n")) {
482
+ if (line.trim() === "") continue;
483
+ const [a, r, ...pathParts] = line.split("\t");
484
+ let path = pathParts.join("\t");
485
+ const brace = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(path);
486
+ if (brace !== null) path = `${brace[1]}${brace[3]}${brace[4]}`;
487
+ else if (path.includes(" => ")) path = path.split(" => ").pop();
488
+ counts.set(path, a === "-" ? null : { added: Number(a), removed: Number(r) });
489
+ }
490
+ const out = [];
491
+ for (const line of (nameStatus ?? "").split("\n")) {
492
+ if (line.trim() === "") continue;
493
+ const parts = line.split("\t");
494
+ const status = parts[0][0];
495
+ const path = status === "R" || status === "C" ? parts[2] : parts[1];
496
+ const c = counts.get(path);
497
+ const entry = { path, status };
498
+ if (status === "R" || status === "C") entry.from = parts[1];
499
+ if (c === null) entry.binary = true;
500
+ else if (c !== undefined) {
501
+ entry.added = c.added;
502
+ entry.removed = c.removed;
503
+ }
504
+ out.push(entry);
227
505
  }
506
+ return out;
507
+ }
508
+
509
+ const ACCEPTANCE_OUTPUT_CAP = 64 * 1024;
510
+ const ACCEPTANCE_TAIL = 2 * 1024;
511
+
512
+ /** DT-1a R2.3: the parent runs the acceptance in the worktree — a configured check
513
+ * through /bin/sh (user-authored), or the evaluator binary with the worktree as
514
+ * its argument. Own process group (the abort and the timeout kill it whole), the
515
+ * output capped, the tail kept. The exit code proves the command RAN on the tree
516
+ * as the child left it (patchSha256 names that state); only an evaluator proves
517
+ * correctness — the child can edit a check's tests. */
518
+ export async function runAcceptance(acceptance, cfg, worktree, baseRev, timeout, signal) {
519
+ const kind = acceptance.check !== undefined ? "check" : "evaluator";
520
+ const command = kind === "check" ? cfg.checks[acceptance.check] : acceptance.evaluator;
521
+ const started = Date.now();
522
+ let patchSha256 = createHash("sha256").update("").digest("hex");
523
+ try {
524
+ execFileSync("git", ["-C", worktree, "add", "-N", "."], { stdio: "ignore" });
525
+ const patch = execFileSync("git", ["-C", worktree, "diff", baseRev ?? "HEAD"], { maxBuffer: 256 * 1024 * 1024 });
526
+ patchSha256 = createHash("sha256").update(patch).digest("hex");
527
+ } catch {
528
+ // a non-git worktree: the state hash stays the empty one
529
+ }
530
+ 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"] });
531
+ let output = "";
532
+ const capture = (d) => {
533
+ if (output.length < ACCEPTANCE_OUTPUT_CAP) output += String(d).slice(0, ACCEPTANCE_OUTPUT_CAP - output.length);
534
+ };
535
+ child.stdout.on("data", capture);
536
+ child.stderr.on("data", capture);
537
+ let killed = null;
538
+ const killGroup = () => {
539
+ try {
540
+ process.kill(-child.pid, "SIGKILL");
541
+ } catch {
542
+ // already gone
543
+ }
544
+ };
545
+ const timer = setTimeout(() => {
546
+ killed = "timeout";
547
+ killGroup();
548
+ }, timeout);
549
+ const onAbort = () => {
550
+ killed = "abort";
551
+ killGroup();
552
+ };
553
+ if (signal?.aborted) onAbort();
554
+ else signal?.addEventListener("abort", onAbort, { once: true });
555
+ const exitCode = await new Promise((resolveExit) => {
556
+ child.on("error", () => resolveExit(null));
557
+ child.on("exit", (code) => resolveExit(code));
558
+ });
559
+ clearTimeout(timer);
560
+ signal?.removeEventListener("abort", onAbort);
561
+ const code = killed !== null ? null : exitCode;
562
+ return {
563
+ kind,
564
+ ...(kind === "check" ? { name: acceptance.check, command } : { evaluator: command }),
565
+ exitCode: code,
566
+ passed: code === 0,
567
+ ...(killed !== null ? { killed } : {}),
568
+ tail: output.slice(-ACCEPTANCE_TAIL),
569
+ durationMs: Date.now() - started,
570
+ patchSha256,
571
+ baseRev,
572
+ };
228
573
  }
229
574
 
230
575
  /**
@@ -239,9 +584,15 @@ async function runChild({ childId, role, task, sessionsDir, bin, timeout, signal
239
584
  * spawn the human just approved in the ask tier, so the provider
240
585
  * credentials the parent was trusted with ride along.
241
586
  */
242
- function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal) {
587
+ export function childArgs(bin, childId, taskPath, model) {
588
+ // DT-1a: a configured profile name rides as the CLI's own --model flag
589
+ // (the flag beats everything; the child shares the parent's config).
590
+ return [bin, ...(model !== undefined ? ["--model", model] : []), "chat", childId, "--task-file", taskPath];
591
+ }
592
+
593
+ function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal, model) {
243
594
  const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
244
- const child = spawn(process.execPath, [bin, "chat", childId, "--task-file", taskPath], {
595
+ const child = spawn(process.execPath, childArgs(bin, childId, taskPath, model), {
245
596
  cwd,
246
597
  env: {
247
598
  ...process.env,
@@ -296,14 +647,36 @@ function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal) {
296
647
  /** The role policy: read-only for explorer/reviewer, the full six for
297
648
  * implementer/tester. Only allow/deny — NEVER ask (a headless child cannot
298
649
  * answer an approval prompt; ask would deadlock). */
299
- export function rolePolicyContent(role) {
650
+ export function rolePolicyContent(role, scope) {
300
651
  const allowed = role === "implementer" || role === "tester" ? SIX_TOOLS : READ_ONLY;
301
- return `export default { name: "subagent-${role}", approvals: [{
652
+ if (scope === undefined) {
653
+ return `export default { name: "subagent-${role}", approvals: [{
302
654
  decide(call) {
303
655
  if (${JSON.stringify(allowed)}.includes(call.name)) return { action: "allow" };
304
656
  return { action: "deny", reason: "not allowed for the ${role} role" };
305
657
  }
306
658
  }] };
659
+ `;
660
+ }
661
+ // DT-1a R2.1: a scoped task has NO shell (a shell writes anywhere; the
662
+ // worktree is the only filesystem boundary a shell respects), and every
663
+ // write target is checked after normalization — the helper is imported
664
+ // from this very module by absolute URL, so the child runs the same code.
665
+ const self = new URL(import.meta.url).href;
666
+ return `import { pathInScope } from ${JSON.stringify(self)};
667
+ const ROOT = ${JSON.stringify(scope.root)};
668
+ const GLOBS = ${JSON.stringify(scope.globs)};
669
+ export default { name: "subagent-${role}-scoped", approvals: [{
670
+ decide(call) {
671
+ if (!${JSON.stringify(allowed)}.includes(call.name)) return { action: "deny", reason: "not allowed for the ${role} role" };
672
+ 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)" };
673
+ if (call.name === "write_file" || call.name === "edit_file") {
674
+ const target = String((call.input && call.input.path) ?? "");
675
+ if (!pathInScope(ROOT, target, GLOBS)) return { action: "deny", reason: "outside the task scope: " + target + " (allowed: " + GLOBS.join(", ") + ")" };
676
+ }
677
+ return { action: "allow" };
678
+ }
679
+ }] };
307
680
  `;
308
681
  }
309
682
 
@@ -334,7 +707,7 @@ export async function extractChildResult(sessionsDir, childId, diag) {
334
707
  // never by position. More than one run is ambiguous, and reported.
335
708
  const runIds = new Set(records.map((r) => r.runId).filter((id) => typeof id === "string"));
336
709
  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 };
710
+ 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
711
  }
339
712
  events = records.map((r) => r.event ?? r);
340
713
  } catch (err) {
@@ -344,11 +717,11 @@ export async function extractChildResult(sessionsDir, childId, diag) {
344
717
  if (events === null || !events.some((e) => e.type === "terminal")) await new Promise((r) => setTimeout(r, 200));
345
718
  }
346
719
  if (events === null) {
347
- return { outcome: "missing", toolCalls: 0, text: "", failed: true, reason: `child session JSONL missing: ${msg(lastErr)}`, diag };
720
+ return { outcome: "missing", toolCalls: 0, text: "", usage: NO_USAGE, failed: true, reason: `child session JSONL missing: ${msg(lastErr)}`, diag };
348
721
  }
349
722
  const terminal = events.find((e) => e.type === "terminal");
350
723
  if (terminal === undefined) {
351
- return { outcome: "no-terminal", toolCalls: countToolCalls(events), text: finalText(events), failed: true, reason: "child session has no terminal", diag };
724
+ return { outcome: "no-terminal", toolCalls: countToolCalls(events), text: finalText(events), usage: usageOf(events), failed: true, reason: "child session has no terminal", diag };
352
725
  }
353
726
  const outcome = terminal.outcome?.kind ?? "unknown";
354
727
  const toolCalls = countToolCalls(events);
@@ -357,6 +730,7 @@ export async function extractChildResult(sessionsDir, childId, diag) {
357
730
  outcome,
358
731
  toolCalls,
359
732
  text,
733
+ usage: usageOf(events),
360
734
  failed: outcome !== "completed",
361
735
  reason: outcome === "completed" ? "" : `child ended with ${outcome}`,
362
736
  diag: outcome === "completed" ? "" : diag,
@@ -379,6 +753,23 @@ function countToolCalls(events) {
379
753
  return committed(events).filter((e) => e.type === "tool_call_end").length;
380
754
  }
381
755
 
756
+ const NO_USAGE = { completedResponses: null, abandonedAttempts: null, inputTokens: null, outputTokens: null, cacheRead: null };
757
+
758
+ /** DT-1a R2.4: the cost of the delegation, honestly — responses that carried a
759
+ * usage event (never a "requests" count: a failed request leaves none), the
760
+ * abandoned attempts counted separately, token sums over the former only. */
761
+ function usageOf(events) {
762
+ const usages = committed(events).filter((e) => e.type === "usage");
763
+ const sum = (k) => usages.reduce((n, e) => n + (typeof e[k] === "number" ? e[k] : 0), 0);
764
+ return {
765
+ completedResponses: usages.length,
766
+ abandonedAttempts: events.filter((e) => e.type === "model_output_abandoned").length,
767
+ inputTokens: sum("inputTokens"),
768
+ outputTokens: sum("outputTokens"),
769
+ cacheRead: sum("cacheRead"),
770
+ };
771
+ }
772
+
382
773
  /** Projection-equivalent: the assistant text since the last flush boundary,
383
774
  * over the COMMITTED events only. */
384
775
  function finalText(events) {
@@ -410,6 +801,11 @@ async function collectWorktree(worktree, baseRev, patchPath) {
410
801
  const base = baseRev ?? "HEAD";
411
802
  stat = execFileSync("git", ["-C", worktree, "diff", "--stat", base], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).trim();
412
803
  if (stat === "") return { kind: "unchanged" };
804
+ // DT-1a R2.4: the machine formats — never a parsed --stat
805
+ var changedFiles = parseChangedFiles(
806
+ execFileSync("git", ["-C", worktree, "diff", "--numstat", "-M", base], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }),
807
+ execFileSync("git", ["-C", worktree, "diff", "--name-status", "-M", base], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }),
808
+ );
413
809
  } catch (err) {
414
810
  return { kind: "failed", reason: `git diff --stat: ${msg(err)}` };
415
811
  }
@@ -428,7 +824,7 @@ async function collectWorktree(worktree, baseRev, patchPath) {
428
824
  closeSync(fd);
429
825
  fd = null;
430
826
  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 };
827
+ return { kind: "collected", stat, bytes: statSync(patchPath).size, changedFiles };
432
828
  } catch (err) {
433
829
  if (fd !== null) closeSync(fd);
434
830
  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.31.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.31.0",
27
27
  "@types/node": "^26.1.2",
28
28
  "typescript": "^5.7.2",
29
29
  "vitest": "^3.0.0"