@jaggerxtrm/specialists 3.4.3 → 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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // specialists-complete — Claude Code UserPromptSubmit hook
2
+ // specialists-complete — Claude Code UserPromptSubmit/PostToolUse hook
3
3
  // Checks .specialists/ready/ for completed background job markers and injects
4
- // completion banners into Claude's context.
4
+ // completion/failure banners into Claude's context.
5
5
  //
6
6
  // Installed by: specialists install
7
7
 
@@ -32,16 +32,26 @@ for (const jobId of markers) {
32
32
  try {
33
33
  let specialist = jobId;
34
34
  let elapsed = '';
35
+ let completionStatus = 'done';
36
+ let errorMessage = '';
35
37
 
36
38
  if (existsSync(statusPath)) {
37
39
  const status = JSON.parse(readFileSync(statusPath, 'utf-8'));
38
40
  specialist = status.specialist ?? jobId;
39
41
  elapsed = status.elapsed_s !== undefined ? `, ${status.elapsed_s}s` : '';
42
+ completionStatus = status.status ?? 'done';
43
+ errorMessage = status.error ? ` — ${status.error}` : '';
40
44
  }
41
45
 
42
- banners.push(
43
- `[Specialist '${specialist}' completed (job ${jobId}${elapsed}). Run: specialists result ${jobId}]`
44
- );
46
+ if (completionStatus === 'error') {
47
+ banners.push(
48
+ `[Specialist '${specialist}' failed (job ${jobId}${elapsed}${errorMessage}). Run: specialists feed ${jobId} --follow]`
49
+ );
50
+ } else {
51
+ banners.push(
52
+ `[Specialist '${specialist}' completed (job ${jobId}${elapsed}). Run: specialists result ${jobId}]`
53
+ );
54
+ }
45
55
 
46
56
  // Delete marker so it only fires once
47
57
  unlinkSync(markerPath);
@@ -53,7 +63,7 @@ for (const jobId of markers) {
53
63
 
54
64
  if (banners.length === 0) process.exit(0);
55
65
 
56
- // UserPromptSubmit hooks inject content via JSON
66
+ // UserPromptSubmit/PostToolUse hooks inject content via JSON
57
67
  process.stdout.write(JSON.stringify({
58
68
  type: 'inject',
59
69
  content: banners.join('\n'),
@@ -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,14 +284,14 @@ 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 |
296
290
 
297
291
  MCP is intentionally minimal. Use CLI commands for orchestration, monitoring, steering,
298
292
  resume, and cancellation.
293
+ If you encounter legacy `start_specialist`, treat it as deprecated and migrate to
294
+ `specialists run <name> --prompt "..." --background`.
299
295
 
300
296
  ---
301
297
 
@@ -311,17 +307,10 @@ resume, and cancellation.
311
307
 
312
308
  ---
313
309
 
314
- ## Setup and Troubleshooting
315
-
316
- ```bash
317
- specialists init # first-time setup: creates .specialists/, wires AGENTS.md/CLAUDE.md
318
- specialists doctor # health check: hooks, MCP, zombie jobs
319
- specialists edit <name> # edit a specialist's YAML config
320
- ```
310
+ ## Troubleshooting
321
311
 
322
312
  - **"specialist not found"** → `specialists list` (project-scope only)
323
313
  - **Job hangs** → `specialists steer <id> "finish up"` or `specialists stop <id>`
324
- - **MCP tools missing** → `specialists init` then restart Claude Code
325
314
  - **YAML skipped** → stderr shows `[specialists] skipping <file>: <reason>`
326
315
  - **Stall timeout** → specialist hit 120s inactivity. Check `specialists feed <id>`, then retry or switch specialist.
327
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}`);
@@ -19297,6 +19303,7 @@ function ensureProjectHookWiring(cwd) {
19297
19303
  }
19298
19304
  }
19299
19305
  addHook("UserPromptSubmit", "node .claude/hooks/specialists-complete.mjs");
19306
+ addHook("PostToolUse", "node .claude/hooks/specialists-complete.mjs");
19300
19307
  addHook("SessionStart", "node .claude/hooks/specialists-session-start.mjs");
19301
19308
  if (changed) {
19302
19309
  saveJson(settingsPath, settings);
@@ -19344,11 +19351,18 @@ function installProjectSkills(cwd) {
19344
19351
  skip(`${totalSkipped} skill location${totalSkipped === 1 ? "" : "s"} already exist (not overwritten)`);
19345
19352
  }
19346
19353
  }
19347
- function createUserDirs(cwd) {
19354
+ function createSpecialistsDirs(cwd) {
19355
+ const defaultDir = join6(cwd, ".specialists", "default");
19348
19356
  const userDir = join6(cwd, ".specialists", "user");
19349
- if (!existsSync6(userDir)) {
19350
- mkdirSync(userDir, { recursive: true });
19351
- 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/");
19352
19366
  }
19353
19367
  }
19354
19368
  function createRuntimeDirs(cwd) {
@@ -19418,74 +19432,26 @@ function ensureAgentsMd(cwd) {
19418
19432
  ok("created AGENTS.md with Specialists section");
19419
19433
  }
19420
19434
  }
19421
- function hasPiSessionEnv() {
19422
- return Boolean(process.env.PI_SESSION_ID || process.env.PI_RPC_SOCKET || process.env.PI_AGENT_SESSION || process.env.PI_CODING_AGENT);
19423
- }
19424
- function readLinuxProcFile(path) {
19425
- try {
19426
- return readFileSync3(path, "utf-8");
19427
- } catch {
19428
- return null;
19429
- }
19430
- }
19431
- function getLinuxParentPid(pid) {
19432
- const status = readLinuxProcFile(`/proc/${pid}/status`);
19433
- if (!status)
19434
- return null;
19435
- const ppidLine = status.split(`
19436
- `).find((line) => line.startsWith("PPid:"));
19437
- if (!ppidLine)
19438
- return null;
19439
- const value = Number(ppidLine.replace("PPid:", "").trim());
19440
- return Number.isFinite(value) && value > 0 ? value : null;
19441
- }
19442
- function hasPiAncestorProcess(maxDepth = 8) {
19443
- let pid = process.ppid;
19444
- let depth = 0;
19445
- while (pid && depth < maxDepth) {
19446
- const cmdline = readLinuxProcFile(`/proc/${pid}/cmdline`);
19447
- if (!cmdline)
19448
- break;
19449
- const command = cmdline.replace(/\0/g, " ").trim();
19450
- const executable = basename2(command.split(" ")[0] ?? "");
19451
- const isPiExecutable = executable === "pi" || executable === "pi-coding-agent" || executable.startsWith("pi-");
19452
- if (isPiExecutable || command.includes("@mariozechner/pi-coding-agent")) {
19453
- return true;
19454
- }
19455
- pid = getLinuxParentPid(pid);
19456
- depth++;
19457
- }
19458
- return false;
19459
- }
19460
- function hasExistingDefaultSpecialists(cwd) {
19461
- const defaultDir = join6(cwd, ".specialists", "default");
19462
- const legacyNestedDir = join6(defaultDir, "specialists");
19463
- const hasFlat = existsSync6(defaultDir) && readdirSync2(defaultDir).some((file) => file.endsWith(".specialist.yaml"));
19464
- if (hasFlat)
19465
- return true;
19466
- return existsSync6(legacyNestedDir) && readdirSync2(legacyNestedDir).some((file) => file.endsWith(".specialist.yaml"));
19467
- }
19468
- function shouldSkipDefaultSyncInPiSession(cwd) {
19469
- if (process.env.SPECIALISTS_INIT_FORCE_DEFAULT_SYNC === "1")
19470
- return false;
19471
- if (!hasExistingDefaultSpecialists(cwd))
19472
- return false;
19473
- return hasPiSessionEnv() || hasPiAncestorProcess();
19474
- }
19475
- async function run5() {
19435
+ async function run5(opts = {}) {
19476
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
+ }
19477
19443
  console.log(`
19478
19444
  ${bold4("specialists init")}
19479
19445
  `);
19480
- const skipDefaultSync = shouldSkipDefaultSyncInPiSession(cwd);
19481
- if (skipDefaultSync) {
19482
- skip("pi session detected with existing default specialists; skipped .specialists/default sync");
19483
- } else {
19446
+ const { syncDefaults = false } = opts;
19447
+ if (syncDefaults) {
19484
19448
  migrateLegacySpecialists(cwd, "default");
19485
19449
  copyCanonicalSpecialists(cwd);
19450
+ } else {
19451
+ skip(".specialists/default/ not synced (pass --sync-defaults to write canonical specialists)");
19486
19452
  }
19487
19453
  migrateLegacySpecialists(cwd, "user");
19488
- createUserDirs(cwd);
19454
+ createSpecialistsDirs(cwd);
19489
19455
  createRuntimeDirs(cwd);
19490
19456
  ensureGitignore(cwd);
19491
19457
  ensureAgentsMd(cwd);
@@ -19504,7 +19470,7 @@ ${bold4("Done!")}
19504
19470
  console.log("");
19505
19471
  console.log(` ${dim4(".specialists/ structure:")}`);
19506
19472
  console.log(` .specialists/`);
19507
- console.log(` ├── default/ ${dim4("# canonical specialists (from init)")}`);
19473
+ console.log(` ├── default/ ${dim4("# canonical specialists (from init --sync-defaults)")}`);
19508
19474
  console.log(` ├── user/ ${dim4("# your custom specialists")}`);
19509
19475
  console.log(` ├── jobs/ ${dim4("# runtime (gitignored)")}`);
19510
19476
  console.log(` └── ready/ ${dim4("# runtime (gitignored)")}`);
@@ -19520,7 +19486,7 @@ var init_init = __esm(() => {
19520
19486
  AGENTS_BLOCK = `
19521
19487
  ## Specialists
19522
19488
 
19523
- Call \`specialists init\` once per project, then use CLI commands via Bash.
19489
+ Use CLI commands via Bash to run and monitor specialists:
19524
19490
 
19525
19491
  Core specialist commands (CLI-first in pi):
19526
19492
  - \`specialists list\`
@@ -19824,7 +19790,7 @@ __export(exports_config, {
19824
19790
  });
19825
19791
  import { existsSync as existsSync9 } from "node:fs";
19826
19792
  import { readdir as readdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
19827
- import { basename as basename3, join as join9 } from "node:path";
19793
+ import { basename as basename2, join as join9 } from "node:path";
19828
19794
  function usage() {
19829
19795
  return [
19830
19796
  "Usage:",
@@ -19933,7 +19899,7 @@ async function getAcrossFiles(files, keyPath) {
19933
19899
  const content = await readFile3(file, "utf-8");
19934
19900
  const doc2 = $parseDocument(content);
19935
19901
  const value = doc2.getIn(keyPath);
19936
- const name = getSpecialistNameFromPath(basename3(file));
19902
+ const name = getSpecialistNameFromPath(basename2(file));
19937
19903
  console.log(`${yellow7(name)}: ${formatValue(value)}`);
19938
19904
  }
19939
19905
  }
@@ -20201,6 +20167,10 @@ class Supervisor {
20201
20167
  readyDir() {
20202
20168
  return join10(this.opts.jobsDir, "..", "ready");
20203
20169
  }
20170
+ writeReadyMarker(id) {
20171
+ mkdirSync2(this.readyDir(), { recursive: true });
20172
+ writeFileSync3(join10(this.readyDir(), id), "", "utf-8");
20173
+ }
20204
20174
  readStatus(id) {
20205
20175
  const path = this.statusPath(id);
20206
20176
  if (!existsSync10(path))
@@ -20367,6 +20337,58 @@ class Supervisor {
20367
20337
  let closeFn;
20368
20338
  let fifoReadStream;
20369
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
+ };
20370
20392
  const thresholds = {
20371
20393
  ...STALL_DETECTION_DEFAULTS,
20372
20394
  ...this.opts.stallDetection
@@ -20412,7 +20434,13 @@ class Supervisor {
20412
20434
  }
20413
20435
  }
20414
20436
  }, 1e4);
20415
- const sigtermHandler = () => killFn?.();
20437
+ const sigtermHandler = () => {
20438
+ if (keepAliveSession) {
20439
+ closeKeepAliveSession();
20440
+ return;
20441
+ }
20442
+ killFn?.();
20443
+ };
20416
20444
  process.once("SIGTERM", sigtermHandler);
20417
20445
  try {
20418
20446
  const result = await runner.run(runOptions, (delta) => {
@@ -20464,48 +20492,21 @@ class Supervisor {
20464
20492
  if (parsed?.type === "steer" && typeof parsed.message === "string") {
20465
20493
  steerFn?.(parsed.message).catch(() => {});
20466
20494
  } else if (parsed?.type === "resume" && typeof parsed.task === "string") {
20467
- if (resumeFn) {
20468
- setStatus({ status: "running", current_event: "starting" });
20469
- resumeFn(parsed.task).then((output) => {
20470
- mkdirSync2(this.jobDir(id), { recursive: true });
20471
- writeFileSync3(this.resultPath(id), output, "utf-8");
20472
- setStatus({
20473
- status: "waiting",
20474
- current_event: "waiting",
20475
- elapsed_s: Math.round((Date.now() - startedAtMs) / 1000),
20476
- last_event_at_ms: Date.now()
20477
- });
20478
- }).catch((err) => {
20479
- setStatus({ status: "error", error: err?.message ?? String(err) });
20480
- });
20481
- }
20495
+ handleResumeTurn(parsed.task);
20482
20496
  } else if (parsed?.type === "prompt" && typeof parsed.message === "string") {
20483
20497
  console.error('[specialists] DEPRECATED: FIFO message {type:"prompt"} is deprecated. Use {type:"resume", task:"..."} instead.');
20484
- if (resumeFn) {
20485
- setStatus({ status: "running", current_event: "starting" });
20486
- resumeFn(parsed.message).then((output) => {
20487
- mkdirSync2(this.jobDir(id), { recursive: true });
20488
- writeFileSync3(this.resultPath(id), output, "utf-8");
20489
- setStatus({
20490
- status: "waiting",
20491
- current_event: "waiting",
20492
- elapsed_s: Math.round((Date.now() - startedAtMs) / 1000),
20493
- last_event_at_ms: Date.now()
20494
- });
20495
- }).catch((err) => {
20496
- setStatus({ status: "error", error: err?.message ?? String(err) });
20497
- });
20498
- }
20498
+ handleResumeTurn(parsed.message);
20499
20499
  } else if (parsed?.type === "close") {
20500
- closeFn?.().catch(() => {});
20500
+ closeKeepAliveSession();
20501
20501
  }
20502
20502
  } catch {}
20503
20503
  });
20504
20504
  fifoReadline.on("error", () => {});
20505
20505
  }, (rFn, cFn) => {
20506
+ keepAliveSession = true;
20506
20507
  resumeFn = rFn;
20507
20508
  closeFn = cFn;
20508
- setStatus({ status: "waiting", current_event: "waiting" });
20509
+ setStatus({ status: "waiting", current_event: "waiting", last_event_at_ms: Date.now() });
20509
20510
  }, (tool, args, toolCallId) => {
20510
20511
  currentTool = tool;
20511
20512
  currentToolArgs = args;
@@ -20531,43 +20532,62 @@ class Supervisor {
20531
20532
  toolStartMs = undefined;
20532
20533
  toolDurationWarnEmitted = false;
20533
20534
  });
20534
- const elapsed = Math.round((Date.now() - startedAtMs) / 1000);
20535
+ latestOutput = result.output;
20535
20536
  mkdirSync2(this.jobDir(id), { recursive: true });
20536
- 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
+ };
20537
20558
  const inputBeadId = runOptions.inputBeadId;
20538
- const ownsBead = Boolean(result.beadId && !inputBeadId);
20559
+ const ownsBead = Boolean(finalResult.beadId && !inputBeadId);
20539
20560
  const shouldWriteExternalBeadNotes = runOptions.beadsWriteNotes ?? true;
20540
- const shouldAppendReadOnlyResultToInputBead = Boolean(inputBeadId && result.permissionRequired === "READ_ONLY" && this.opts.beadsClient);
20541
- if (ownsBead && result.beadId) {
20542
- 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));
20543
20564
  } else if (shouldWriteExternalBeadNotes) {
20544
20565
  if (shouldAppendReadOnlyResultToInputBead && inputBeadId) {
20545
- this.opts.beadsClient?.updateBeadNotes(inputBeadId, formatBeadNotes(result));
20546
- } else if (result.beadId) {
20547
- 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));
20548
20569
  }
20549
20570
  }
20550
- if (result.beadId) {
20571
+ if (finalResult.beadId) {
20551
20572
  if (!inputBeadId) {
20552
- this.opts.beadsClient?.closeBead(result.beadId, "COMPLETE", result.durationMs, result.model);
20573
+ this.opts.beadsClient?.closeBead(finalResult.beadId, "COMPLETE", finalResult.durationMs, finalResult.model);
20553
20574
  }
20554
20575
  }
20555
20576
  setStatus({
20556
20577
  status: "done",
20557
20578
  elapsed_s: elapsed,
20558
20579
  last_event_at_ms: Date.now(),
20559
- model: result.model,
20560
- backend: result.backend,
20561
- bead_id: result.beadId
20580
+ model: finalResult.model,
20581
+ backend: finalResult.backend,
20582
+ bead_id: finalResult.beadId
20562
20583
  });
20563
20584
  appendTimelineEvent(createRunCompleteEvent("COMPLETE", elapsed, {
20564
- model: result.model,
20565
- backend: result.backend,
20566
- bead_id: result.beadId,
20567
- output: result.output
20585
+ model: finalResult.model,
20586
+ backend: finalResult.backend,
20587
+ bead_id: finalResult.beadId,
20588
+ output: finalResult.output
20568
20589
  }));
20569
- mkdirSync2(this.readyDir(), { recursive: true });
20570
- writeFileSync3(join10(this.readyDir(), id), "", "utf-8");
20590
+ this.writeReadyMarker(id);
20571
20591
  return id;
20572
20592
  } catch (err) {
20573
20593
  const elapsed = Math.round((Date.now() - startedAtMs) / 1000);
@@ -20580,6 +20600,7 @@ class Supervisor {
20580
20600
  appendTimelineEvent(createRunCompleteEvent("ERROR", elapsed, {
20581
20601
  error: errorMsg
20582
20602
  }));
20603
+ this.writeReadyMarker(id);
20583
20604
  throw err;
20584
20605
  } finally {
20585
20606
  if (stuckIntervalId !== undefined)
@@ -20794,6 +20815,9 @@ function createTmuxSession(name, cwd, cmd, extraEnv = {}) {
20794
20815
  throw new Error(`Failed to create tmux session "${name}": ${errorOutput}`);
20795
20816
  }
20796
20817
  }
20818
+ function killTmuxSession(name) {
20819
+ spawnSync7("tmux", ["kill-session", "-t", name], { encoding: "utf8", stdio: "pipe" });
20820
+ }
20797
20821
  var TMUX_SESSION_PREFIX = "sp";
20798
20822
  var init_tmux_utils = () => {};
20799
20823
 
@@ -21628,7 +21652,14 @@ var exports_feed = {};
21628
21652
  __export(exports_feed, {
21629
21653
  run: () => run12
21630
21654
  });
21631
- 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";
21632
21663
  import { join as join15 } from "node:path";
21633
21664
  function getHumanEventKey(event) {
21634
21665
  switch (event.type) {
@@ -21692,31 +21723,55 @@ function parseSince(value) {
21692
21723
  }
21693
21724
  return;
21694
21725
  }
21695
- 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) {
21696
21739
  const statusPath = join15(jobsDir, jobId, "status.json");
21740
+ const raw = readFileFresh(statusPath);
21741
+ if (!raw)
21742
+ return null;
21697
21743
  try {
21698
- const status = JSON.parse(readFileSync10(statusPath, "utf-8"));
21699
- return status.status === "done" || status.status === "error";
21744
+ return JSON.parse(raw);
21700
21745
  } catch {
21701
- return false;
21746
+ return null;
21702
21747
  }
21703
21748
  }
21704
- 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
+ }
21705
21769
  const cache = new Map;
21706
21770
  return (jobId) => {
21707
- if (cache.has(jobId))
21708
- return cache.get(jobId);
21709
- const statusPath = join15(jobsDir, jobId, "status.json");
21710
- let meta = { startedAtMs: Date.now() };
21711
- try {
21712
- const status = JSON.parse(readFileSync10(statusPath, "utf-8"));
21713
- meta = {
21714
- model: status.model,
21715
- backend: status.backend,
21716
- beadId: status.bead_id,
21717
- startedAtMs: status.started_at_ms ?? Date.now()
21718
- };
21719
- } catch {}
21771
+ const cached2 = cache.get(jobId);
21772
+ if (cached2)
21773
+ return cached2;
21774
+ const meta = readJobMeta(jobsDir, jobId);
21720
21775
  cache.set(jobId, meta);
21721
21776
  return meta;
21722
21777
  };
@@ -21725,6 +21780,7 @@ function parseArgs8(argv) {
21725
21780
  let jobId;
21726
21781
  let specialist;
21727
21782
  let since;
21783
+ let from = 0;
21728
21784
  let limit = 100;
21729
21785
  let follow = false;
21730
21786
  let forever = false;
@@ -21742,6 +21798,11 @@ function parseArgs8(argv) {
21742
21798
  since = parseSince(argv[++i]);
21743
21799
  continue;
21744
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
+ }
21745
21806
  if (argv[i] === "--limit" && argv[i + 1]) {
21746
21807
  limit = parseInt(argv[++i], 10);
21747
21808
  continue;
@@ -21761,7 +21822,7 @@ function parseArgs8(argv) {
21761
21822
  if (!jobId && !argv[i].startsWith("--"))
21762
21823
  jobId = argv[i];
21763
21824
  }
21764
- return { jobId, specialist, since, limit, follow, forever, json };
21825
+ return { jobId, specialist, since, from, limit, follow, forever, json };
21765
21826
  }
21766
21827
  function printSnapshot(merged, options, jobsDir) {
21767
21828
  if (merged.length === 0) {
@@ -21806,36 +21867,106 @@ function printSnapshot(merged, options, jobsDir) {
21806
21867
  function isCompletionEvent(event) {
21807
21868
  return isRunCompleteEvent(event);
21808
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
+ }
21809
21937
  async function followMerged(jobsDir, options) {
21810
21938
  const colorMap = new JobColorMap;
21811
- const getJobMeta = makeJobMetaReader(jobsDir);
21939
+ const getJobMeta = makeJobMetaReader(jobsDir, { useCache: false });
21812
21940
  const lastSeenT = new Map;
21941
+ const trackedJobs = new Set(listMatchingJobIds(jobsDir, options).filter((jobId) => !isTerminalJobStatus(jobsDir, jobId)));
21813
21942
  const completedJobs = new Set;
21814
- const filteredBatches = () => readAllJobEvents(jobsDir).filter((batch) => !options.jobId || batch.jobId === options.jobId).filter((batch) => !options.specialist || batch.specialist === options.specialist);
21815
- const initial = queryTimeline(jobsDir, {
21943
+ const filteredBatches = () => readFilteredBatchesFresh(jobsDir, options);
21944
+ const initial = filterMergedEventsByCursor(queryTimeline(jobsDir, {
21816
21945
  jobId: options.jobId,
21817
21946
  specialist: options.specialist,
21818
21947
  since: options.since,
21819
21948
  limit: options.limit
21820
- });
21949
+ }), options.from);
21821
21950
  printSnapshot(initial, { ...options, json: options.json }, jobsDir);
21822
21951
  for (const batch of filteredBatches()) {
21823
21952
  if (batch.events.length > 0) {
21824
21953
  const maxT = Math.max(...batch.events.map((event) => event.t));
21825
21954
  lastSeenT.set(batch.jobId, maxT);
21826
21955
  }
21827
- if (batch.events.some(isCompletionEvent) || isTerminalJobStatus(jobsDir, batch.jobId)) {
21956
+ if (trackedJobs.has(batch.jobId) && batch.events.some(isCompletionEvent)) {
21828
21957
  completedJobs.add(batch.jobId);
21829
21958
  }
21830
21959
  }
21831
- const initialBatchCount = filteredBatches().length;
21832
- if (!options.forever && initialBatchCount > 0 && completedJobs.size === initialBatchCount) {
21960
+ if (!options.forever && trackedJobs.size === 0) {
21833
21961
  if (!options.json) {
21834
21962
  process.stderr.write(dim7(`All jobs complete.
21835
21963
  `));
21836
21964
  }
21837
21965
  return;
21838
21966
  }
21967
+ if (!options.forever && trackedJobs.size > 0 && completedJobs.size === trackedJobs.size) {
21968
+ return;
21969
+ }
21839
21970
  if (!options.json) {
21840
21971
  process.stderr.write(dim7(`Following... (Ctrl+C to stop)
21841
21972
  `));
@@ -21845,11 +21976,21 @@ async function followMerged(jobsDir, options) {
21845
21976
  await new Promise((resolve2) => {
21846
21977
  const interval = setInterval(() => {
21847
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
+ }
21848
21989
  const newEvents = [];
21849
21990
  for (const batch of batches) {
21850
21991
  const lastT = lastSeenT.get(batch.jobId) ?? 0;
21851
21992
  for (const event of batch.events) {
21852
- if (event.t > lastT) {
21993
+ if (event.t > lastT && isEventAtOrAfterCursor(event, options.from)) {
21853
21994
  newEvents.push({
21854
21995
  jobId: batch.jobId,
21855
21996
  specialist: batch.specialist,
@@ -21862,7 +22003,7 @@ async function followMerged(jobsDir, options) {
21862
22003
  const maxT = Math.max(...batch.events.map((e) => e.t));
21863
22004
  lastSeenT.set(batch.jobId, maxT);
21864
22005
  }
21865
- if (batch.events.some(isCompletionEvent) || isTerminalJobStatus(jobsDir, batch.jobId)) {
22006
+ if (trackedJobs.has(batch.jobId) && (batch.events.some(isCompletionEvent) || isTerminalJobStatus(jobsDir, batch.jobId))) {
21866
22007
  completedJobs.add(batch.jobId);
21867
22008
  }
21868
22009
  }
@@ -21892,7 +22033,7 @@ async function followMerged(jobsDir, options) {
21892
22033
  console.log(formatEventLine(event, { jobId, specialist: specialistDisplay, beadId, colorize }));
21893
22034
  }
21894
22035
  }
21895
- if (!options.forever && batches.length > 0 && completedJobs.size === batches.length) {
22036
+ if (!options.forever && trackedJobs.size > 0 && completedJobs.size === trackedJobs.size) {
21896
22037
  clearInterval(interval);
21897
22038
  resolve2();
21898
22039
  }
@@ -21906,16 +22047,19 @@ async function run12() {
21906
22047
  console.log(dim7("No jobs directory found."));
21907
22048
  return;
21908
22049
  }
22050
+ if (options.from > 0 && !options.json) {
22051
+ console.log(dim7(`Showing events from seq ${options.from}`));
22052
+ }
21909
22053
  if (options.follow) {
21910
22054
  await followMerged(jobsDir, options);
21911
22055
  return;
21912
22056
  }
21913
- const merged = queryTimeline(jobsDir, {
22057
+ const merged = filterMergedEventsByCursor(queryTimeline(jobsDir, {
21914
22058
  jobId: options.jobId,
21915
22059
  specialist: options.specialist,
21916
22060
  since: options.since,
21917
22061
  limit: options.limit
21918
- });
22062
+ }), options.from);
21919
22063
  printSnapshot(merged, options, jobsDir);
21920
22064
  }
21921
22065
  var init_feed = __esm(() => {
@@ -22159,10 +22303,10 @@ __export(exports_clean, {
22159
22303
  });
22160
22304
  import {
22161
22305
  existsSync as existsSync16,
22162
- readdirSync as readdirSync6,
22306
+ readdirSync as readdirSync7,
22163
22307
  readFileSync as readFileSync12,
22164
22308
  rmSync as rmSync2,
22165
- statSync as statSync2
22309
+ statSync as statSync3
22166
22310
  } from "node:fs";
22167
22311
  import { join as join19 } from "node:path";
22168
22312
  function parseTtlDaysFromEnvironment() {
@@ -22218,10 +22362,10 @@ function parseOptions(argv) {
22218
22362
  }
22219
22363
  function readDirectorySizeBytes(directoryPath) {
22220
22364
  let totalBytes = 0;
22221
- const entries = readdirSync6(directoryPath, { withFileTypes: true });
22365
+ const entries = readdirSync7(directoryPath, { withFileTypes: true });
22222
22366
  for (const entry of entries) {
22223
22367
  const entryPath = join19(directoryPath, entry.name);
22224
- const stats = statSync2(entryPath);
22368
+ const stats = statSync3(entryPath);
22225
22369
  if (stats.isDirectory()) {
22226
22370
  totalBytes += readDirectorySizeBytes(entryPath);
22227
22371
  continue;
@@ -22245,7 +22389,7 @@ function readCompletedJobDirectory(baseDirectory, entry) {
22245
22389
  }
22246
22390
  if (!COMPLETED_STATUSES.has(statusData.status))
22247
22391
  return null;
22248
- const directoryStats = statSync2(directoryPath);
22392
+ const directoryStats = statSync3(directoryPath);
22249
22393
  return {
22250
22394
  id: entry.name,
22251
22395
  directoryPath,
@@ -22255,7 +22399,7 @@ function readCompletedJobDirectory(baseDirectory, entry) {
22255
22399
  };
22256
22400
  }
22257
22401
  function collectCompletedJobDirectories(jobsDirectoryPath) {
22258
- const entries = readdirSync6(jobsDirectoryPath, { withFileTypes: true });
22402
+ const entries = readdirSync7(jobsDirectoryPath, { withFileTypes: true });
22259
22403
  const completedJobs = [];
22260
22404
  for (const entry of entries) {
22261
22405
  const completedJob = readCompletedJobDirectory(jobsDirectoryPath, entry);
@@ -22372,14 +22516,25 @@ async function run18() {
22372
22516
  `);
22373
22517
  process.exit(1);
22374
22518
  }
22519
+ const tmuxSession = status.tmux_session;
22375
22520
  try {
22376
22521
  process.kill(status.pid, "SIGTERM");
22377
22522
  process.stdout.write(`${green11("✓")} Sent SIGTERM to PID ${status.pid} (job ${jobId})
22378
22523
  `);
22524
+ if (tmuxSession) {
22525
+ killTmuxSession(tmuxSession);
22526
+ process.stdout.write(`${dim10(` tmux session ${tmuxSession} killed`)}
22527
+ `);
22528
+ }
22379
22529
  } catch (err) {
22380
22530
  if (err.code === "ESRCH") {
22381
22531
  process.stderr.write(`${red6(`Process ${status.pid} not found.`)} Job may have already completed.
22382
22532
  `);
22533
+ if (tmuxSession) {
22534
+ killTmuxSession(tmuxSession);
22535
+ process.stdout.write(`${dim10(` tmux session ${tmuxSession} killed`)}
22536
+ `);
22537
+ }
22383
22538
  } else {
22384
22539
  process.stderr.write(`${red6("Error:")} ${err.message}
22385
22540
  `);
@@ -22390,6 +22545,7 @@ async function run18() {
22390
22545
  var green11 = (s) => `\x1B[32m${s}\x1B[0m`, red6 = (s) => `\x1B[31m${s}\x1B[0m`, dim10 = (s) => `\x1B[2m${s}\x1B[0m`;
22391
22546
  var init_stop = __esm(() => {
22392
22547
  init_supervisor();
22548
+ init_tmux_utils();
22393
22549
  });
22394
22550
 
22395
22551
  // src/cli/attach.ts
@@ -22678,7 +22834,7 @@ __export(exports_doctor, {
22678
22834
  run: () => run21
22679
22835
  });
22680
22836
  import { spawnSync as spawnSync10 } from "node:child_process";
22681
- 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";
22682
22838
  import { join as join22 } from "node:path";
22683
22839
  function ok3(msg) {
22684
22840
  console.log(` ${green13("✓")} ${msg}`);
@@ -22850,7 +23006,7 @@ function checkZombieJobs() {
22850
23006
  }
22851
23007
  let entries;
22852
23008
  try {
22853
- entries = readdirSync7(jobsDir);
23009
+ entries = readdirSync8(jobsDir);
22854
23010
  } catch {
22855
23011
  entries = [];
22856
23012
  }
@@ -30577,34 +30733,39 @@ async function run24() {
30577
30733
  if (wantsHelp()) {
30578
30734
  console.log([
30579
30735
  "",
30580
- "Usage: specialists init [--force-workflow]",
30736
+ "Usage: specialists init [--sync-defaults]",
30581
30737
  "",
30582
30738
  "Bootstrap a project for specialists. This is the sole onboarding command.",
30583
30739
  "",
30584
- "What it does:",
30585
- " • creates specialists/ for project .specialist.yaml files",
30586
- " • creates .specialists/ runtime dirs (jobs/, ready/)",
30587
- " • adds .specialists/ to .gitignore",
30588
- " • injects the managed workflow block into AGENTS.md and CLAUDE.md",
30589
- " • 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/",
30590
30748
  "",
30591
30749
  "Options:",
30592
- " --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.",
30593
30752
  "",
30594
30753
  "Examples:",
30595
- " specialists init",
30596
- " specialists init --force-workflow",
30754
+ " specialists init # safe for agents to call",
30755
+ " specialists init --sync-defaults # human-only: sync canonical specialists",
30597
30756
  "",
30598
30757
  "Notes:",
30599
30758
  " setup and install are deprecated; use specialists init.",
30600
- " 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).",
30601
30761
  ""
30602
30762
  ].join(`
30603
30763
  `));
30604
30764
  return;
30605
30765
  }
30766
+ const syncDefaults = process.argv.includes("--sync-defaults");
30606
30767
  const { run: handler } = await Promise.resolve().then(() => (init_init(), exports_init));
30607
- return handler();
30768
+ return handler({ syncDefaults });
30608
30769
  }
30609
30770
  if (sub === "validate") {
30610
30771
  if (wantsHelp()) {
@@ -30812,11 +30973,13 @@ async function run24() {
30812
30973
  " specialists feed -f Follow all jobs globally",
30813
30974
  "",
30814
30975
  "Options:",
30976
+ " --from <n> Show only events with seq >= <n>",
30815
30977
  " -f, --follow Follow live updates",
30816
30978
  " --forever Keep following in global mode even when all jobs complete",
30817
30979
  "",
30818
30980
  "Examples:",
30819
30981
  " specialists feed 49adda",
30982
+ " specialists feed 49adda --from 15",
30820
30983
  " specialists feed 49adda --follow",
30821
30984
  " specialists feed -f",
30822
30985
  " specialists feed -f --forever",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaggerxtrm/specialists",
3
- "version": "3.4.3",
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: specialist_init · use_specialist · start_specialist · feed_specialist · run_parallel');
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');