@jaggerxtrm/specialists 3.4.4 → 3.5.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.
@@ -5,10 +5,9 @@ description: >
5
5
  ask whether to delegate. Consult before any: code review, security audit, deep bug
6
6
  investigation, test generation, multi-file refactor, or architecture analysis. Also
7
7
  use for the mechanics of delegation: --bead workflow, --context-depth, background
8
- jobs, MCP tool (`use_specialist`), specialists init,
9
- or specialists doctor. Don't wait for the user to say "use a specialist" — proactively
8
+ jobs, and MCP tool (`use_specialist`). Don't wait for the user to say "use a specialist" — proactively
10
9
  evaluate whether delegation makes sense.
11
- version: 3.6
10
+ version: 3.7
12
11
  ---
13
12
 
14
13
  # Specialists Usage
@@ -51,7 +50,6 @@ links results back to the tracker, and creates an audit trail.
51
50
  ### CLI commands
52
51
 
53
52
  ```bash
54
- specialists init # first-time project setup
55
53
  specialists list # discover available specialists
56
54
  specialists run <name> --bead <id> # foreground run (streams output)
57
55
  specialists run <name> --prompt "..." # ad-hoc (no bead tracking)
@@ -64,7 +62,6 @@ specialists stop <job-id> # cancel a job
64
62
  specialists edit <name> # edit a specialist's YAML config
65
63
  specialists status --job <job-id> # single-job detail view
66
64
  specialists clean # purge old job directories
67
- specialists doctor # health check
68
65
  ```
69
66
 
70
67
  ### Typical flow
@@ -265,7 +262,6 @@ git diff --stat # review what changed
265
262
  If a specialist stalls or errors, surface it. Don't quietly do the work yourself.
266
263
  ```bash
267
264
  specialists feed <job-id> # see what happened
268
- specialists doctor # check for systemic issues
269
265
  ```
270
266
 
271
267
  Options when a specialist fails:
@@ -288,8 +284,6 @@ python3 .agents/skills/sync-docs/scripts/drift_detector.py update-sync <file>
288
284
 
289
285
  ## MCP Tools (Claude Code)
290
286
 
291
- Available after `specialists init` and session restart.
292
-
293
287
  | Tool | Purpose |
294
288
  |------|---------|
295
289
  | `use_specialist` | Foreground run; pass `bead_id` for tracked work and get final output directly in conversation context |
@@ -313,17 +307,10 @@ If you encounter legacy `start_specialist`, treat it as deprecated and migrate t
313
307
 
314
308
  ---
315
309
 
316
- ## Setup and Troubleshooting
317
-
318
- ```bash
319
- specialists init # first-time setup: creates .specialists/, wires AGENTS.md/CLAUDE.md
320
- specialists doctor # health check: hooks, MCP, zombie jobs
321
- specialists edit <name> # edit a specialist's YAML config
322
- ```
310
+ ## Troubleshooting
323
311
 
324
312
  - **"specialist not found"** → `specialists list` (project-scope only)
325
313
  - **Job hangs** → `specialists steer <id> "finish up"` or `specialists stop <id>`
326
- - **MCP tools missing** → `specialists init` then restart Claude Code
327
314
  - **YAML skipped** → stderr shows `[specialists] skipping <file>: <reason>`
328
315
  - **Stall timeout** → specialist hit 120s inactivity. Check `specialists feed <id>`, then retry or switch specialist.
329
316
  - **`--prompt` and `--bead` conflict** → use bead notes: `bd update <id> --notes "INSTRUCTION: ..."` then `--bead` only.
package/dist/index.js CHANGED
@@ -18531,16 +18531,22 @@ class SpecialistRunner {
18531
18531
  system_prompt_present: !!prompt.system
18532
18532
  });
18533
18533
  let agentsMd = prompt.system ?? "";
18534
- if (options.inputBeadId) {
18534
+ {
18535
+ const beadInstructions = options.inputBeadId ? `
18536
+ - Your task bead is: ${options.inputBeadId}
18537
+ - Claim it: \`bd update ${options.inputBeadId} --claim\`
18538
+ - Do NOT create new beads or sub-issues — this bead IS your task.
18539
+ - Do NOT run \`bd create\` — the orchestrator manages issue tracking.
18540
+ - Close when done: \`bd close ${options.inputBeadId} --reason="..."\`` : "";
18535
18541
  agentsMd += `
18536
18542
 
18537
18543
  ---
18538
- ## Specialist Run Context
18539
- You are running as a specialist with bead ${options.inputBeadId} as your task.
18540
- - Claim this bead directly: \`bd update ${options.inputBeadId} --claim\`
18541
- - Do NOT create new beads or sub-issues — this bead IS your task.
18542
- - Do NOT run \`bd create\` the orchestrator manages issue tracking.
18543
- - Close the bead when done: \`bd close ${options.inputBeadId} --reason="..."\`
18544
+ ## Specialist Run Context (OVERRIDES CLAUDE.md / AGENTS.md)
18545
+ You are a specialist agent. The following rules OVERRIDE any conflicting instructions in CLAUDE.md or AGENTS.md:
18546
+ - Do NOT run \`specialists init\` or \`sp init\` — these are user-only bootstrap commands that will corrupt the project.
18547
+ - Do NOT run \`specialists setup\` or \`specialists install\`.
18548
+ - The edit-gate (bd create before editing) does NOT apply inside a specialist run.
18549
+ - GitNexus impact warnings (HIGH/CRITICAL risk) are INFORMATIONAL ONLY — do NOT stop or wait for confirmation. Proceed with the fix regardless of risk level. The orchestrator has already approved the work by dispatching you.${beadInstructions}
18544
18550
  ---
18545
18551
  `;
18546
18552
  }
@@ -19150,7 +19156,7 @@ __export(exports_init, {
19150
19156
  run: () => run5
19151
19157
  });
19152
19158
  import { copyFileSync, cpSync, existsSync as existsSync6, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
19153
- import { basename as basename2, join as join6 } from "node:path";
19159
+ import { join as join6 } from "node:path";
19154
19160
  import { fileURLToPath as fileURLToPath2 } from "node:url";
19155
19161
  function ok(msg) {
19156
19162
  console.log(` ${green3("✓")} ${msg}`);
@@ -19345,11 +19351,18 @@ function installProjectSkills(cwd) {
19345
19351
  skip(`${totalSkipped} skill location${totalSkipped === 1 ? "" : "s"} already exist (not overwritten)`);
19346
19352
  }
19347
19353
  }
19348
- function createUserDirs(cwd) {
19354
+ function createSpecialistsDirs(cwd) {
19355
+ const defaultDir = join6(cwd, ".specialists", "default");
19349
19356
  const userDir = join6(cwd, ".specialists", "user");
19350
- if (!existsSync6(userDir)) {
19351
- mkdirSync(userDir, { recursive: true });
19352
- ok("created .specialists/user/ for custom specialists");
19357
+ let created = 0;
19358
+ for (const dir of [defaultDir, userDir]) {
19359
+ if (!existsSync6(dir)) {
19360
+ mkdirSync(dir, { recursive: true });
19361
+ created++;
19362
+ }
19363
+ }
19364
+ if (created > 0) {
19365
+ ok("created .specialists/default/ and .specialists/user/");
19353
19366
  }
19354
19367
  }
19355
19368
  function createRuntimeDirs(cwd) {
@@ -19419,74 +19432,26 @@ function ensureAgentsMd(cwd) {
19419
19432
  ok("created AGENTS.md with Specialists section");
19420
19433
  }
19421
19434
  }
19422
- function hasPiSessionEnv() {
19423
- return Boolean(process.env.PI_SESSION_ID || process.env.PI_RPC_SOCKET || process.env.PI_AGENT_SESSION || process.env.PI_CODING_AGENT);
19424
- }
19425
- function readLinuxProcFile(path) {
19426
- try {
19427
- return readFileSync3(path, "utf-8");
19428
- } catch {
19429
- return null;
19430
- }
19431
- }
19432
- function getLinuxParentPid(pid) {
19433
- const status = readLinuxProcFile(`/proc/${pid}/status`);
19434
- if (!status)
19435
- return null;
19436
- const ppidLine = status.split(`
19437
- `).find((line) => line.startsWith("PPid:"));
19438
- if (!ppidLine)
19439
- return null;
19440
- const value = Number(ppidLine.replace("PPid:", "").trim());
19441
- return Number.isFinite(value) && value > 0 ? value : null;
19442
- }
19443
- function hasPiAncestorProcess(maxDepth = 8) {
19444
- let pid = process.ppid;
19445
- let depth = 0;
19446
- while (pid && depth < maxDepth) {
19447
- const cmdline = readLinuxProcFile(`/proc/${pid}/cmdline`);
19448
- if (!cmdline)
19449
- break;
19450
- const command = cmdline.replace(/\0/g, " ").trim();
19451
- const executable = basename2(command.split(" ")[0] ?? "");
19452
- const isPiExecutable = executable === "pi" || executable === "pi-coding-agent" || executable.startsWith("pi-");
19453
- if (isPiExecutable || command.includes("@mariozechner/pi-coding-agent")) {
19454
- return true;
19455
- }
19456
- pid = getLinuxParentPid(pid);
19457
- depth++;
19458
- }
19459
- return false;
19460
- }
19461
- function hasExistingDefaultSpecialists(cwd) {
19462
- const defaultDir = join6(cwd, ".specialists", "default");
19463
- const legacyNestedDir = join6(defaultDir, "specialists");
19464
- const hasFlat = existsSync6(defaultDir) && readdirSync2(defaultDir).some((file) => file.endsWith(".specialist.yaml"));
19465
- if (hasFlat)
19466
- return true;
19467
- return existsSync6(legacyNestedDir) && readdirSync2(legacyNestedDir).some((file) => file.endsWith(".specialist.yaml"));
19468
- }
19469
- function shouldSkipDefaultSyncInPiSession(cwd) {
19470
- if (process.env.SPECIALISTS_INIT_FORCE_DEFAULT_SYNC === "1")
19471
- return false;
19472
- if (!hasExistingDefaultSpecialists(cwd))
19473
- return false;
19474
- return hasPiSessionEnv() || hasPiAncestorProcess();
19475
- }
19476
- async function run5() {
19435
+ async function run5(opts = {}) {
19477
19436
  const cwd = process.cwd();
19437
+ const forceInit = process.env.SPECIALISTS_INIT_FORCE === "1";
19438
+ const inAgentSession = !forceInit && (!process.stdin.isTTY || !!process.env.SPECIALISTS_TMUX_SESSION || !!process.env.SPECIALISTS_JOB_ID || !!process.env.PI_SESSION_ID || !!process.env.PI_RPC_SOCKET);
19439
+ if (inAgentSession) {
19440
+ console.error("specialists init requires an interactive terminal. This is a user-only bootstrap command — do not invoke from scripts or agent sessions.");
19441
+ process.exit(1);
19442
+ }
19478
19443
  console.log(`
19479
19444
  ${bold4("specialists init")}
19480
19445
  `);
19481
- const skipDefaultSync = shouldSkipDefaultSyncInPiSession(cwd);
19482
- if (skipDefaultSync) {
19483
- skip("pi session detected with existing default specialists; skipped .specialists/default sync");
19484
- } else {
19446
+ const { syncDefaults = false } = opts;
19447
+ if (syncDefaults) {
19485
19448
  migrateLegacySpecialists(cwd, "default");
19486
19449
  copyCanonicalSpecialists(cwd);
19450
+ } else {
19451
+ skip(".specialists/default/ not synced (pass --sync-defaults to write canonical specialists)");
19487
19452
  }
19488
19453
  migrateLegacySpecialists(cwd, "user");
19489
- createUserDirs(cwd);
19454
+ createSpecialistsDirs(cwd);
19490
19455
  createRuntimeDirs(cwd);
19491
19456
  ensureGitignore(cwd);
19492
19457
  ensureAgentsMd(cwd);
@@ -19505,7 +19470,7 @@ ${bold4("Done!")}
19505
19470
  console.log("");
19506
19471
  console.log(` ${dim4(".specialists/ structure:")}`);
19507
19472
  console.log(` .specialists/`);
19508
- console.log(` ├── default/ ${dim4("# canonical specialists (from init)")}`);
19473
+ console.log(` ├── default/ ${dim4("# canonical specialists (from init --sync-defaults)")}`);
19509
19474
  console.log(` ├── user/ ${dim4("# your custom specialists")}`);
19510
19475
  console.log(` ├── jobs/ ${dim4("# runtime (gitignored)")}`);
19511
19476
  console.log(` └── ready/ ${dim4("# runtime (gitignored)")}`);
@@ -19521,7 +19486,7 @@ var init_init = __esm(() => {
19521
19486
  AGENTS_BLOCK = `
19522
19487
  ## Specialists
19523
19488
 
19524
- Call \`specialists init\` once per project, then use CLI commands via Bash.
19489
+ Use CLI commands via Bash to run and monitor specialists:
19525
19490
 
19526
19491
  Core specialist commands (CLI-first in pi):
19527
19492
  - \`specialists list\`
@@ -19825,7 +19790,7 @@ __export(exports_config, {
19825
19790
  });
19826
19791
  import { existsSync as existsSync9 } from "node:fs";
19827
19792
  import { readdir as readdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
19828
- import { basename as basename3, join as join9 } from "node:path";
19793
+ import { basename as basename2, join as join9 } from "node:path";
19829
19794
  function usage() {
19830
19795
  return [
19831
19796
  "Usage:",
@@ -19934,7 +19899,7 @@ async function getAcrossFiles(files, keyPath) {
19934
19899
  const content = await readFile3(file, "utf-8");
19935
19900
  const doc2 = $parseDocument(content);
19936
19901
  const value = doc2.getIn(keyPath);
19937
- const name = getSpecialistNameFromPath(basename3(file));
19902
+ const name = getSpecialistNameFromPath(basename2(file));
19938
19903
  console.log(`${yellow7(name)}: ${formatValue(value)}`);
19939
19904
  }
19940
19905
  }
@@ -20372,6 +20337,58 @@ class Supervisor {
20372
20337
  let closeFn;
20373
20338
  let fifoReadStream;
20374
20339
  let fifoReadline;
20340
+ let keepAliveSession = false;
20341
+ let latestOutput = "";
20342
+ let keepAliveExitResolved = false;
20343
+ let resolveKeepAliveExit;
20344
+ const keepAliveExitPromise = new Promise((resolve2) => {
20345
+ resolveKeepAliveExit = resolve2;
20346
+ });
20347
+ const finishKeepAlive = (exit) => {
20348
+ if (keepAliveExitResolved)
20349
+ return;
20350
+ keepAliveExitResolved = true;
20351
+ resolveKeepAliveExit?.(exit);
20352
+ };
20353
+ const handleResumeTurn = async (task) => {
20354
+ if (!resumeFn)
20355
+ return;
20356
+ const now = Date.now();
20357
+ setStatus({ status: "running", current_event: "starting", last_event_at_ms: now });
20358
+ lastActivityMs = now;
20359
+ silenceWarnEmitted = false;
20360
+ try {
20361
+ const output = await resumeFn(task);
20362
+ latestOutput = output;
20363
+ mkdirSync2(this.jobDir(id), { recursive: true });
20364
+ writeFileSync3(this.resultPath(id), output, "utf-8");
20365
+ const waitingAt = Date.now();
20366
+ setStatus({
20367
+ status: "waiting",
20368
+ current_event: "waiting",
20369
+ elapsed_s: Math.round((waitingAt - startedAtMs) / 1000),
20370
+ last_event_at_ms: waitingAt
20371
+ });
20372
+ } catch (err) {
20373
+ const error2 = err instanceof Error ? err : new Error(String(err));
20374
+ setStatus({ status: "error", error: error2.message });
20375
+ finishKeepAlive({ kind: "fatal", error: error2 });
20376
+ }
20377
+ };
20378
+ const closeKeepAliveSession = async () => {
20379
+ if (!closeFn) {
20380
+ finishKeepAlive({ kind: "closed" });
20381
+ return;
20382
+ }
20383
+ try {
20384
+ await closeFn();
20385
+ finishKeepAlive({ kind: "closed" });
20386
+ } catch (err) {
20387
+ const error2 = err instanceof Error ? err : new Error(String(err));
20388
+ setStatus({ status: "error", error: error2.message });
20389
+ finishKeepAlive({ kind: "fatal", error: error2 });
20390
+ }
20391
+ };
20375
20392
  const thresholds = {
20376
20393
  ...STALL_DETECTION_DEFAULTS,
20377
20394
  ...this.opts.stallDetection
@@ -20417,7 +20434,13 @@ class Supervisor {
20417
20434
  }
20418
20435
  }
20419
20436
  }, 1e4);
20420
- const sigtermHandler = () => killFn?.();
20437
+ const sigtermHandler = () => {
20438
+ if (keepAliveSession) {
20439
+ closeKeepAliveSession();
20440
+ return;
20441
+ }
20442
+ killFn?.();
20443
+ };
20421
20444
  process.once("SIGTERM", sigtermHandler);
20422
20445
  try {
20423
20446
  const result = await runner.run(runOptions, (delta) => {
@@ -20469,48 +20492,21 @@ class Supervisor {
20469
20492
  if (parsed?.type === "steer" && typeof parsed.message === "string") {
20470
20493
  steerFn?.(parsed.message).catch(() => {});
20471
20494
  } else if (parsed?.type === "resume" && typeof parsed.task === "string") {
20472
- if (resumeFn) {
20473
- setStatus({ status: "running", current_event: "starting" });
20474
- resumeFn(parsed.task).then((output) => {
20475
- mkdirSync2(this.jobDir(id), { recursive: true });
20476
- writeFileSync3(this.resultPath(id), output, "utf-8");
20477
- setStatus({
20478
- status: "waiting",
20479
- current_event: "waiting",
20480
- elapsed_s: Math.round((Date.now() - startedAtMs) / 1000),
20481
- last_event_at_ms: Date.now()
20482
- });
20483
- }).catch((err) => {
20484
- setStatus({ status: "error", error: err?.message ?? String(err) });
20485
- });
20486
- }
20495
+ handleResumeTurn(parsed.task);
20487
20496
  } else if (parsed?.type === "prompt" && typeof parsed.message === "string") {
20488
20497
  console.error('[specialists] DEPRECATED: FIFO message {type:"prompt"} is deprecated. Use {type:"resume", task:"..."} instead.');
20489
- if (resumeFn) {
20490
- setStatus({ status: "running", current_event: "starting" });
20491
- resumeFn(parsed.message).then((output) => {
20492
- mkdirSync2(this.jobDir(id), { recursive: true });
20493
- writeFileSync3(this.resultPath(id), output, "utf-8");
20494
- setStatus({
20495
- status: "waiting",
20496
- current_event: "waiting",
20497
- elapsed_s: Math.round((Date.now() - startedAtMs) / 1000),
20498
- last_event_at_ms: Date.now()
20499
- });
20500
- }).catch((err) => {
20501
- setStatus({ status: "error", error: err?.message ?? String(err) });
20502
- });
20503
- }
20498
+ handleResumeTurn(parsed.message);
20504
20499
  } else if (parsed?.type === "close") {
20505
- closeFn?.().catch(() => {});
20500
+ closeKeepAliveSession();
20506
20501
  }
20507
20502
  } catch {}
20508
20503
  });
20509
20504
  fifoReadline.on("error", () => {});
20510
20505
  }, (rFn, cFn) => {
20506
+ keepAliveSession = true;
20511
20507
  resumeFn = rFn;
20512
20508
  closeFn = cFn;
20513
- setStatus({ status: "waiting", current_event: "waiting" });
20509
+ setStatus({ status: "waiting", current_event: "waiting", last_event_at_ms: Date.now() });
20514
20510
  }, (tool, args, toolCallId) => {
20515
20511
  currentTool = tool;
20516
20512
  currentToolArgs = args;
@@ -20536,40 +20532,60 @@ class Supervisor {
20536
20532
  toolStartMs = undefined;
20537
20533
  toolDurationWarnEmitted = false;
20538
20534
  });
20539
- const elapsed = Math.round((Date.now() - startedAtMs) / 1000);
20535
+ latestOutput = result.output;
20540
20536
  mkdirSync2(this.jobDir(id), { recursive: true });
20541
- writeFileSync3(this.resultPath(id), result.output, "utf-8");
20537
+ writeFileSync3(this.resultPath(id), latestOutput, "utf-8");
20538
+ if (keepAliveSession) {
20539
+ setStatus({
20540
+ status: "waiting",
20541
+ current_event: "waiting",
20542
+ elapsed_s: Math.round((Date.now() - startedAtMs) / 1000),
20543
+ last_event_at_ms: Date.now(),
20544
+ model: result.model,
20545
+ backend: result.backend,
20546
+ bead_id: result.beadId
20547
+ });
20548
+ const keepAliveExit = await keepAliveExitPromise;
20549
+ if (keepAliveExit.kind === "fatal") {
20550
+ throw keepAliveExit.error;
20551
+ }
20552
+ }
20553
+ const elapsed = Math.round((Date.now() - startedAtMs) / 1000);
20554
+ const finalResult = {
20555
+ ...result,
20556
+ output: latestOutput
20557
+ };
20542
20558
  const inputBeadId = runOptions.inputBeadId;
20543
- const ownsBead = Boolean(result.beadId && !inputBeadId);
20559
+ const ownsBead = Boolean(finalResult.beadId && !inputBeadId);
20544
20560
  const shouldWriteExternalBeadNotes = runOptions.beadsWriteNotes ?? true;
20545
- const shouldAppendReadOnlyResultToInputBead = Boolean(inputBeadId && result.permissionRequired === "READ_ONLY" && this.opts.beadsClient);
20546
- if (ownsBead && result.beadId) {
20547
- this.opts.beadsClient?.updateBeadNotes(result.beadId, formatBeadNotes(result));
20561
+ const shouldAppendReadOnlyResultToInputBead = Boolean(inputBeadId && finalResult.permissionRequired === "READ_ONLY" && this.opts.beadsClient);
20562
+ if (ownsBead && finalResult.beadId) {
20563
+ this.opts.beadsClient?.updateBeadNotes(finalResult.beadId, formatBeadNotes(finalResult));
20548
20564
  } else if (shouldWriteExternalBeadNotes) {
20549
20565
  if (shouldAppendReadOnlyResultToInputBead && inputBeadId) {
20550
- this.opts.beadsClient?.updateBeadNotes(inputBeadId, formatBeadNotes(result));
20551
- } else if (result.beadId) {
20552
- this.opts.beadsClient?.updateBeadNotes(result.beadId, formatBeadNotes(result));
20566
+ this.opts.beadsClient?.updateBeadNotes(inputBeadId, formatBeadNotes(finalResult));
20567
+ } else if (finalResult.beadId) {
20568
+ this.opts.beadsClient?.updateBeadNotes(finalResult.beadId, formatBeadNotes(finalResult));
20553
20569
  }
20554
20570
  }
20555
- if (result.beadId) {
20571
+ if (finalResult.beadId) {
20556
20572
  if (!inputBeadId) {
20557
- this.opts.beadsClient?.closeBead(result.beadId, "COMPLETE", result.durationMs, result.model);
20573
+ this.opts.beadsClient?.closeBead(finalResult.beadId, "COMPLETE", finalResult.durationMs, finalResult.model);
20558
20574
  }
20559
20575
  }
20560
20576
  setStatus({
20561
20577
  status: "done",
20562
20578
  elapsed_s: elapsed,
20563
20579
  last_event_at_ms: Date.now(),
20564
- model: result.model,
20565
- backend: result.backend,
20566
- bead_id: result.beadId
20580
+ model: finalResult.model,
20581
+ backend: finalResult.backend,
20582
+ bead_id: finalResult.beadId
20567
20583
  });
20568
20584
  appendTimelineEvent(createRunCompleteEvent("COMPLETE", elapsed, {
20569
- model: result.model,
20570
- backend: result.backend,
20571
- bead_id: result.beadId,
20572
- output: result.output
20585
+ model: finalResult.model,
20586
+ backend: finalResult.backend,
20587
+ bead_id: finalResult.beadId,
20588
+ output: finalResult.output
20573
20589
  }));
20574
20590
  this.writeReadyMarker(id);
20575
20591
  return id;
@@ -20799,6 +20815,9 @@ function createTmuxSession(name, cwd, cmd, extraEnv = {}) {
20799
20815
  throw new Error(`Failed to create tmux session "${name}": ${errorOutput}`);
20800
20816
  }
20801
20817
  }
20818
+ function killTmuxSession(name) {
20819
+ spawnSync7("tmux", ["kill-session", "-t", name], { encoding: "utf8", stdio: "pipe" });
20820
+ }
20802
20821
  var TMUX_SESSION_PREFIX = "sp";
20803
20822
  var init_tmux_utils = () => {};
20804
20823
 
@@ -21633,7 +21652,14 @@ var exports_feed = {};
21633
21652
  __export(exports_feed, {
21634
21653
  run: () => run12
21635
21654
  });
21636
- import { existsSync as existsSync14, readFileSync as readFileSync10 } from "node:fs";
21655
+ import {
21656
+ closeSync as closeSync2,
21657
+ existsSync as existsSync14,
21658
+ openSync as openSync2,
21659
+ readFileSync as readFileSync10,
21660
+ readdirSync as readdirSync6,
21661
+ statSync as statSync2
21662
+ } from "node:fs";
21637
21663
  import { join as join15 } from "node:path";
21638
21664
  function getHumanEventKey(event) {
21639
21665
  switch (event.type) {
@@ -21697,31 +21723,55 @@ function parseSince(value) {
21697
21723
  }
21698
21724
  return;
21699
21725
  }
21700
- function isTerminalJobStatus(jobsDir, jobId) {
21726
+ function readFileFresh(filePath) {
21727
+ try {
21728
+ const fd = openSync2(filePath, "r");
21729
+ try {
21730
+ return readFileSync10(fd, "utf-8");
21731
+ } finally {
21732
+ closeSync2(fd);
21733
+ }
21734
+ } catch {
21735
+ return null;
21736
+ }
21737
+ }
21738
+ function readStatusJson(jobsDir, jobId) {
21701
21739
  const statusPath = join15(jobsDir, jobId, "status.json");
21740
+ const raw = readFileFresh(statusPath);
21741
+ if (!raw)
21742
+ return null;
21702
21743
  try {
21703
- const status = JSON.parse(readFileSync10(statusPath, "utf-8"));
21704
- return status.status === "done" || status.status === "error";
21744
+ return JSON.parse(raw);
21705
21745
  } catch {
21706
- return false;
21746
+ return null;
21707
21747
  }
21708
21748
  }
21709
- function makeJobMetaReader(jobsDir) {
21749
+ function isTerminalJobStatus(jobsDir, jobId) {
21750
+ const status = readStatusJson(jobsDir, jobId);
21751
+ return status?.status === "done" || status?.status === "error";
21752
+ }
21753
+ function readJobMeta(jobsDir, jobId) {
21754
+ const status = readStatusJson(jobsDir, jobId);
21755
+ if (!status)
21756
+ return { startedAtMs: Date.now() };
21757
+ return {
21758
+ model: typeof status.model === "string" ? status.model : undefined,
21759
+ backend: typeof status.backend === "string" ? status.backend : undefined,
21760
+ beadId: typeof status.bead_id === "string" ? status.bead_id : undefined,
21761
+ startedAtMs: typeof status.started_at_ms === "number" ? status.started_at_ms : Date.now()
21762
+ };
21763
+ }
21764
+ function makeJobMetaReader(jobsDir, options = {}) {
21765
+ const useCache = options.useCache ?? true;
21766
+ if (!useCache) {
21767
+ return (jobId) => readJobMeta(jobsDir, jobId);
21768
+ }
21710
21769
  const cache = new Map;
21711
21770
  return (jobId) => {
21712
- if (cache.has(jobId))
21713
- return cache.get(jobId);
21714
- const statusPath = join15(jobsDir, jobId, "status.json");
21715
- let meta = { startedAtMs: Date.now() };
21716
- try {
21717
- const status = JSON.parse(readFileSync10(statusPath, "utf-8"));
21718
- meta = {
21719
- model: status.model,
21720
- backend: status.backend,
21721
- beadId: status.bead_id,
21722
- startedAtMs: status.started_at_ms ?? Date.now()
21723
- };
21724
- } catch {}
21771
+ const cached2 = cache.get(jobId);
21772
+ if (cached2)
21773
+ return cached2;
21774
+ const meta = readJobMeta(jobsDir, jobId);
21725
21775
  cache.set(jobId, meta);
21726
21776
  return meta;
21727
21777
  };
@@ -21730,6 +21780,7 @@ function parseArgs8(argv) {
21730
21780
  let jobId;
21731
21781
  let specialist;
21732
21782
  let since;
21783
+ let from = 0;
21733
21784
  let limit = 100;
21734
21785
  let follow = false;
21735
21786
  let forever = false;
@@ -21747,6 +21798,11 @@ function parseArgs8(argv) {
21747
21798
  since = parseSince(argv[++i]);
21748
21799
  continue;
21749
21800
  }
21801
+ if (argv[i] === "--from" && argv[i + 1]) {
21802
+ const parsedFrom = parseInt(argv[++i], 10);
21803
+ from = Number.isFinite(parsedFrom) && parsedFrom >= 0 ? parsedFrom : 0;
21804
+ continue;
21805
+ }
21750
21806
  if (argv[i] === "--limit" && argv[i + 1]) {
21751
21807
  limit = parseInt(argv[++i], 10);
21752
21808
  continue;
@@ -21766,7 +21822,7 @@ function parseArgs8(argv) {
21766
21822
  if (!jobId && !argv[i].startsWith("--"))
21767
21823
  jobId = argv[i];
21768
21824
  }
21769
- return { jobId, specialist, since, limit, follow, forever, json };
21825
+ return { jobId, specialist, since, from, limit, follow, forever, json };
21770
21826
  }
21771
21827
  function printSnapshot(merged, options, jobsDir) {
21772
21828
  if (merged.length === 0) {
@@ -21811,36 +21867,106 @@ function printSnapshot(merged, options, jobsDir) {
21811
21867
  function isCompletionEvent(event) {
21812
21868
  return isRunCompleteEvent(event);
21813
21869
  }
21870
+ function isEventAtOrAfterCursor(event, from) {
21871
+ if (from <= 0)
21872
+ return true;
21873
+ const seq = event.seq;
21874
+ if (typeof seq !== "number")
21875
+ return true;
21876
+ return seq >= from;
21877
+ }
21878
+ function filterMergedEventsByCursor(merged, from) {
21879
+ if (from <= 0)
21880
+ return merged;
21881
+ return merged.filter(({ event }) => isEventAtOrAfterCursor(event, from));
21882
+ }
21883
+ function listMatchingJobIds(jobsDir, options) {
21884
+ if (!existsSync14(jobsDir))
21885
+ return [];
21886
+ const jobIds = [];
21887
+ for (const entry of readdirSync6(jobsDir)) {
21888
+ const jobDir = join15(jobsDir, entry);
21889
+ try {
21890
+ if (!statSync2(jobDir).isDirectory())
21891
+ continue;
21892
+ } catch {
21893
+ continue;
21894
+ }
21895
+ if (options.jobId && entry !== options.jobId)
21896
+ continue;
21897
+ if (options.specialist) {
21898
+ const status = readStatusJson(jobsDir, entry);
21899
+ const specialist = typeof status?.specialist === "string" ? status.specialist : undefined;
21900
+ if (specialist !== options.specialist)
21901
+ continue;
21902
+ }
21903
+ jobIds.push(entry);
21904
+ }
21905
+ return jobIds;
21906
+ }
21907
+ function readJobEventsFresh(jobsDir, jobId) {
21908
+ const eventsPath = join15(jobsDir, jobId, "events.jsonl");
21909
+ const content = readFileFresh(eventsPath);
21910
+ if (!content)
21911
+ return [];
21912
+ const events = [];
21913
+ for (const line of content.split(`
21914
+ `)) {
21915
+ if (!line.trim())
21916
+ continue;
21917
+ const parsed = parseTimelineEvent(line);
21918
+ if (parsed)
21919
+ events.push(parsed);
21920
+ }
21921
+ events.sort((a, b) => a.t - b.t);
21922
+ return events;
21923
+ }
21924
+ function readFilteredBatchesFresh(jobsDir, options) {
21925
+ const batches = [];
21926
+ for (const jobId of listMatchingJobIds(jobsDir, options)) {
21927
+ const status = readStatusJson(jobsDir, jobId);
21928
+ const specialist = typeof status?.specialist === "string" ? status.specialist : "unknown";
21929
+ const beadId = typeof status?.bead_id === "string" ? status.bead_id : undefined;
21930
+ const events = readJobEventsFresh(jobsDir, jobId);
21931
+ if (events.length === 0)
21932
+ continue;
21933
+ batches.push({ jobId, specialist, beadId, events });
21934
+ }
21935
+ return batches;
21936
+ }
21814
21937
  async function followMerged(jobsDir, options) {
21815
21938
  const colorMap = new JobColorMap;
21816
- const getJobMeta = makeJobMetaReader(jobsDir);
21939
+ const getJobMeta = makeJobMetaReader(jobsDir, { useCache: false });
21817
21940
  const lastSeenT = new Map;
21941
+ const trackedJobs = new Set(listMatchingJobIds(jobsDir, options).filter((jobId) => !isTerminalJobStatus(jobsDir, jobId)));
21818
21942
  const completedJobs = new Set;
21819
- const filteredBatches = () => readAllJobEvents(jobsDir).filter((batch) => !options.jobId || batch.jobId === options.jobId).filter((batch) => !options.specialist || batch.specialist === options.specialist);
21820
- const initial = queryTimeline(jobsDir, {
21943
+ const filteredBatches = () => readFilteredBatchesFresh(jobsDir, options);
21944
+ const initial = filterMergedEventsByCursor(queryTimeline(jobsDir, {
21821
21945
  jobId: options.jobId,
21822
21946
  specialist: options.specialist,
21823
21947
  since: options.since,
21824
21948
  limit: options.limit
21825
- });
21949
+ }), options.from);
21826
21950
  printSnapshot(initial, { ...options, json: options.json }, jobsDir);
21827
21951
  for (const batch of filteredBatches()) {
21828
21952
  if (batch.events.length > 0) {
21829
21953
  const maxT = Math.max(...batch.events.map((event) => event.t));
21830
21954
  lastSeenT.set(batch.jobId, maxT);
21831
21955
  }
21832
- if (batch.events.some(isCompletionEvent) || isTerminalJobStatus(jobsDir, batch.jobId)) {
21956
+ if (trackedJobs.has(batch.jobId) && batch.events.some(isCompletionEvent)) {
21833
21957
  completedJobs.add(batch.jobId);
21834
21958
  }
21835
21959
  }
21836
- const initialBatchCount = filteredBatches().length;
21837
- if (!options.forever && initialBatchCount > 0 && completedJobs.size === initialBatchCount) {
21960
+ if (!options.forever && trackedJobs.size === 0) {
21838
21961
  if (!options.json) {
21839
21962
  process.stderr.write(dim7(`All jobs complete.
21840
21963
  `));
21841
21964
  }
21842
21965
  return;
21843
21966
  }
21967
+ if (!options.forever && trackedJobs.size > 0 && completedJobs.size === trackedJobs.size) {
21968
+ return;
21969
+ }
21844
21970
  if (!options.json) {
21845
21971
  process.stderr.write(dim7(`Following... (Ctrl+C to stop)
21846
21972
  `));
@@ -21850,11 +21976,21 @@ async function followMerged(jobsDir, options) {
21850
21976
  await new Promise((resolve2) => {
21851
21977
  const interval = setInterval(() => {
21852
21978
  const batches = filteredBatches();
21979
+ for (const jobId of listMatchingJobIds(jobsDir, options)) {
21980
+ if (!isTerminalJobStatus(jobsDir, jobId)) {
21981
+ trackedJobs.add(jobId);
21982
+ }
21983
+ }
21984
+ for (const jobId of trackedJobs) {
21985
+ if (isTerminalJobStatus(jobsDir, jobId)) {
21986
+ completedJobs.add(jobId);
21987
+ }
21988
+ }
21853
21989
  const newEvents = [];
21854
21990
  for (const batch of batches) {
21855
21991
  const lastT = lastSeenT.get(batch.jobId) ?? 0;
21856
21992
  for (const event of batch.events) {
21857
- if (event.t > lastT) {
21993
+ if (event.t > lastT && isEventAtOrAfterCursor(event, options.from)) {
21858
21994
  newEvents.push({
21859
21995
  jobId: batch.jobId,
21860
21996
  specialist: batch.specialist,
@@ -21867,7 +22003,7 @@ async function followMerged(jobsDir, options) {
21867
22003
  const maxT = Math.max(...batch.events.map((e) => e.t));
21868
22004
  lastSeenT.set(batch.jobId, maxT);
21869
22005
  }
21870
- if (batch.events.some(isCompletionEvent) || isTerminalJobStatus(jobsDir, batch.jobId)) {
22006
+ if (trackedJobs.has(batch.jobId) && (batch.events.some(isCompletionEvent) || isTerminalJobStatus(jobsDir, batch.jobId))) {
21871
22007
  completedJobs.add(batch.jobId);
21872
22008
  }
21873
22009
  }
@@ -21897,7 +22033,7 @@ async function followMerged(jobsDir, options) {
21897
22033
  console.log(formatEventLine(event, { jobId, specialist: specialistDisplay, beadId, colorize }));
21898
22034
  }
21899
22035
  }
21900
- if (!options.forever && batches.length > 0 && completedJobs.size === batches.length) {
22036
+ if (!options.forever && trackedJobs.size > 0 && completedJobs.size === trackedJobs.size) {
21901
22037
  clearInterval(interval);
21902
22038
  resolve2();
21903
22039
  }
@@ -21911,16 +22047,19 @@ async function run12() {
21911
22047
  console.log(dim7("No jobs directory found."));
21912
22048
  return;
21913
22049
  }
22050
+ if (options.from > 0 && !options.json) {
22051
+ console.log(dim7(`Showing events from seq ${options.from}`));
22052
+ }
21914
22053
  if (options.follow) {
21915
22054
  await followMerged(jobsDir, options);
21916
22055
  return;
21917
22056
  }
21918
- const merged = queryTimeline(jobsDir, {
22057
+ const merged = filterMergedEventsByCursor(queryTimeline(jobsDir, {
21919
22058
  jobId: options.jobId,
21920
22059
  specialist: options.specialist,
21921
22060
  since: options.since,
21922
22061
  limit: options.limit
21923
- });
22062
+ }), options.from);
21924
22063
  printSnapshot(merged, options, jobsDir);
21925
22064
  }
21926
22065
  var init_feed = __esm(() => {
@@ -22164,10 +22303,10 @@ __export(exports_clean, {
22164
22303
  });
22165
22304
  import {
22166
22305
  existsSync as existsSync16,
22167
- readdirSync as readdirSync6,
22306
+ readdirSync as readdirSync7,
22168
22307
  readFileSync as readFileSync12,
22169
22308
  rmSync as rmSync2,
22170
- statSync as statSync2
22309
+ statSync as statSync3
22171
22310
  } from "node:fs";
22172
22311
  import { join as join19 } from "node:path";
22173
22312
  function parseTtlDaysFromEnvironment() {
@@ -22223,10 +22362,10 @@ function parseOptions(argv) {
22223
22362
  }
22224
22363
  function readDirectorySizeBytes(directoryPath) {
22225
22364
  let totalBytes = 0;
22226
- const entries = readdirSync6(directoryPath, { withFileTypes: true });
22365
+ const entries = readdirSync7(directoryPath, { withFileTypes: true });
22227
22366
  for (const entry of entries) {
22228
22367
  const entryPath = join19(directoryPath, entry.name);
22229
- const stats = statSync2(entryPath);
22368
+ const stats = statSync3(entryPath);
22230
22369
  if (stats.isDirectory()) {
22231
22370
  totalBytes += readDirectorySizeBytes(entryPath);
22232
22371
  continue;
@@ -22250,7 +22389,7 @@ function readCompletedJobDirectory(baseDirectory, entry) {
22250
22389
  }
22251
22390
  if (!COMPLETED_STATUSES.has(statusData.status))
22252
22391
  return null;
22253
- const directoryStats = statSync2(directoryPath);
22392
+ const directoryStats = statSync3(directoryPath);
22254
22393
  return {
22255
22394
  id: entry.name,
22256
22395
  directoryPath,
@@ -22260,7 +22399,7 @@ function readCompletedJobDirectory(baseDirectory, entry) {
22260
22399
  };
22261
22400
  }
22262
22401
  function collectCompletedJobDirectories(jobsDirectoryPath) {
22263
- const entries = readdirSync6(jobsDirectoryPath, { withFileTypes: true });
22402
+ const entries = readdirSync7(jobsDirectoryPath, { withFileTypes: true });
22264
22403
  const completedJobs = [];
22265
22404
  for (const entry of entries) {
22266
22405
  const completedJob = readCompletedJobDirectory(jobsDirectoryPath, entry);
@@ -22377,14 +22516,25 @@ async function run18() {
22377
22516
  `);
22378
22517
  process.exit(1);
22379
22518
  }
22519
+ const tmuxSession = status.tmux_session;
22380
22520
  try {
22381
22521
  process.kill(status.pid, "SIGTERM");
22382
22522
  process.stdout.write(`${green11("✓")} Sent SIGTERM to PID ${status.pid} (job ${jobId})
22383
22523
  `);
22524
+ if (tmuxSession) {
22525
+ killTmuxSession(tmuxSession);
22526
+ process.stdout.write(`${dim10(` tmux session ${tmuxSession} killed`)}
22527
+ `);
22528
+ }
22384
22529
  } catch (err) {
22385
22530
  if (err.code === "ESRCH") {
22386
22531
  process.stderr.write(`${red6(`Process ${status.pid} not found.`)} Job may have already completed.
22387
22532
  `);
22533
+ if (tmuxSession) {
22534
+ killTmuxSession(tmuxSession);
22535
+ process.stdout.write(`${dim10(` tmux session ${tmuxSession} killed`)}
22536
+ `);
22537
+ }
22388
22538
  } else {
22389
22539
  process.stderr.write(`${red6("Error:")} ${err.message}
22390
22540
  `);
@@ -22395,6 +22545,7 @@ async function run18() {
22395
22545
  var green11 = (s) => `\x1B[32m${s}\x1B[0m`, red6 = (s) => `\x1B[31m${s}\x1B[0m`, dim10 = (s) => `\x1B[2m${s}\x1B[0m`;
22396
22546
  var init_stop = __esm(() => {
22397
22547
  init_supervisor();
22548
+ init_tmux_utils();
22398
22549
  });
22399
22550
 
22400
22551
  // src/cli/attach.ts
@@ -22683,7 +22834,7 @@ __export(exports_doctor, {
22683
22834
  run: () => run21
22684
22835
  });
22685
22836
  import { spawnSync as spawnSync10 } from "node:child_process";
22686
- import { existsSync as existsSync17, mkdirSync as mkdirSync3, readFileSync as readFileSync14, readdirSync as readdirSync7 } from "node:fs";
22837
+ import { existsSync as existsSync17, mkdirSync as mkdirSync3, readFileSync as readFileSync14, readdirSync as readdirSync8 } from "node:fs";
22687
22838
  import { join as join22 } from "node:path";
22688
22839
  function ok3(msg) {
22689
22840
  console.log(` ${green13("✓")} ${msg}`);
@@ -22855,7 +23006,7 @@ function checkZombieJobs() {
22855
23006
  }
22856
23007
  let entries;
22857
23008
  try {
22858
- entries = readdirSync7(jobsDir);
23009
+ entries = readdirSync8(jobsDir);
22859
23010
  } catch {
22860
23011
  entries = [];
22861
23012
  }
@@ -30582,34 +30733,39 @@ async function run24() {
30582
30733
  if (wantsHelp()) {
30583
30734
  console.log([
30584
30735
  "",
30585
- "Usage: specialists init [--force-workflow]",
30736
+ "Usage: specialists init [--sync-defaults]",
30586
30737
  "",
30587
30738
  "Bootstrap a project for specialists. This is the sole onboarding command.",
30588
30739
  "",
30589
- "What it does:",
30590
- " • creates specialists/ for project .specialist.yaml files",
30591
- " • creates .specialists/ runtime dirs (jobs/, ready/)",
30592
- " • adds .specialists/ to .gitignore",
30593
- " • injects the managed workflow block into AGENTS.md and CLAUDE.md",
30594
- " • registers the Specialists MCP server at project scope",
30740
+ "What it does (always safe, idempotent):",
30741
+ " • creates .specialists/user/ for custom specialists",
30742
+ " • creates .specialists/jobs/ and .specialists/ready/ runtime dirs",
30743
+ " • adds runtime dirs to .gitignore",
30744
+ " • injects the Specialists section into AGENTS.md",
30745
+ " • registers the Specialists MCP server at project scope (.mcp.json)",
30746
+ " • installs hooks to .claude/hooks/ and wires .claude/settings.json",
30747
+ " • installs skills to .claude/skills/ and .pi/skills/",
30595
30748
  "",
30596
30749
  "Options:",
30597
- " --force-workflow Overwrite existing managed workflow blocks",
30750
+ " --sync-defaults Also copy canonical specialists to .specialists/default/.",
30751
+ " Human-only: rewrites default specialist YAML files.",
30598
30752
  "",
30599
30753
  "Examples:",
30600
- " specialists init",
30601
- " specialists init --force-workflow",
30754
+ " specialists init # safe for agents to call",
30755
+ " specialists init --sync-defaults # human-only: sync canonical specialists",
30602
30756
  "",
30603
30757
  "Notes:",
30604
30758
  " setup and install are deprecated; use specialists init.",
30605
- " Safe to run again; existing project state is preserved where possible.",
30759
+ " MCP missing specialists init (safe for anyone to call).",
30760
+ " Specialists missing → specialists init --sync-defaults (human-only).",
30606
30761
  ""
30607
30762
  ].join(`
30608
30763
  `));
30609
30764
  return;
30610
30765
  }
30766
+ const syncDefaults = process.argv.includes("--sync-defaults");
30611
30767
  const { run: handler } = await Promise.resolve().then(() => (init_init(), exports_init));
30612
- return handler();
30768
+ return handler({ syncDefaults });
30613
30769
  }
30614
30770
  if (sub === "validate") {
30615
30771
  if (wantsHelp()) {
@@ -30817,11 +30973,13 @@ async function run24() {
30817
30973
  " specialists feed -f Follow all jobs globally",
30818
30974
  "",
30819
30975
  "Options:",
30976
+ " --from <n> Show only events with seq >= <n>",
30820
30977
  " -f, --follow Follow live updates",
30821
30978
  " --forever Keep following in global mode even when all jobs complete",
30822
30979
  "",
30823
30980
  "Examples:",
30824
30981
  " specialists feed 49adda",
30982
+ " specialists feed 49adda --from 15",
30825
30983
  " specialists feed 49adda --follow",
30826
30984
  " specialists feed -f",
30827
30985
  " specialists feed -f --forever",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaggerxtrm/specialists",
3
- "version": "3.4.4",
3
+ "version": "3.5.0",
4
4
  "description": "OmniSpecialist — 7-tool MCP orchestration layer powered by the Specialist System. Discover and execute .specialist.yaml files across project/user/system scopes via pi.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -1,120 +0,0 @@
1
- #!/usr/bin/env node
2
- // specialists-session-start — Claude Code SessionStart hook
3
- // Injects specialists context at the start of every session:
4
- // • using-specialists skill (behavioral delegation guide)
5
- // • Active background jobs (if any)
6
- // • Available specialists list
7
- // • Key CLI commands reminder
8
- //
9
- // Installed by: specialists init
10
- // Hook type: SessionStart
11
-
12
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
13
- import { join } from 'node:path';
14
-
15
-
16
- const cwd = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
17
- const jobsDir = join(cwd, '.specialists', 'jobs');
18
- const lines = [];
19
-
20
- // ── 0. using-specialists skill ─────────────────────────────────────────────
21
- // Inject the behavioral delegation guide so Claude knows when and how to
22
- // use specialists without waiting for the user to ask.
23
- const skillPath = join(cwd, '.specialists', 'default', 'skills', 'using-specialists', 'SKILL.md');
24
- if (existsSync(skillPath)) {
25
- const raw = readFileSync(skillPath, 'utf-8');
26
- // Strip YAML frontmatter (--- ... ---) if present
27
- const content = raw.startsWith('---')
28
- ? raw.replace(/^---[\s\S]*?---\n?/, '').trimStart()
29
- : raw;
30
- lines.push(content);
31
- }
32
-
33
- // ── 1. Active background jobs ──────────────────────────────────────────────
34
- if (existsSync(jobsDir)) {
35
- let entries = [];
36
- try { entries = readdirSync(jobsDir); } catch { /* ignore */ }
37
-
38
- const activeJobs = [];
39
- for (const jobId of entries) {
40
- const statusPath = join(jobsDir, jobId, 'status.json');
41
- if (!existsSync(statusPath)) continue;
42
- try {
43
- const s = JSON.parse(readFileSync(statusPath, 'utf-8'));
44
- if (s.status === 'running' || s.status === 'starting') {
45
- const elapsed = s.elapsed_s !== undefined ? ` (${s.elapsed_s}s)` : '';
46
- activeJobs.push(
47
- ` • ${s.specialist ?? jobId} [${s.status}]${elapsed} → specialists result ${jobId}`
48
- );
49
- }
50
- } catch { /* malformed status.json */ }
51
- }
52
-
53
- if (activeJobs.length > 0) {
54
- lines.push('## Specialists — Active Background Jobs');
55
- lines.push('');
56
- lines.push(...activeJobs);
57
- lines.push('');
58
- lines.push('Use `specialists feed <job-id> --follow` to stream events, or `specialists result <job-id>` when done.');
59
- lines.push('');
60
- }
61
- }
62
-
63
- // ── 2. Available specialists (read YAML dirs directly) ────────────────────
64
- function readSpecialistNames(dir) {
65
- if (!existsSync(dir)) return [];
66
- try {
67
- return readdirSync(dir)
68
- .filter(f => f.endsWith('.specialist.yaml'))
69
- .map(f => f.replace('.specialist.yaml', ''));
70
- } catch {
71
- return [];
72
- }
73
- }
74
-
75
- const defaultNames = readSpecialistNames(join(cwd, '.specialists', 'default', 'specialists'));
76
- const userNames = readSpecialistNames(join(cwd, '.specialists', 'user', 'specialists'));
77
-
78
- // User takes precedence on name collision; merge and sort
79
- const allNames = [...new Set([...userNames, ...defaultNames])].sort();
80
-
81
- if (allNames.length > 0) {
82
- lines.push('## Specialists — Available');
83
- lines.push('');
84
- if (defaultNames.length > 0) {
85
- lines.push(`default (${defaultNames.length}): ${defaultNames.join(', ')}`);
86
- }
87
- if (userNames.length > 0) {
88
- const extraUser = userNames.filter(n => !defaultNames.includes(n));
89
- if (extraUser.length > 0) {
90
- lines.push(`user (${extraUser.length}): ${extraUser.join(', ')}`);
91
- }
92
- }
93
- lines.push('');
94
- }
95
-
96
- // ── 3. Key commands reminder ───────────────────────────────────────────────
97
- lines.push('## Specialists — Session Quick Reference');
98
- lines.push('');
99
- lines.push('```');
100
- lines.push('specialists list # discover available specialists');
101
- lines.push('specialists run <name> --prompt "..." # run foreground (streams output)');
102
- lines.push('process start "specialists run <name> --prompt "..."" name="sp-<name>" # async via process extension');
103
- lines.push('specialists run <name> --prompt "..." # foreground stream');
104
- lines.push('specialists feed <job-id> --follow # tail live events');
105
- lines.push('specialists result <job-id> # read final output');
106
- lines.push('specialists status # system health');
107
- lines.push('specialists doctor # troubleshoot issues');
108
- lines.push('```');
109
- lines.push('');
110
- lines.push('MCP tools: use_specialist (foreground only)');
111
-
112
- // ── Output ─────────────────────────────────────────────────────────────────
113
- if (lines.length === 0) process.exit(0);
114
-
115
- process.stdout.write(JSON.stringify({
116
- hookSpecificOutput: {
117
- hookEventName: 'SessionStart',
118
- additionalSystemPrompt: lines.join('\n'),
119
- },
120
- }) + '\n');