kc-beta 0.5.5 → 0.6.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.
Files changed (34) hide show
  1. package/QUICKSTART.md +17 -4
  2. package/README.md +58 -11
  3. package/bin/kc-beta.js +35 -1
  4. package/package.json +1 -1
  5. package/src/agent/bundle-tree.js +553 -0
  6. package/src/agent/context.js +40 -1
  7. package/src/agent/engine.js +644 -28
  8. package/src/agent/llm-client.js +67 -18
  9. package/src/agent/pipelines/finalization.js +186 -0
  10. package/src/agent/pipelines/index.js +8 -0
  11. package/src/agent/pipelines/initializer.js +40 -0
  12. package/src/agent/pipelines/skill-authoring.js +100 -6
  13. package/src/agent/skill-loader.js +54 -4
  14. package/src/agent/task-manager.js +66 -3
  15. package/src/agent/tools/agent-tool.js +283 -35
  16. package/src/agent/tools/bundle-search.js +146 -0
  17. package/src/agent/tools/document-chunk.js +246 -0
  18. package/src/agent/tools/document-classify.js +311 -0
  19. package/src/agent/tools/document-parse.js +8 -1
  20. package/src/agent/tools/phase-advance.js +30 -7
  21. package/src/agent/tools/registry.js +10 -0
  22. package/src/agent/tools/rule-catalog.js +17 -3
  23. package/src/agent/tools/sandbox-exec.js +30 -0
  24. package/src/agent/workspace.js +168 -14
  25. package/src/cli/components.js +165 -17
  26. package/src/cli/index.js +166 -19
  27. package/src/cli/meme.js +58 -0
  28. package/src/config.js +39 -2
  29. package/src/model-tiers.json +3 -2
  30. package/src/providers.js +34 -1
  31. package/template/skills/en/meta-meta/evolution-loop/SKILL.md +13 -1
  32. package/template/skills/en/meta-meta/rule-extraction/SKILL.md +74 -0
  33. package/template/skills/zh/meta-meta/evolution-loop/SKILL.md +7 -1
  34. package/template/skills/zh/meta-meta/rule-extraction/SKILL.md +73 -0
@@ -62,13 +62,65 @@ export class TaskManager {
62
62
  }
63
63
 
64
64
  /**
65
- * Get the next pending task.
65
+ * Get the next pending task (read-only). For serial-mode callers.
66
+ * Parallel workers MUST use claimNextPending() to avoid racing.
66
67
  * @returns {object|null}
67
68
  */
68
69
  getNextPending() {
69
70
  return this._tasks.find((t) => t.status === "pending") || null;
70
71
  }
71
72
 
73
+ /**
74
+ * B2: Atomically claim the next pending task — flips status to
75
+ * "in_progress" and records the worker. Single-threaded JavaScript
76
+ * means this is race-free WITHOUT a filesystem lock as long as neither
77
+ * the find nor the status mutation awaits, because the event loop
78
+ * won't interleave another worker's call between them. If we ever
79
+ * move TaskManager to share state across processes (unlikely; each
80
+ * session has its own file), wrap with workspace.withFileLock.
81
+ *
82
+ * @param {string} [workerLabel] - optional identifier for the claimer,
83
+ * stored on the task for debugging + the TUI taskboard.
84
+ * @returns {object|null} The claimed task, or null if none pending.
85
+ */
86
+ claimNextPending(workerLabel) {
87
+ const task = this._tasks.find((t) => t.status === "pending");
88
+ if (!task) return null;
89
+ task.status = "in_progress";
90
+ task.startedAt = new Date().toISOString();
91
+ if (workerLabel) task.worker = String(workerLabel);
92
+ this.save();
93
+ return task;
94
+ }
95
+
96
+ /**
97
+ * B2: Mark a previously-claimed task as done. Pass an optional
98
+ * summary for the taskboard / display. Worker label is cleared since
99
+ * the task has left in_progress state.
100
+ */
101
+ markDone(id, summary) {
102
+ const task = this._tasks.find((t) => t.id === id);
103
+ if (!task) return;
104
+ task.status = "completed";
105
+ task.completedAt = new Date().toISOString();
106
+ if (summary !== undefined) task.summary = summary;
107
+ delete task.worker;
108
+ this.save();
109
+ }
110
+
111
+ /**
112
+ * B2: Mark a claimed task as failed. Preserves the worker label so
113
+ * post-mortems can trace which slot crashed.
114
+ */
115
+ markFailed(id, errorMessage) {
116
+ const task = this._tasks.find((t) => t.id === id);
117
+ if (!task) return;
118
+ task.status = "failed";
119
+ task.completedAt = new Date().toISOString();
120
+ if (errorMessage) task.summary = String(errorMessage).slice(0, 500);
121
+ this.save();
122
+ }
123
+
72
124
  /**
73
125
  * Get all tasks.
74
126
  * @returns {Array}
@@ -87,12 +139,23 @@ export class TaskManager {
87
139
  // --- Bulk creation from rule catalog ---
88
140
 
89
141
  /**
90
- * Create one task per rule for a given phase.
91
- * Reads rules from the provided array (typically from rules/catalog.json).
142
+ * Phases where one-task-per-rule is the natural unit of work.
143
+ * For BOOTSTRAP / EXTRACTION the unit is a regulation (one PDF → many rules);
144
+ * ralph-loop shouldn't drive per-rule there because the rules don't exist yet
145
+ * (or are the *output*, not the input) — see E2E #3 coverage check.
146
+ */
147
+ static PER_RULE_PHASES = new Set(["skill_authoring", "skill_testing"]);
148
+
149
+ /**
150
+ * Create one task per rule for a given phase — but only if the phase's unit
151
+ * of work is actually a rule. For other phases this is a no-op, and any
152
+ * per-regulation tasks are created separately at session init.
153
+ *
92
154
  * @param {Array<{id: string, title?: string, description?: string}>} rules
93
155
  * @param {string} phase - The phase these tasks belong to
94
156
  */
95
157
  createRuleTasks(rules, phase) {
158
+ if (!TaskManager.PER_RULE_PHASES.has(phase)) return;
96
159
  for (const rule of rules) {
97
160
  const ruleId = rule.id || rule.rule_id;
98
161
  const title = rule.title || rule.description || ruleId;
@@ -11,18 +11,37 @@ function _newAutoTaskId() {
11
11
  return `task_${crypto.randomUUID().slice(0, 8)}`;
12
12
  }
13
13
 
14
+ const VALID_OPERATIONS = new Set(["spawn", "wait", "poll", "list", "kill"]);
15
+
14
16
  /**
15
- * Spawn a sub-agent for parallel work.
16
- * Creates a child AgentEngine that shares workspace files (rules/, rule_skills/,
17
- * workflows/, etc.) but isolates its own persistence under
18
- * `sub_agents/<taskId>/` its own conversation history, event log, and
19
- * session-state. Sub-agents inherit the parent's phase so they get the right
20
- * tools registered. Results arrive via files written under sub_agents/<taskId>/.
17
+ * Spawn + manage sub-agents.
18
+ *
19
+ * Operations (B8 expansion, 2026-04-23):
20
+ * spawn(default) start a new sub-agent. Fire-and-forget; status is
21
+ * written to sub_agents/<taskId>/status.txt. Returns the taskId
22
+ * immediately so callers can use wait/poll/kill later.
23
+ * wait — block until the sub-agent finishes (status.txt != "running")
24
+ * or timeout. Lets the parent confirm completion before acting on
25
+ * the output, rather than re-spawning a duplicate because it missed
26
+ * a status.txt flip (the classic Bug 9 pattern).
27
+ * poll — non-blocking status read. Cheap visibility.
28
+ * list — enumerate all sub-agents under sub_agents/ with their status,
29
+ * age, and running/complete flag. Makes recursive fan-out visible:
30
+ * a parent can see its child spawned 8 grandchildren without
31
+ * inferring it from scattered task_ids.
32
+ * kill — abort a running sub-agent via AbortController. Cooperative:
33
+ * the abort takes effect between LLM events, not mid-token. Does
34
+ * NOT SIGKILL sandbox_exec grandchildren — those exit on their own
35
+ * timeout. Subagent status.txt flips to "killed".
36
+ *
37
+ * Created for session 6304673afaa0's runaway scenario: 8 subagents
38
+ * concurrently rewriting catalog.json with no way for the main agent
39
+ * to stop them.
21
40
  */
22
41
  export class AgentTool extends BaseTool {
23
42
  /**
24
43
  * @param {import('../workspace.js').Workspace} workspace
25
- * @param {(opts: {sessionId: string, subagentScope: string, initialPhase: string}) => import('../engine.js').AgentEngine} engineFactory
44
+ * @param {(opts: {sessionId: string, subagentScope: string, initialPhase: string, abortSignal?: AbortSignal}) => import('../engine.js').AgentEngine} engineFactory
26
45
  * @param {() => string} getCurrentPhase Callback returning the parent's current phase (so sub-agents get phase-appropriate tools)
27
46
  */
28
47
  constructor(workspace, engineFactory, getCurrentPhase = () => "bootstrap") {
@@ -30,21 +49,21 @@ export class AgentTool extends BaseTool {
30
49
  this._workspace = workspace;
31
50
  this._engineFactory = engineFactory;
32
51
  this._getCurrentPhase = getCurrentPhase;
52
+ // Map<taskId, { promise, abortController, startedAt }>
33
53
  this._runningTasks = new Map();
34
54
  }
35
55
 
36
56
  get name() { return "agent_tool"; }
37
57
  get description() {
38
58
  return (
39
- "Spawn a sub-agent for an independent task. The sub-agent must own a " +
40
- "non-overlapping unit of work typically per-rule or per-document " +
41
- "so multiple sub-agents don't have to coordinate through shared mutable " +
42
- "files. Do NOT build a lock mechanism inside the sub-agent's task body; " +
43
- "concurrent peers + locks bottleneck and fail. The sub-agent's own " +
44
- "persistence (history, event log, session-state) lives under " +
45
- "sub_agents/<taskId>/; workspace artifacts (rules/, skills/, workflows/) " +
46
- "are shared. Give the sub-agent a complete, self-contained task " +
47
- "description — it has no conversation context."
59
+ "Spawn + manage sub-agents. operation=spawn (default) starts one; " +
60
+ "wait/poll checks status; list enumerates all; kill aborts a runaway. " +
61
+ "Sub-agents own non-overlapping units of work per-rule or per-document. " +
62
+ "Their persistence (history, events, state) lives under sub_agents/<taskId>/; " +
63
+ "workspace artifacts (rules/, skills/, workflows/) are shared. Give the " +
64
+ "sub-agent a complete self-contained brief it has no conversation context. " +
65
+ "Before re-spawning a task with a familiar id, use poll to check the existing " +
66
+ "one's status prevents duplicate work."
48
67
  );
49
68
  }
50
69
 
@@ -52,25 +71,94 @@ export class AgentTool extends BaseTool {
52
71
  return {
53
72
  type: "object",
54
73
  properties: {
74
+ operation: {
75
+ type: "string",
76
+ enum: ["spawn", "wait", "poll", "list", "kill"],
77
+ description: "spawn (default) | wait | poll | list | kill",
78
+ },
55
79
  task_description: {
56
80
  type: "string",
57
- description: "Complete task description for the sub-agent. Be specific — it has no conversation context.",
81
+ description: "(spawn) Complete task description for the sub-agent. Be specific — it has no conversation context.",
58
82
  },
59
83
  task_id: {
60
84
  type: "string",
61
- description: "Optional task identifier alphanumeric + _- only, max 64 chars. Used as a folder name under sub_agents/. If omitted or invalid, an auto-generated id is used.",
85
+ description: "(spawn: optional, auto-generated if invalid) | (wait/poll/kill: required) alphanumeric + _- only, max 64 chars.",
86
+ },
87
+ timeout_ms: {
88
+ type: "integer",
89
+ description: "(wait) Max time to wait in milliseconds. Default 30000 (30s).",
62
90
  },
63
91
  },
64
- required: ["task_description"],
65
92
  };
66
93
  }
67
94
 
68
95
  async execute(input) {
96
+ const op = (input.operation || "spawn").toLowerCase();
97
+ if (!VALID_OPERATIONS.has(op)) {
98
+ return new ToolResult(
99
+ `Unknown operation '${op}'. Valid: spawn, wait, poll, list, kill.`,
100
+ true,
101
+ );
102
+ }
103
+ if (op === "spawn") return this._spawn(input);
104
+ if (op === "list") return this._list();
105
+ if (op === "poll") return this._poll(input.task_id);
106
+ if (op === "wait") return this._wait(input.task_id, input.timeout_ms);
107
+ if (op === "kill") return this._kill(input.task_id);
108
+ return new ToolResult(`Not implemented: ${op}`, true);
109
+ }
110
+
111
+ // ---- spawn ----
112
+
113
+ /**
114
+ * H3: Dispatch-completeness linter. When the task_description starts by
115
+ * declaring it covers N items — "从以下核心法规中" / "法规1...法规2..."
116
+ * or "items 1 through 5" — check whether the body actually lists that
117
+ * many distinct items. Doesn't block spawn; prepends a warning to the
118
+ * tool result so the composing LLM sees the discrepancy on the next
119
+ * turn and can self-correct.
120
+ *
121
+ * Motivated by session 6304673afaa0: main agent wrote a brief titled
122
+ * "从以下核心法规中提取" (plural) but only listed 法规1. Reg 02 was
123
+ * silently dropped — no automation caught it until the rules were
124
+ * already half-extracted.
125
+ */
126
+ _lintBriefCompleteness(taskDesc) {
127
+ const issues = [];
128
+ // Chinese enumerated: 法规1、法规2... / 文件1、文件2... / 任务1... etc
129
+ const zhEnumMatches = Array.from(taskDesc.matchAll(/(?:法规|文件|任务|步骤|规则组|项目)(\d+)/g))
130
+ .map((m) => parseInt(m[1], 10))
131
+ .filter((n) => Number.isFinite(n));
132
+ if (zhEnumMatches.length > 0) {
133
+ const maxN = Math.max(...zhEnumMatches);
134
+ const uniqueN = new Set(zhEnumMatches).size;
135
+ if (maxN > uniqueN) {
136
+ issues.push(
137
+ `Brief references items up to ${maxN} but only ${uniqueN} distinct item numbers appear — ` +
138
+ `possible dropped item (e.g. saying "法规1, 法规3" without listing 法规2).`,
139
+ );
140
+ }
141
+ }
142
+ // English enumerated: "item 1", "step 2" etc, or bullet list mismatch
143
+ const enEnumMatches = Array.from(taskDesc.matchAll(/\b(?:item|step|task|regulation|file)\s+(\d+)/gi))
144
+ .map((m) => parseInt(m[1], 10))
145
+ .filter((n) => Number.isFinite(n));
146
+ if (enEnumMatches.length > 0) {
147
+ const maxN = Math.max(...enEnumMatches);
148
+ const uniqueN = new Set(enEnumMatches).size;
149
+ if (maxN > uniqueN) {
150
+ issues.push(
151
+ `Brief references items up to ${maxN} but only ${uniqueN} distinct item numbers appear — ` +
152
+ `possible dropped item.`,
153
+ );
154
+ }
155
+ }
156
+ return issues;
157
+ }
158
+
159
+ async _spawn(input) {
69
160
  const taskDesc = input.task_description || "";
70
161
  const requestedId = (input.task_id || "").trim();
71
- // Sanitize: anything not matching VALID_TASK_ID is silently replaced with
72
- // an auto-generated id. The label survives in result metadata so KC can
73
- // still cross-reference, but the path component is always safe.
74
162
  const taskId = requestedId && VALID_TASK_ID.test(requestedId)
75
163
  ? requestedId
76
164
  : _newAutoTaskId();
@@ -78,33 +166,58 @@ export class AgentTool extends BaseTool {
78
166
 
79
167
  if (!taskDesc) return new ToolResult("No task_description provided", true);
80
168
 
81
- // Create sub-agent output directory (taskId is now sanitized)
169
+ // B8: reject re-spawn of an id that's currently alive. Prevents Bug 9
170
+ // double-dispatch when the caller didn't poll first. Caller can kill
171
+ // the existing one explicitly if they want to replace it.
172
+ if (this._runningTasks.has(taskId)) {
173
+ return new ToolResult(
174
+ `Sub-agent '${taskId}' is still running. Use poll to check status, wait to block for completion, or kill to abort before re-spawning.`,
175
+ true,
176
+ );
177
+ }
178
+
82
179
  const taskDir = path.join(this._workspace.cwd, "sub_agents", taskId);
83
180
  fs.mkdirSync(taskDir, { recursive: true });
84
181
  fs.writeFileSync(path.join(taskDir, "task.md"), taskDesc, "utf-8");
182
+ fs.writeFileSync(path.join(taskDir, "status.txt"), "running", "utf-8");
85
183
  if (labelOverridden) {
86
184
  fs.writeFileSync(path.join(taskDir, "requested_id.txt"), requestedId, "utf-8");
87
185
  }
88
186
 
89
- // Create child engine. Critical: pass subagentScope + initialPhase so the
90
- // child's persistence is isolated to sub_agents/<taskId>/ AND it has the
91
- // same tools registered as the parent (Bug 2 fix).
187
+ const abortController = new AbortController();
188
+
92
189
  let childEngine;
93
190
  try {
94
191
  childEngine = this._engineFactory({
95
192
  sessionId: this._workspace.sessionId,
96
193
  subagentScope: taskId,
97
194
  initialPhase: this._getCurrentPhase(),
195
+ abortSignal: abortController.signal, // forward-compatible; engine may use or ignore
98
196
  });
99
197
  } catch (e) {
198
+ fs.writeFileSync(path.join(taskDir, "status.txt"), `failed: ${e.message}`, "utf-8");
100
199
  return new ToolResult(`Failed to create sub-agent: ${e.message}`, true);
101
200
  }
102
201
 
103
- // Run the sub-agent asynchronously (fire and forget)
202
+ const startedAt = Date.now();
104
203
  const taskPromise = (async () => {
105
204
  const resultEvents = [];
106
205
  try {
107
206
  for await (const event of childEngine.runTurn(taskDesc)) {
207
+ if (abortController.signal.aborted) {
208
+ // B8: cooperative kill check on each yielded event. Effective
209
+ // on next event boundary; previous event was allowed to complete
210
+ // so partial work is preserved.
211
+ fs.writeFileSync(path.join(taskDir, "status.txt"), "killed", "utf-8");
212
+ try {
213
+ fs.writeFileSync(
214
+ path.join(taskDir, "result.json"),
215
+ JSON.stringify(resultEvents, null, 2),
216
+ "utf-8",
217
+ );
218
+ } catch { /* best-effort */ }
219
+ return;
220
+ }
108
221
  resultEvents.push({
109
222
  type: event.type,
110
223
  text: event.text,
@@ -118,26 +231,161 @@ export class AgentTool extends BaseTool {
118
231
  JSON.stringify(resultEvents, null, 2),
119
232
  "utf-8",
120
233
  );
121
-
122
234
  const textParts = resultEvents.filter((e) => e.type === "text_delta").map((e) => e.text || "");
123
235
  fs.writeFileSync(path.join(taskDir, "output.md"), textParts.join(""), "utf-8");
124
236
  fs.writeFileSync(path.join(taskDir, "status.txt"), "completed", "utf-8");
125
237
  } catch (e) {
126
- fs.writeFileSync(path.join(taskDir, "status.txt"), `failed: ${e.message}`, "utf-8");
238
+ const status = abortController.signal.aborted ? "killed" : `failed: ${e.message}`;
239
+ try { fs.writeFileSync(path.join(taskDir, "status.txt"), status, "utf-8"); } catch { /* ignore */ }
240
+ } finally {
241
+ // Clean up any lingering background work the child started.
242
+ try { childEngine.stop?.(); } catch { /* ignore */ }
127
243
  }
128
244
  })();
129
245
 
130
- this._runningTasks.set(taskId, taskPromise);
246
+ this._runningTasks.set(taskId, { promise: taskPromise, abortController, startedAt });
131
247
  taskPromise.catch(() => {}).finally(() => this._runningTasks.delete(taskId));
132
248
 
133
- return new ToolResult(JSON.stringify({
249
+ // H3: linter runs on the brief we just saved. Non-blocking; warning
250
+ // surfaces in the tool result so the LLM sees it.
251
+ const lintWarnings = this._lintBriefCompleteness(taskDesc);
252
+
253
+ const result = {
134
254
  task_id: taskId,
135
255
  requested_id: labelOverridden ? requestedId : undefined,
136
- status: "started",
256
+ status: "running",
137
257
  output_dir: `sub_agents/${taskId}/`,
138
258
  message: labelOverridden
139
- ? `Sub-agent started under sanitized id '${taskId}' (your '${requestedId}' wasn't a valid path component). Check sub_agents/${taskId}/status.txt.`
140
- : `Sub-agent started. Check sub_agents/${taskId}/status.txt for completion, output.md for text.`,
141
- }, null, 2));
259
+ ? `Sub-agent started under sanitized id '${taskId}' (your '${requestedId}' wasn't a valid path component). Use operation=poll with task_id=${taskId} to check progress.`
260
+ : `Sub-agent '${taskId}' started. Use operation=poll to check progress, operation=wait to block for completion, operation=kill to abort.`,
261
+ };
262
+ if (lintWarnings.length > 0) {
263
+ result.brief_lint_warnings = lintWarnings;
264
+ }
265
+ return new ToolResult(JSON.stringify(result, null, 2));
266
+ }
267
+
268
+ // ---- poll ----
269
+
270
+ _poll(taskId) {
271
+ const id = (taskId || "").trim();
272
+ if (!id) return new ToolResult("task_id required for poll", true);
273
+ const info = this._readStatus(id);
274
+ if (!info) return new ToolResult(`No sub-agent dir for task_id '${id}'`, true);
275
+ return new ToolResult(JSON.stringify(info, null, 2));
276
+ }
277
+
278
+ // ---- wait ----
279
+
280
+ async _wait(taskId, timeoutMs) {
281
+ const id = (taskId || "").trim();
282
+ if (!id) return new ToolResult("task_id required for wait", true);
283
+ const entry = this._runningTasks.get(id);
284
+ const budget = Math.max(1000, Math.min(10 * 60_000, Number(timeoutMs) || 30_000));
285
+
286
+ if (entry) {
287
+ const timeoutP = new Promise((resolve) => setTimeout(() => resolve("timeout"), budget));
288
+ const result = await Promise.race([entry.promise.then(() => "done", () => "done"), timeoutP]);
289
+ if (result === "timeout") {
290
+ const info = this._readStatus(id);
291
+ return new ToolResult(
292
+ `Timeout after ${budget}ms waiting for '${id}'. Current status: ${info?.status || "unknown"}. Task still running — poll again or kill.`,
293
+ false,
294
+ );
295
+ }
296
+ }
297
+
298
+ const info = this._readStatus(id);
299
+ if (!info) return new ToolResult(`No sub-agent dir for task_id '${id}'`, true);
300
+ return new ToolResult(JSON.stringify(info, null, 2));
301
+ }
302
+
303
+ // ---- kill ----
304
+
305
+ _kill(taskId) {
306
+ const id = (taskId || "").trim();
307
+ if (!id) return new ToolResult("task_id required for kill", true);
308
+ const entry = this._runningTasks.get(id);
309
+ if (!entry) {
310
+ const info = this._readStatus(id);
311
+ if (info && info.status === "running") {
312
+ // Dir says running but not in _runningTasks — orphan from a previous
313
+ // process. Mark the file as killed so downstream readers see the
314
+ // truth. Nothing to abort at the JS level.
315
+ try { fs.writeFileSync(path.join(this._workspace.cwd, "sub_agents", id, "status.txt"), "killed", "utf-8"); } catch { /* ignore */ }
316
+ return new ToolResult(`No in-process handle for '${id}'; marked status.txt as killed (was orphan from a prior process).`);
317
+ }
318
+ return new ToolResult(`No running sub-agent with task_id '${id}' (already completed or never spawned).`, true);
319
+ }
320
+ entry.abortController.abort();
321
+ return new ToolResult(
322
+ `Kill signal sent to '${id}'. Cooperative abort — takes effect on the next event boundary. Poll or wait to confirm.`,
323
+ );
324
+ }
325
+
326
+ // ---- list ----
327
+
328
+ _list() {
329
+ const baseDir = path.join(this._workspace.cwd, "sub_agents");
330
+ if (!fs.existsSync(baseDir)) {
331
+ return new ToolResult("No sub-agents have been spawned yet.");
332
+ }
333
+ let entries;
334
+ try { entries = fs.readdirSync(baseDir); }
335
+ catch { return new ToolResult("sub_agents/ not readable", true); }
336
+
337
+ const rows = [];
338
+ for (const name of entries.sort()) {
339
+ const info = this._readStatus(name);
340
+ if (!info) continue;
341
+ const inProcess = this._runningTasks.has(name);
342
+ rows.push({
343
+ task_id: name,
344
+ status: info.status,
345
+ in_process_handle: inProcess, // true → kill will abort; false → orphan
346
+ started_ago_s: info.age_s,
347
+ last_activity_s: info.idle_s,
348
+ });
349
+ }
350
+ if (rows.length === 0) return new ToolResult("No sub-agents found.");
351
+
352
+ const active = rows.filter((r) => r.status === "running").length;
353
+ const summary = `${rows.length} sub-agent(s) (${active} running)`;
354
+ return new ToolResult(`${summary}\n\n${JSON.stringify(rows, null, 2)}`);
355
+ }
356
+
357
+ // ---- helpers ----
358
+
359
+ _readStatus(taskId) {
360
+ const dir = path.join(this._workspace.cwd, "sub_agents", taskId);
361
+ if (!fs.existsSync(dir)) return null;
362
+ const statusPath = path.join(dir, "status.txt");
363
+ let status = "unknown";
364
+ let mtimeMs = 0;
365
+ let ageSec = 0;
366
+ let idleSec = 0;
367
+ try {
368
+ if (fs.existsSync(statusPath)) {
369
+ status = fs.readFileSync(statusPath, "utf-8").trim();
370
+ mtimeMs = fs.statSync(statusPath).mtimeMs;
371
+ }
372
+ } catch { /* ignore */ }
373
+ try {
374
+ const dirStat = fs.statSync(dir);
375
+ ageSec = Math.round((Date.now() - dirStat.ctimeMs) / 1000);
376
+ idleSec = mtimeMs ? Math.round((Date.now() - mtimeMs) / 1000) : ageSec;
377
+ } catch { /* ignore */ }
378
+ return { task_id: taskId, status, age_s: ageSec, idle_s: idleSec, dir: `sub_agents/${taskId}/` };
379
+ }
380
+
381
+ /**
382
+ * B8: List currently-running sub-agents. Called by engine's phase-advance
383
+ * path to emit a `stale_subagents` pipeline event — the main agent's next
384
+ * turn sees the list and decides whether to kill each. Soft signal, not
385
+ * an automated kill, because phase_advance can fire from _maybeAutoAdvance
386
+ * unexpectedly and coupling the lifecycle would amplify blast radius.
387
+ */
388
+ getRunningTaskIds() {
389
+ return Array.from(this._runningTasks.keys());
142
390
  }
143
391
  }
@@ -0,0 +1,146 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { BaseTool, ToolResult } from "./base.js";
4
+ import { BundleTree } from "../bundle-tree.js";
5
+
6
+ const CACHE_SUBDIR = path.join("cache", "bundles");
7
+
8
+ /**
9
+ * Keyword RAG over a cached BundleTree. Requires a prior `document_chunk`
10
+ * call to have built a cached tree; this tool reads that cache and runs
11
+ * BundleTree.search() against it.
12
+ *
13
+ * Bigram (CJK 2-grams) + English word tokens. No embedding models, no
14
+ * vector DB — cheap and deterministic. Good enough for the
15
+ * regulation-to-rule retrieval pattern (rules have distinct technical
16
+ * vocabulary).
17
+ */
18
+ export class BundleSearchTool extends BaseTool {
19
+ constructor(workspace) {
20
+ super();
21
+ this._workspace = workspace;
22
+ }
23
+
24
+ get name() { return "bundle_search"; }
25
+
26
+ get description() {
27
+ return (
28
+ "Search a cached BundleTree by keywords (CJK bigrams + English words). " +
29
+ "Returns the top-ranked chunks so you can pull evidence for a rule or " +
30
+ "answer a question about the bundle. Call `document_chunk` first to " +
31
+ "build the tree. If cache_key is omitted, uses the most recently " +
32
+ "built bundle in the workspace."
33
+ );
34
+ }
35
+
36
+ get inputSchema() {
37
+ return {
38
+ type: "object",
39
+ properties: {
40
+ keywords: {
41
+ type: "array",
42
+ items: { type: "string" },
43
+ description: "Keywords or phrases to search for. CJK + English both supported.",
44
+ },
45
+ limit: {
46
+ type: "integer",
47
+ description: "Max results to return. Default 8.",
48
+ },
49
+ cache_key: {
50
+ type: "string",
51
+ description:
52
+ "BundleTree cache file name (e.g. 'a1b2c3d4e5f6.json'). Omit to use the most recently built bundle.",
53
+ },
54
+ include_content: {
55
+ type: "boolean",
56
+ description: "Include full chunk content in output (default false — only IDs and snippets).",
57
+ },
58
+ },
59
+ required: ["keywords"],
60
+ };
61
+ }
62
+
63
+ async execute(input) {
64
+ const keywords = Array.isArray(input.keywords) ? input.keywords : [];
65
+ const limit = Number.isFinite(input.limit) ? input.limit : 8;
66
+ const includeContent = input.include_content === true;
67
+ const cacheKey = input.cache_key || "";
68
+
69
+ if (keywords.length === 0) return new ToolResult("No keywords provided", true);
70
+
71
+ const cacheDir = path.join(this._workspace.cwd, CACHE_SUBDIR);
72
+ if (!fs.existsSync(cacheDir)) {
73
+ return new ToolResult(
74
+ "No bundle cache found. Call `document_chunk` first to build one.",
75
+ true,
76
+ );
77
+ }
78
+
79
+ let cachePath;
80
+ if (cacheKey) {
81
+ cachePath = path.join(cacheDir, cacheKey.endsWith(".json") ? cacheKey : `${cacheKey}.json`);
82
+ if (!fs.existsSync(cachePath)) {
83
+ return new ToolResult(`BundleTree cache not found: ${cacheKey}`, true);
84
+ }
85
+ } else {
86
+ cachePath = this._findMostRecentCache(cacheDir);
87
+ if (!cachePath) {
88
+ return new ToolResult(
89
+ "No bundle cache found. Call `document_chunk` first to build one.",
90
+ true,
91
+ );
92
+ }
93
+ }
94
+
95
+ let tree;
96
+ try {
97
+ tree = BundleTree.fromJSON(JSON.parse(fs.readFileSync(cachePath, "utf-8")));
98
+ } catch (e) {
99
+ return new ToolResult(`Corrupt bundle cache (${path.basename(cachePath)}): ${e.message}`, true);
100
+ }
101
+
102
+ const hits = tree.search(keywords, limit);
103
+ if (hits.length === 0) {
104
+ return new ToolResult(
105
+ `No matches for ${JSON.stringify(keywords)} in ${path.basename(cachePath)}.`,
106
+ );
107
+ }
108
+
109
+ const lines = [
110
+ `Found ${hits.length} chunk(s) matching ${JSON.stringify(keywords)} · source: ${path.basename(cachePath)}`,
111
+ "",
112
+ ];
113
+ for (const ch of hits) {
114
+ const headerPath = (ch.header_path || []).join(" / ");
115
+ lines.push(
116
+ `[${ch.chunk_id}] ${ch.title} · ${ch.source_file} p.${ch.page_range[0]}-${ch.page_range[1]} · ${ch.tokens || 0}tok`,
117
+ );
118
+ if (headerPath) lines.push(` path: ${headerPath}`);
119
+ if (includeContent) {
120
+ lines.push(" ─");
121
+ lines.push((ch.content || "").split("\n").map((l) => ` ${l}`).join("\n"));
122
+ } else {
123
+ const snippet = (ch.content || "").replace(/\s+/g, " ").slice(0, 160);
124
+ if (snippet) lines.push(` ${snippet}${(ch.content || "").length > 160 ? "…" : ""}`);
125
+ }
126
+ lines.push("");
127
+ }
128
+ return new ToolResult(lines.join("\n"));
129
+ }
130
+
131
+ _findMostRecentCache(cacheDir) {
132
+ let entries;
133
+ try { entries = fs.readdirSync(cacheDir); }
134
+ catch { return null; }
135
+ const candidates = entries
136
+ .filter((n) => n.endsWith(".json") && !n.endsWith(".classification.json"))
137
+ .map((n) => {
138
+ const full = path.join(cacheDir, n);
139
+ try { return { full, mtime: fs.statSync(full).mtimeMs }; }
140
+ catch { return null; }
141
+ })
142
+ .filter(Boolean)
143
+ .sort((a, b) => b.mtime - a.mtime);
144
+ return candidates[0]?.full || null;
145
+ }
146
+ }