@jaggerxtrm/specialists 3.14.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +24 -3
  3. package/config/catalog/gitnexus.json +12 -0
  4. package/config/catalog/index.json +59 -0
  5. package/config/catalog/native.json +12 -0
  6. package/config/catalog/serena.json +12 -0
  7. package/config/mandatory-rules/README.md +7 -6
  8. package/config/mandatory-rules/changelog-keeper-scope.md +18 -30
  9. package/config/mandatory-rules/code-quality-defaults.md +5 -0
  10. package/config/mandatory-rules/diagnose-loop.md +13 -0
  11. package/config/mandatory-rules/gitnexus-required.md +1 -0
  12. package/config/mandatory-rules/research-tool-routing.md +12 -0
  13. package/config/mandatory-rules/security-review-defaults.md +9 -0
  14. package/config/mandatory-rules/serena-cheatsheet.md +16 -4
  15. package/config/presets.json +1 -1
  16. package/config/skills/memory-audit-transaction/SKILL.md +196 -0
  17. package/config/skills/memory-audit-transaction/scripts/pre-bulk-export.sh +58 -0
  18. package/config/skills/using-specialists/SKILL.md +13 -12
  19. package/config/skills/using-specialists-auto/SKILL.md +137 -0
  20. package/config/skills/using-specialists-v2/SKILL.md +14 -21
  21. package/config/skills/using-specialists-v3/SKILL.md +399 -27
  22. package/config/specialists/changelog-drafter.specialist.json +3 -2
  23. package/config/specialists/changelog-keeper.specialist.json +8 -13
  24. package/config/specialists/code-sanity.specialist.json +3 -5
  25. package/config/specialists/debugger.specialist.json +4 -8
  26. package/config/specialists/executor.specialist.json +6 -8
  27. package/config/specialists/explorer.specialist.json +7 -8
  28. package/config/specialists/memory-processor.specialist.json +14 -7
  29. package/config/specialists/node-coordinator.specialist.json +2 -2
  30. package/config/specialists/overthinker.specialist.json +7 -10
  31. package/config/specialists/planner.specialist.json +3 -4
  32. package/config/specialists/researcher.specialist.json +15 -19
  33. package/config/specialists/reviewer.specialist.json +4 -8
  34. package/config/specialists/security-auditor.specialist.json +3 -8
  35. package/config/specialists/specialists-creator.specialist.json +4 -2
  36. package/config/specialists/test-runner.specialist.json +10 -10
  37. package/config/specialists/xt-merge.specialist.json +10 -4
  38. package/dist/asset-contract.json +205 -0
  39. package/dist/index.js +1990 -704
  40. package/dist/lib.js +99 -17
  41. package/dist/types/cli/clean.d.ts.map +1 -1
  42. package/dist/types/cli/doctor.d.ts +1 -0
  43. package/dist/types/cli/doctor.d.ts.map +1 -1
  44. package/dist/types/cli/edit.d.ts.map +1 -1
  45. package/dist/types/cli/epic.d.ts +0 -1
  46. package/dist/types/cli/epic.d.ts.map +1 -1
  47. package/dist/types/cli/feed.d.ts.map +1 -1
  48. package/dist/types/cli/finalize.d.ts +2 -0
  49. package/dist/types/cli/finalize.d.ts.map +1 -0
  50. package/dist/types/cli/format-helpers.d.ts.map +1 -1
  51. package/dist/types/cli/init.d.ts.map +1 -1
  52. package/dist/types/cli/list-rules.d.ts.map +1 -1
  53. package/dist/types/cli/merge.d.ts +4 -3
  54. package/dist/types/cli/merge.d.ts.map +1 -1
  55. package/dist/types/cli/ps.d.ts.map +1 -1
  56. package/dist/types/cli/quickstart.d.ts.map +1 -1
  57. package/dist/types/cli/run.d.ts +1 -0
  58. package/dist/types/cli/run.d.ts.map +1 -1
  59. package/dist/types/pi/session.d.ts.map +1 -1
  60. package/dist/types/specialist/epic-lifecycle.d.ts +5 -5
  61. package/dist/types/specialist/epic-lifecycle.d.ts.map +1 -1
  62. package/dist/types/specialist/epic-readiness.d.ts +1 -1
  63. package/dist/types/specialist/epic-readiness.d.ts.map +1 -1
  64. package/dist/types/specialist/jobRegistry.d.ts +5 -0
  65. package/dist/types/specialist/jobRegistry.d.ts.map +1 -1
  66. package/dist/types/specialist/observability-sqlite.d.ts +8 -0
  67. package/dist/types/specialist/observability-sqlite.d.ts.map +1 -1
  68. package/dist/types/specialist/process-health.d.ts +77 -0
  69. package/dist/types/specialist/process-health.d.ts.map +1 -0
  70. package/dist/types/specialist/runner.d.ts.map +1 -1
  71. package/dist/types/specialist/schema.d.ts +162 -0
  72. package/dist/types/specialist/schema.d.ts.map +1 -1
  73. package/dist/types/specialist/script-runner.d.ts +31 -1
  74. package/dist/types/specialist/script-runner.d.ts.map +1 -1
  75. package/dist/types/specialist/supervisor.d.ts +8 -0
  76. package/dist/types/specialist/supervisor.d.ts.map +1 -1
  77. package/dist/types/specialist/timeline-query.d.ts +1 -1
  78. package/dist/types/specialist/timeline-query.d.ts.map +1 -1
  79. package/dist/types/specialist/worktree.d.ts.map +1 -1
  80. package/package.json +32 -7
  81. package/config/benchmarks/executor-benchmark-matrix.json +0 -25
  82. package/config/mandatory-rules/debugger-trace-first.md +0 -5
  83. package/config/skills/using-specialists/evals/evals.json +0 -68
  84. package/config/skills/using-specialists-v3/evals/evals.json +0 -89
package/dist/index.js CHANGED
@@ -17571,7 +17571,8 @@ var init_schema = __esm(() => {
17571
17571
  extensions: objectType({
17572
17572
  serena: booleanType().optional(),
17573
17573
  gitnexus: booleanType().optional()
17574
- }).passthrough().optional()
17574
+ }).passthrough().optional(),
17575
+ expected_output_keys: arrayType(stringType()).optional()
17575
17576
  }).passthrough();
17576
17577
  PromptSchema2 = objectType({
17577
17578
  system: stringType().optional(),
@@ -17801,6 +17802,22 @@ var init_loader = __esm(() => {
17801
17802
  init_canonical_asset_resolver();
17802
17803
  });
17803
17804
 
17805
+ // src/specialist/job-file-output.ts
17806
+ function normalizeMode(value) {
17807
+ return (value ?? "").trim().toLowerCase();
17808
+ }
17809
+ function detectJobFileOutputMode(env = process.env) {
17810
+ const normalized = normalizeMode(env.SPECIALISTS_JOB_FILE_OUTPUT);
17811
+ if (normalized === "on" || normalized === "1" || normalized === "true")
17812
+ return "on";
17813
+ if (normalized === "off" || normalized === "0" || normalized === "false")
17814
+ return "off";
17815
+ return "off";
17816
+ }
17817
+ function isJobFileOutputEnabled(env = process.env) {
17818
+ return detectJobFileOutputMode(env) === "on";
17819
+ }
17820
+
17804
17821
  // src/specialist/templateEngine.ts
17805
17822
  function renderTemplate(template, variables) {
17806
17823
  return template.replace(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, key) => {
@@ -18019,12 +18036,21 @@ import { isAbsolute, resolve, sep, join as join2, dirname } from "path";
18019
18036
  function loadSharedToolCatalogIndex() {
18020
18037
  if (cachedToolCatalogIndex)
18021
18038
  return cachedToolCatalogIndex;
18039
+ const overridePath = resolve(process.cwd(), ".specialists", "catalog", "index.json");
18022
18040
  try {
18023
- const indexPath = resolve(process.cwd(), ".specialists", "catalog", "index.json");
18024
- cachedToolCatalogIndex = loadToolCatalogIndex(readFileSync(indexPath, "utf8"));
18041
+ cachedToolCatalogIndex = loadToolCatalogIndex(readFileSync(overridePath, "utf8"));
18025
18042
  return cachedToolCatalogIndex;
18026
18043
  } catch {
18027
- return;
18044
+ try {
18045
+ const canonicalDir = resolveCanonicalAssetDir("catalog");
18046
+ if (!canonicalDir)
18047
+ return;
18048
+ const canonicalPath = resolve(canonicalDir, "index.json");
18049
+ cachedToolCatalogIndex = loadToolCatalogIndex(readFileSync(canonicalPath, "utf8"));
18050
+ return cachedToolCatalogIndex;
18051
+ } catch {
18052
+ return;
18053
+ }
18028
18054
  }
18029
18055
  }
18030
18056
  function probeExtensionHealth(packageName) {
@@ -18370,6 +18396,10 @@ class PiAgentSession {
18370
18396
  "--no-extensions",
18371
18397
  ...providerArgs,
18372
18398
  "--no-session",
18399
+ "--offline",
18400
+ "--no-context-files",
18401
+ "--no-prompt-templates",
18402
+ "--no-themes",
18373
18403
  ...extraArgs
18374
18404
  ];
18375
18405
  const toolsFlag = resolvePermissionTools({
@@ -18429,7 +18459,8 @@ class PiAgentSession {
18429
18459
  this.proc = spawn("pi", args, {
18430
18460
  stdio: ["pipe", "pipe", "pipe"],
18431
18461
  cwd: sessionCwd,
18432
- env: worktreeBoundary ? { ...baseEnv, [WORKTREE_BOUNDARY_ENV_KEY]: worktreeBoundary } : baseEnv
18462
+ env: worktreeBoundary ? { ...baseEnv, [WORKTREE_BOUNDARY_ENV_KEY]: worktreeBoundary } : baseEnv,
18463
+ detached: true
18433
18464
  });
18434
18465
  const donePromise = new Promise((resolve2, reject) => {
18435
18466
  this._doneResolve = resolve2;
@@ -18854,14 +18885,17 @@ class PiAgentSession {
18854
18885
  this._clearStallTimer();
18855
18886
  this.proc?.stdin?.end();
18856
18887
  if (this.proc) {
18888
+ const proc = this.proc;
18857
18889
  await new Promise((resolve2) => {
18858
- this.proc.on("close", () => resolve2());
18890
+ proc.on("close", () => resolve2());
18859
18891
  setTimeout(() => {
18860
- if (this.proc && !this._killed) {
18861
- this.proc.kill();
18892
+ if (proc.exitCode === null && proc.pid != null) {
18893
+ try {
18894
+ process.kill(-proc.pid, "SIGKILL");
18895
+ } catch {}
18862
18896
  }
18863
18897
  resolve2();
18864
- }, 2000);
18898
+ }, 8000);
18865
18899
  });
18866
18900
  }
18867
18901
  }
@@ -18882,8 +18916,17 @@ class PiAgentSession {
18882
18916
  entry.reject(killError);
18883
18917
  }
18884
18918
  this._pendingRequests.clear();
18885
- this.proc?.kill();
18919
+ const proc = this.proc;
18886
18920
  this.proc = undefined;
18921
+ proc?.kill();
18922
+ const pid = proc?.pid;
18923
+ if (pid != null) {
18924
+ setTimeout(() => {
18925
+ try {
18926
+ process.kill(-pid, "SIGKILL");
18927
+ } catch {}
18928
+ }, 8000).unref();
18929
+ }
18887
18930
  this._doneReject?.(killError);
18888
18931
  }
18889
18932
  getStderr() {
@@ -18919,6 +18962,7 @@ class PiAgentSession {
18919
18962
  var SessionKilledError, StallTimeoutError, TEST_COMMAND_STALL_TIMEOUT_MS = 300000, GITNEXUS_IMPACT_STALL_TIMEOUT_MS = 300000, TEST_COMMAND_PATTERNS, cachedToolCatalogIndex, WRITE_BOUNDARY_TOOL_NAMES, WORKTREE_BOUNDARY_ENV_KEY = "SPECIALISTS_WORKTREE_BOUNDARY";
18920
18963
  var init_session = __esm(() => {
18921
18964
  init_backendMap();
18965
+ init_canonical_asset_resolver();
18922
18966
  init_manifest_resolver();
18923
18967
  init_tool_catalog();
18924
18968
  SessionKilledError = class SessionKilledError extends Error {
@@ -19877,22 +19921,25 @@ class SqliteClient {
19877
19921
  VALUES (?, ?, ?, ?, ?, ?, ?)
19878
19922
  `, [jobId, seq, specialist, beadId ?? null, event.t, event.type, eventJson]);
19879
19923
  }
19924
+ findActiveJob(beadId, specialist) {
19925
+ return this.db.query(`
19926
+ SELECT
19927
+ job_id,
19928
+ status,
19929
+ updated_at_ms,
19930
+ CAST(JSON_EXTRACT(status_json, '$.pid') AS INTEGER) AS pid
19931
+ FROM specialist_jobs
19932
+ WHERE bead_id = ?
19933
+ AND specialist = ?
19934
+ AND status IN ('starting', 'running', 'waiting')
19935
+ ORDER BY updated_at_ms DESC
19936
+ LIMIT 1
19937
+ `).get(beadId, specialist);
19938
+ }
19880
19939
  claimJobStart(status, event) {
19881
19940
  return claimJobStartWithStore({
19882
19941
  transaction: (callback) => this.db.transaction(callback)(),
19883
- findActiveJob: (beadId, specialist) => this.db.query(`
19884
- SELECT
19885
- job_id,
19886
- status,
19887
- updated_at_ms,
19888
- CAST(JSON_EXTRACT(status_json, '$.pid') AS INTEGER) AS pid
19889
- FROM specialist_jobs
19890
- WHERE bead_id = ?
19891
- AND specialist = ?
19892
- AND status IN ('starting', 'running')
19893
- ORDER BY updated_at_ms DESC
19894
- LIMIT 1
19895
- `).get(beadId, specialist),
19942
+ findActiveJob: (beadId, specialist) => this.findActiveJob(beadId, specialist),
19896
19943
  writeStatusRow: (nextStatus) => this.writeStatusRow(nextStatus),
19897
19944
  writeEventRow: (jobId, specialist, beadId, nextEvent) => this.writeEventRow(jobId, specialist, beadId, nextEvent),
19898
19945
  cancelStaleClaim: (jobId) => {
@@ -20097,6 +20144,21 @@ class SqliteClient {
20097
20144
  this.writeStatusRow(status);
20098
20145
  }, "upsertStatus");
20099
20146
  }
20147
+ markSpecialistJobCancelled(jobId, reason) {
20148
+ withRetry(() => {
20149
+ const transaction = this.db.transaction(() => {
20150
+ const nowMs = Date.now();
20151
+ this.db.run(`
20152
+ UPDATE specialist_jobs
20153
+ SET status = 'cancelled',
20154
+ status_json = JSON_PATCH(status_json, JSON_OBJECT('status', 'cancelled', 'cancelled_reason', ?)),
20155
+ updated_at_ms = ?
20156
+ WHERE job_id = ?
20157
+ `, [reason, nowMs, jobId]);
20158
+ });
20159
+ transaction();
20160
+ }, "markSpecialistJobCancelled");
20161
+ }
20100
20162
  upsertEpicRun(epic) {
20101
20163
  withRetry(() => {
20102
20164
  this.writeEpicRunRow(epic);
@@ -20597,6 +20659,16 @@ class SqliteClient {
20597
20659
  }
20598
20660
  }, "readLatestToolEvent");
20599
20661
  }
20662
+ getLastActivityTimestampMs(jobId) {
20663
+ return withRetry(() => {
20664
+ const row = this.db.query(`
20665
+ SELECT MAX(t) AS last_activity_ms
20666
+ FROM specialist_events
20667
+ WHERE job_id = ? AND type IN ('tool', 'think')
20668
+ `).get(jobId);
20669
+ return typeof row?.last_activity_ms === "number" ? row.last_activity_ms : null;
20670
+ }, "getLastActivityTimestampMs");
20671
+ }
20600
20672
  aggregateJobMetrics(jobId) {
20601
20673
  return withRetry(() => {
20602
20674
  const jobRow = this.db.query(`
@@ -21901,6 +21973,40 @@ function runScript(command, cwd) {
21901
21973
  return { name: scriptName, output: e.stdout ?? e.message ?? "", exitCode: e.status ?? 1 };
21902
21974
  }
21903
21975
  }
21976
+ function buildReviewedGitnexusSummary(opts) {
21977
+ const jobId = opts.reusedFromJobId?.trim();
21978
+ if (!jobId)
21979
+ return "";
21980
+ try {
21981
+ const client = createObservabilitySqliteClient(opts.cwd);
21982
+ if (!client)
21983
+ return "";
21984
+ const events = client.readEvents(jobId);
21985
+ let runComplete = null;
21986
+ for (let i = events.length - 1;i >= 0; i -= 1) {
21987
+ const ev = events[i];
21988
+ if (ev?.type === "run_complete") {
21989
+ runComplete = ev;
21990
+ break;
21991
+ }
21992
+ }
21993
+ if (!runComplete?.gitnexus_summary)
21994
+ return "";
21995
+ const gs = runComplete.gitnexus_summary;
21996
+ const files = (gs.files_touched ?? []).slice(0, 20).join(", ") || "(none)";
21997
+ const symbols = (gs.symbols_analyzed ?? []).slice(0, 20).join(", ") || "(none)";
21998
+ const risk = gs.highest_risk ?? "UNKNOWN";
21999
+ const invocations = gs.tool_invocations ?? 0;
22000
+ return `<gitnexus_summary reviewed_job_id="${jobId}">
22001
+ files_touched: ${files}
22002
+ symbols_analyzed: ${symbols}
22003
+ highest_risk: ${risk}
22004
+ tool_invocations: ${invocations}
22005
+ </gitnexus_summary>`;
22006
+ } catch {
22007
+ return "";
22008
+ }
22009
+ }
21904
22010
  function formatScriptOutput(results) {
21905
22011
  const withOutput = results.filter((r) => r.output.trim());
21906
22012
  if (withOutput.length === 0)
@@ -21972,7 +22078,7 @@ function validateBeforeRun(spec, permissionLevel) {
21972
22078
  }
21973
22079
  } else {
21974
22080
  const binary = run.split(" ")[0];
21975
- if (!commandExists(binary)) {
22081
+ if (binary && !SHELL_BUILTINS.has(binary) && !commandExists(binary)) {
21976
22082
  errors5.push(` \u2717 skills.scripts: command not found on PATH: ${binary}`);
21977
22083
  }
21978
22084
  }
@@ -22394,6 +22500,13 @@ ${buildBeadBoundaryInstruction(runCwd, options.worktreeBoundary)}`.trim() : this
22394
22500
  ...options.reusedFromJobId ? { reused_from_job_id: options.reusedFromJobId } : {},
22395
22501
  ...options.worktreeOwnerJobId ? { worktree_owner_job_id: options.worktreeOwnerJobId } : {}
22396
22502
  };
22503
+ const gitnexusSummary = buildReviewedGitnexusSummary({
22504
+ reusedFromJobId: options.reusedFromJobId,
22505
+ cwd: runCwd
22506
+ });
22507
+ if (gitnexusSummary) {
22508
+ lineageVariables.gitnexus_summary = gitnexusSummary;
22509
+ }
22397
22510
  const beadTemplateVariables = {
22398
22511
  prompt: resolvedPrompt,
22399
22512
  bead_id: options.inputBeadId ?? "",
@@ -22806,7 +22919,7 @@ ${outputContractWarnings.map((msg) => ` \u26A0 ${msg}`).join(`
22806
22919
  `)}
22807
22920
  `);
22808
22921
  }
22809
- if (output_file) {
22922
+ if (output_file && isJobFileOutputEnabled()) {
22810
22923
  await writeFile(output_file, output, "utf-8").catch(() => {});
22811
22924
  }
22812
22925
  await hooks.emit("post_execute", invocationId, metadata.name, metadata.version, {
@@ -22848,13 +22961,42 @@ ${outputContractWarnings.map((msg) => ` \u26A0 ${msg}`).join(`
22848
22961
  return jobId;
22849
22962
  }
22850
22963
  }
22851
- var PERMISSION_GATED_TOOLS, RETRY_BASE_DELAY_MS = 1000, RETRY_MAX_JITTER = 0.2, BASE_OUTPUT_SCHEMA, IMPACT_REPORT_SCHEMA, OUTPUT_TYPE_SCHEMA_EXTENSIONS, OUTPUT_TYPE_GUIDANCE;
22964
+ var SHELL_BUILTINS, PERMISSION_GATED_TOOLS, RETRY_BASE_DELAY_MS = 1000, RETRY_MAX_JITTER = 0.2, BASE_OUTPUT_SCHEMA, IMPACT_REPORT_SCHEMA, OUTPUT_TYPE_SCHEMA_EXTENSIONS, OUTPUT_TYPE_GUIDANCE;
22852
22965
  var init_runner = __esm(() => {
22853
22966
  init_session();
22854
22967
  init_circuitBreaker();
22855
22968
  init_mandatory_rules();
22969
+ init_observability_sqlite();
22856
22970
  init_beads();
22857
22971
  init_memory_retrieval();
22972
+ SHELL_BUILTINS = new Set([
22973
+ "if",
22974
+ "then",
22975
+ "else",
22976
+ "elif",
22977
+ "fi",
22978
+ "for",
22979
+ "while",
22980
+ "until",
22981
+ "do",
22982
+ "done",
22983
+ "case",
22984
+ "esac",
22985
+ "select",
22986
+ "in",
22987
+ "function",
22988
+ "return",
22989
+ "break",
22990
+ "continue",
22991
+ ":",
22992
+ ".",
22993
+ "true",
22994
+ "false",
22995
+ "[",
22996
+ "[[",
22997
+ "{",
22998
+ "("
22999
+ ]);
22858
23000
  PERMISSION_GATED_TOOLS = {
22859
23001
  bash: ["LOW", "MEDIUM", "HIGH"],
22860
23002
  edit: ["MEDIUM", "HIGH"],
@@ -23355,6 +23497,7 @@ var RULE_TIERS, SPEC_TIERS;
23355
23497
  var init_list_rules = __esm(() => {
23356
23498
  init_mandatory_rules();
23357
23499
  RULE_TIERS = [
23500
+ { rel: ".specialists/user/mandatory-rules", tier: "user" },
23358
23501
  { rel: ".specialists/mandatory-rules", tier: "overlay" },
23359
23502
  { rel: ".specialists/default/mandatory-rules", tier: "default" },
23360
23503
  { rel: "config/mandatory-rules", tier: "config" }
@@ -23366,22 +23509,6 @@ var init_list_rules = __esm(() => {
23366
23509
  ];
23367
23510
  });
23368
23511
 
23369
- // src/specialist/job-file-output.ts
23370
- function normalizeMode(value) {
23371
- return (value ?? "").trim().toLowerCase();
23372
- }
23373
- function detectJobFileOutputMode(env = process.env) {
23374
- const normalized = normalizeMode(env.SPECIALISTS_JOB_FILE_OUTPUT);
23375
- if (normalized === "on" || normalized === "1" || normalized === "true")
23376
- return "on";
23377
- if (normalized === "off" || normalized === "0" || normalized === "false")
23378
- return "off";
23379
- return "off";
23380
- }
23381
- function isJobFileOutputEnabled(env = process.env) {
23382
- return detectJobFileOutputMode(env) === "on";
23383
- }
23384
-
23385
23512
  // src/specialist/timeline-events.ts
23386
23513
  function summarizeToolResult(resultContent) {
23387
23514
  if (!resultContent)
@@ -23746,25 +23873,24 @@ function resolveChainId(status) {
23746
23873
  return;
23747
23874
  }
23748
23875
  function evaluateEpicMergeReadiness(input) {
23749
- const isEligibleState = input.epicStatus === "merge_ready";
23750
23876
  const blockingChains = input.chainStatuses.filter((chain) => chain.hasRunningJob).map((chain) => chain.chainId);
23751
- const isReady = isEligibleState && blockingChains.length === 0;
23752
- if (!isEligibleState) {
23877
+ const isReady = blockingChains.length === 0;
23878
+ if (!isReady) {
23753
23879
  return {
23754
23880
  epicId: input.epicId,
23755
23881
  epicStatus: input.epicStatus,
23756
23882
  isReady,
23757
23883
  blockingChains,
23758
- summary: `Epic ${input.epicId} is ${input.epicStatus}; expected merge_ready before publication.`
23884
+ summary: `Epic ${input.epicId} is blocked by active chains: ${blockingChains.join(", ")}.`
23759
23885
  };
23760
23886
  }
23761
- if (blockingChains.length > 0) {
23887
+ if (input.epicStatus === "merged" || input.epicStatus === "abandoned") {
23762
23888
  return {
23763
23889
  epicId: input.epicId,
23764
23890
  epicStatus: input.epicStatus,
23765
23891
  isReady,
23766
23892
  blockingChains,
23767
- summary: `Epic ${input.epicId} is blocked by active chains: ${blockingChains.join(", ")}.`
23893
+ summary: `Epic ${input.epicId} is ${input.epicStatus}; live chains are terminal.`
23768
23894
  };
23769
23895
  }
23770
23896
  return {
@@ -23772,7 +23898,7 @@ function evaluateEpicMergeReadiness(input) {
23772
23898
  epicStatus: input.epicStatus,
23773
23899
  isReady,
23774
23900
  blockingChains,
23775
- summary: `Epic ${input.epicId} is merge-ready and all chains are terminal.`
23901
+ summary: `Epic ${input.epicId} is live-ready and all chains are terminal.`
23776
23902
  };
23777
23903
  }
23778
23904
  function appendEpicTransitionAudit(statusJson, entry) {
@@ -23796,9 +23922,6 @@ function appendEpicTransitionAudit(statusJson, entry) {
23796
23922
  transitions: [...previous, entry]
23797
23923
  });
23798
23924
  }
23799
- function summarizeEpicTransition(epicId, from, to) {
23800
- return `Epic ${epicId}: ${from} -> ${to}`;
23801
- }
23802
23925
  var EPIC_TERMINAL_STATES, VALID_EPIC_TRANSITIONS;
23803
23926
  var init_epic_lifecycle = __esm(() => {
23804
23927
  EPIC_TERMINAL_STATES = ["merged", "failed", "abandoned"];
@@ -23990,79 +24113,55 @@ function evaluatePrepReadiness(prepJobs) {
23990
24113
  blocker_job_ids: [...running, ...failed].map((job) => job.id)
23991
24114
  };
23992
24115
  }
23993
- function toReadinessState(persistedState, prep, chains) {
23994
- if (persistedState === "merged")
23995
- return "merged";
23996
- if (persistedState === "abandoned")
23997
- return "abandoned";
23998
- const hasBlockingPrep = prep.running > 0;
23999
- const hasFailedPrep = prep.failed > 0;
24116
+ function deriveEpicReadinessState(prep, chains) {
24117
+ const hasActivePrep = prep.running > 0;
24000
24118
  const hasPendingChain = chains.some((chain) => chain.state === "pending");
24001
24119
  const hasBlockedChain = chains.some((chain) => chain.state === "blocked");
24120
+ const hasFailedPrep = prep.failed > 0;
24002
24121
  const hasFailedChain = chains.some((chain) => chain.state === "failed");
24003
24122
  const allChainsPass = chains.length === 0 || chains.every((chain) => chain.state === "pass");
24123
+ if (hasActivePrep || hasPendingChain)
24124
+ return "resolving";
24004
24125
  if (hasFailedPrep || hasFailedChain)
24005
24126
  return "failed";
24006
- if (persistedState === "failed" && allChainsPass)
24007
- return "merge_ready";
24008
- if (persistedState === "failed")
24009
- return "failed";
24010
- if (hasBlockingPrep || hasPendingChain)
24011
- return persistedState === "resolving" ? "resolving" : "unresolved";
24012
24127
  if (hasBlockedChain)
24013
- return persistedState === "resolving" ? "resolving" : "blocked";
24128
+ return "blocked";
24014
24129
  if (allChainsPass)
24015
24130
  return "merge_ready";
24016
24131
  return "blocked";
24017
24132
  }
24018
- function toNextState(persistedState, readinessState) {
24133
+ function deriveEpicNextState(persistedState, readinessState) {
24019
24134
  if (persistedState === "merged" || persistedState === "abandoned")
24020
24135
  return persistedState;
24021
- if (readinessState === "failed") {
24022
- if (persistedState === "merge_ready") {
24023
- return transitionEpicState("merge_ready", "failed");
24024
- }
24025
- if (persistedState === "resolving") {
24026
- return transitionEpicState("resolving", "failed");
24027
- }
24028
- return persistedState;
24029
- }
24030
- if (readinessState === "merge_ready") {
24031
- if (persistedState === "failed")
24032
- return "merge_ready";
24033
- if (persistedState === "open")
24034
- return transitionEpicState("open", "resolving");
24035
- if (persistedState === "resolving")
24036
- return transitionEpicState("resolving", "merge_ready");
24037
- return persistedState;
24038
- }
24039
- if (persistedState === "open" && (readinessState === "unresolved" || readinessState === "resolving" || readinessState === "blocked")) {
24040
- return transitionEpicState("open", "resolving");
24041
- }
24042
- if (persistedState === "merge_ready" && (readinessState === "unresolved" || readinessState === "resolving" || readinessState === "blocked")) {
24043
- return transitionEpicState("merge_ready", "resolving");
24044
- }
24045
- return persistedState;
24136
+ if (readinessState === "merge_ready")
24137
+ return "merge_ready";
24138
+ if (readinessState === "failed")
24139
+ return "failed";
24140
+ return "resolving";
24046
24141
  }
24047
- function buildSummaryLine(epicId, readinessState, prep, chains) {
24142
+ function buildSummaryLine(epicId, persistedState, readinessState, prep, chains) {
24048
24143
  const chainPass = chains.filter((chain) => chain.state === "pass").length;
24049
24144
  const chainTotal = chains.length;
24050
- const blockedChains = chains.filter((chain) => chain.state === "blocked" || chain.state === "pending").map((chain) => chain.chain_id);
24145
+ const blockedChains = chains.filter((chain) => chain.state === "blocked" || chain.state === "pending" || chain.state === "failed").map((chain) => chain.chain_id);
24146
+ const failedChains = chains.filter((chain) => chain.state === "failed").map((chain) => chain.chain_id);
24051
24147
  const prepSegment = `prep done=${prep.done}/${prep.total} running=${prep.running} failed=${prep.failed}`;
24052
24148
  const chainSegment = `chains pass=${chainPass}/${chainTotal}`;
24053
- if (blockedChains.length > 0) {
24054
- return `Epic ${epicId}: ${readinessState} (${prepSegment}; ${chainSegment}; blocked=${blockedChains.join(", ")})`;
24055
- }
24056
- return `Epic ${epicId}: ${readinessState} (${prepSegment}; ${chainSegment})`;
24149
+ const stateSegment = persistedState === readinessState ? readinessState : `${readinessState} (stored=${persistedState})`;
24150
+ const segments = [prepSegment, chainSegment];
24151
+ if (blockedChains.length > 0)
24152
+ segments.push(`blocked=${blockedChains.join(", ")}`);
24153
+ if (failedChains.length > 0)
24154
+ segments.push(`failed=${failedChains.join(", ")}`);
24155
+ return `Epic ${epicId}: ${stateSegment} (${segments.join("; ")})`;
24057
24156
  }
24058
24157
  function evaluateEpicReadinessSummary(input) {
24059
24158
  const prep = evaluatePrepReadiness(input.prepJobs);
24060
24159
  const chains = input.chainInputs.map((chain) => evaluateChainReadiness(chain.chain_id, chain.jobs, chain.chain_root_bead_id));
24061
- const readinessState = toReadinessState(input.persistedState, prep, chains);
24062
- const nextState = toNextState(input.persistedState, readinessState);
24160
+ const readinessState = deriveEpicReadinessState(prep, chains);
24161
+ const nextState = deriveEpicNextState(input.persistedState, readinessState);
24063
24162
  const blockers = [
24064
24163
  ...prep.blocker_job_ids.map((jobId) => `prep:${jobId}`),
24065
- ...chains.filter((chain) => chain.state === "pending" || chain.state === "blocked").map((chain) => `chain:${chain.chain_id}`)
24164
+ ...chains.filter((chain) => chain.state === "pending" || chain.state === "blocked" || chain.state === "failed").map((chain) => `chain:${chain.chain_id}`)
24066
24165
  ];
24067
24166
  return {
24068
24167
  epic_id: input.epicId,
@@ -24073,7 +24172,7 @@ function evaluateEpicReadinessSummary(input) {
24073
24172
  prep,
24074
24173
  chains,
24075
24174
  blockers,
24076
- summary: buildSummaryLine(input.epicId, readinessState, prep, chains)
24175
+ summary: buildSummaryLine(input.epicId, input.persistedState, readinessState, prep, chains)
24077
24176
  };
24078
24177
  }
24079
24178
  function loadEpicReadinessSummary(sqlite, epicId) {
@@ -24113,7 +24212,7 @@ function loadEpicReadinessSummary(sqlite, epicId) {
24113
24212
  function syncEpicStateFromReadiness(sqlite, summary) {
24114
24213
  const now = Date.now();
24115
24214
  const existing = sqlite.readEpicRun(summary.epic_id);
24116
- const healedFromFailed = summary.persisted_state === "failed" && summary.readiness_state === "merge_ready";
24215
+ const recoveredFromLegacyFailure = summary.persisted_state === "failed" && summary.readiness_state === "merge_ready";
24117
24216
  const nextRecord = {
24118
24217
  epic_id: summary.epic_id,
24119
24218
  status: summary.next_state,
@@ -24128,21 +24227,20 @@ function syncEpicStateFromReadiness(sqlite, summary) {
24128
24227
  chains: summary.chains,
24129
24228
  summary: summary.summary,
24130
24229
  evaluated_at_ms: now,
24131
- note: healedFromFailed ? "epic reconciler: healed failed -> merge_ready after chain merge" : undefined
24230
+ note: recoveredFromLegacyFailure ? "derived readiness healed legacy failed row after live chains turned pass" : undefined
24132
24231
  })
24133
24232
  };
24134
- if (summary.can_transition || healedFromFailed || !existing) {
24233
+ if (summary.can_transition || recoveredFromLegacyFailure || !existing) {
24135
24234
  sqlite.upsertEpicRun(nextRecord);
24136
24235
  }
24137
24236
  return nextRecord;
24138
24237
  }
24139
24238
  var ACTIVE_JOB_STATUSES, TERMINAL_JOB_STATUSES, REVIEWER_VERDICT_REGEX;
24140
24239
  var init_epic_readiness = __esm(() => {
24141
- init_epic_lifecycle();
24142
24240
  init_process_liveness();
24143
24241
  ACTIVE_JOB_STATUSES = new Set(["starting", "running", "waiting"]);
24144
24242
  TERMINAL_JOB_STATUSES = new Set(["done", "error"]);
24145
- REVIEWER_VERDICT_REGEX = /Verdict:\s*(PASS|PARTIAL|FAIL)/i;
24243
+ REVIEWER_VERDICT_REGEX = /Verdict:\s*\**\s*(PASS|PARTIAL|FAIL)\s*\**/i;
24146
24244
  });
24147
24245
 
24148
24246
  // src/specialist/chain-identity.ts
@@ -24419,8 +24517,20 @@ function runAutoCommitCheckpoint(options) {
24419
24517
  return { status: "failed", reason: String(error2) };
24420
24518
  }
24421
24519
  }
24520
+ function gitnexusHasEmbeddings(cwd) {
24521
+ try {
24522
+ const metaPath = join9(cwd, ".gitnexus", "meta.json");
24523
+ const raw = readFileSync8(metaPath, "utf-8");
24524
+ const meta = JSON.parse(raw);
24525
+ return typeof meta.stats?.embeddings === "number" && meta.stats.embeddings > 0;
24526
+ } catch {
24527
+ return false;
24528
+ }
24529
+ }
24422
24530
  function startDetachedGitnexusAnalyze(cwd) {
24423
- const child = spawn2("npx", ["gitnexus", "analyze"], {
24531
+ const baseArgs = ["gitnexus", "analyze", "--skip-agents-md", "--no-stats"];
24532
+ const args = gitnexusHasEmbeddings(cwd) ? [...baseArgs, "--embeddings"] : baseArgs;
24533
+ const child = spawn2("npx", args, {
24424
24534
  cwd,
24425
24535
  detached: true,
24426
24536
  stdio: "ignore"
@@ -24707,6 +24817,55 @@ class Supervisor {
24707
24817
  return [];
24708
24818
  }
24709
24819
  }
24820
+ listChainJobIds(chainId) {
24821
+ try {
24822
+ if (this.isDisposed) {
24823
+ throw this.createDisposedSqliteError("listChainJobIds");
24824
+ }
24825
+ return this.withSqliteOperation("listChainJobIds", (client) => client.listChainJobIds(chainId)) ?? [];
24826
+ } catch (error2) {
24827
+ console.warn(`[supervisor] SQLite listChainJobIds failed: ${String(error2)}`);
24828
+ return [];
24829
+ }
24830
+ }
24831
+ readResult(id) {
24832
+ try {
24833
+ if (this.isDisposed) {
24834
+ throw this.createDisposedSqliteError("readResult");
24835
+ }
24836
+ const sqliteResult = this.withSqliteOperation("readResult", (client) => client.readResult(id));
24837
+ if (sqliteResult)
24838
+ return sqliteResult;
24839
+ } catch (error2) {
24840
+ if (!(error2 instanceof Error && error2.message.includes("supervisor is disposed"))) {
24841
+ console.warn(`[supervisor] SQLite readResult failed, falling back to file state: ${String(error2)}`);
24842
+ }
24843
+ }
24844
+ const path = this.resultPath(id);
24845
+ if (!existsSync10(path))
24846
+ return null;
24847
+ try {
24848
+ return readFileSync8(path, "utf-8");
24849
+ } catch {
24850
+ return null;
24851
+ }
24852
+ }
24853
+ finalizeWaitingJob(id) {
24854
+ const currentStatus = this.readStatus(id);
24855
+ if (!currentStatus)
24856
+ return null;
24857
+ if (currentStatus.status !== "waiting")
24858
+ return currentStatus;
24859
+ if (currentStatus.fifo_path) {
24860
+ writeFileSync3(currentStatus.fifo_path, JSON.stringify({ type: "close" }) + `
24861
+ `, { flag: "a" });
24862
+ }
24863
+ const finalized = this.updateJobStatus(id, "done");
24864
+ if (!finalized)
24865
+ return null;
24866
+ this.aggregateJobMetricsBestEffort(id);
24867
+ return finalized;
24868
+ }
24710
24869
  emitMetaEvent(jobId, model, backend) {
24711
24870
  if (this.isDisposed)
24712
24871
  return;
@@ -25165,8 +25324,24 @@ class Supervisor {
25165
25324
  }));
25166
25325
  };
25167
25326
  const shouldAutoCloseReadOnlyKeepAlive = (output) => isReadOnlySpecialist && TERMINAL_COMPLIANCE_VERDICT_REGEX.test(output);
25327
+ const shouldAutoFinalizeKeepAlive = (output) => PASS_COMPLIANCE_VERDICT_REGEX.test(output);
25168
25328
  const shouldWriteExternalBeadNotes = runOptions.beadsWriteNotes ?? true;
25169
25329
  let skipFinalKeepAliveInputBeadAppend = false;
25330
+ let lastGitnexusAnalyzedSha;
25331
+ const triggerGitnexusAnalyzeIfNeeded = (sha, source) => {
25332
+ if (!isGitnexusAnalyzeRequired(runOptions.permissionRequired))
25333
+ return;
25334
+ if (sha && lastGitnexusAnalyzedSha === sha)
25335
+ return;
25336
+ try {
25337
+ startDetachedGitnexusAnalyze(runOptions.workingDirectory ?? process.cwd());
25338
+ appendTimelineEvent(createMetaEvent("gitnexus_analyze_started", source));
25339
+ if (sha)
25340
+ lastGitnexusAnalyzedSha = sha;
25341
+ } catch (err) {
25342
+ appendTimelineEvent(createMetaEvent("gitnexus_analyze_start_failed", `${source}: ${String(err?.message ?? err)}`));
25343
+ }
25344
+ };
25170
25345
  const appendResultToInputBead = (params) => {
25171
25346
  const inputBeadId = runOptions.inputBeadId;
25172
25347
  const shouldAppendResultToInputBead = Boolean(shouldWriteExternalBeadNotes && inputBeadId && this.opts.beadsClient);
@@ -25189,12 +25364,14 @@ class Supervisor {
25189
25364
  const appendError = `[bead-append-failed] ${appendResult.error ?? "Unknown error"}`;
25190
25365
  appendTimelineEvent(createMetaEvent("bead_append_failed", appendError));
25191
25366
  setStatus({ current_event: "bead_append_failed", last_event_at_ms: Date.now() });
25192
- try {
25193
- appendFileSync(this.resultPath(id), `
25367
+ if (this.isJobFileOutputEnabled) {
25368
+ try {
25369
+ appendFileSync(this.resultPath(id), `
25194
25370
 
25195
25371
  ${appendError}
25196
25372
  `, "utf-8");
25197
- } catch {}
25373
+ } catch {}
25374
+ }
25198
25375
  return false;
25199
25376
  };
25200
25377
  const applyAutoCommitCheckpoint = (target, autoCommitPolicy2) => {
@@ -25225,6 +25402,7 @@ ${appendError}
25225
25402
  commit_sha: autoCommitResult.sha,
25226
25403
  committed_files: autoCommitResult.files
25227
25404
  }));
25405
+ triggerGitnexusAnalyzeIfNeeded(autoCommitResult.sha, "checkpoint");
25228
25406
  };
25229
25407
  const handleResumeTurn = async (task) => {
25230
25408
  if (!resumeFn)
@@ -25236,8 +25414,10 @@ ${appendError}
25236
25414
  try {
25237
25415
  const output = await resumeFn(task);
25238
25416
  latestOutput = output;
25239
- mkdirSync4(this.jobDir(id), { recursive: true });
25240
- writeFileSync3(this.resultPath(id), output, "utf-8");
25417
+ if (this.isJobFileOutputEnabled) {
25418
+ mkdirSync4(this.jobDir(id), { recursive: true });
25419
+ writeFileSync3(this.resultPath(id), output, "utf-8");
25420
+ }
25241
25421
  try {
25242
25422
  this.withSqliteOperation("upsertResult:resume_turn", (client) => client.upsertResult(id, output));
25243
25423
  } catch (error2) {
@@ -25249,7 +25429,9 @@ ${appendError}
25249
25429
  beadId: statusSnapshot.bead_id,
25250
25430
  output
25251
25431
  });
25252
- const isWaitingTurn = !shouldAutoCloseReadOnlyKeepAlive(output);
25432
+ const passFinalize = shouldAutoFinalizeKeepAlive(output);
25433
+ const readOnlyClose = shouldAutoCloseReadOnlyKeepAlive(output);
25434
+ const isWaitingTurn = !readOnlyClose && !passFinalize;
25253
25435
  applyAutoCommitCheckpoint(isWaitingTurn ? "waiting" : "terminal", autoCommitPolicy);
25254
25436
  appendResultToInputBead({
25255
25437
  output,
@@ -25676,6 +25858,8 @@ ${appendError}
25676
25858
  if (keepAliveSession) {
25677
25859
  if (shouldAutoCloseReadOnlyKeepAlive(finalResult.output)) {
25678
25860
  await closeKeepAliveSession();
25861
+ } else if (shouldAutoFinalizeKeepAlive(finalResult.output)) {
25862
+ await closeKeepAliveSession();
25679
25863
  } else {
25680
25864
  appendResultToInputBead({
25681
25865
  output: finalResult.output,
@@ -25791,24 +25975,7 @@ ${appendError}
25791
25975
  throw new Error("[supervisor] SQLite upsertStatusWithEventAndResult failed: database client unavailable");
25792
25976
  }
25793
25977
  this.aggregateJobMetricsBestEffort(id);
25794
- if (isGitnexusAnalyzeRequired(finalResult.permissionRequired)) {
25795
- try {
25796
- startDetachedGitnexusAnalyze(runOptions.workingDirectory ?? process.cwd());
25797
- appendTimelineEventFileOnly({
25798
- t: Date.now(),
25799
- type: TIMELINE_EVENT_TYPES.META,
25800
- model: "gitnexus_analyze_started",
25801
- backend: "supervisor"
25802
- });
25803
- } catch (err) {
25804
- appendTimelineEventFileOnly({
25805
- t: Date.now(),
25806
- type: TIMELINE_EVENT_TYPES.META,
25807
- model: "gitnexus_analyze_start_failed",
25808
- backend: String(err?.message ?? err)
25809
- });
25810
- }
25811
- }
25978
+ triggerGitnexusAnalyzeIfNeeded(statusSnapshot.last_auto_commit_sha, "terminal");
25812
25979
  this.writeReadyMarker(id);
25813
25980
  return id;
25814
25981
  } catch (err) {
@@ -25897,7 +26064,7 @@ ${appendError}
25897
26064
  }
25898
26065
  }
25899
26066
  }
25900
- var JOB_TTL_DAYS, STALL_DETECTION_DEFAULTS, GITNEXUS_RISK_ORDER, MODEL_CONTEXT_WINDOWS, TERMINAL_COMPLIANCE_VERDICT_REGEX, AUTO_COMMIT_NOISE_PREFIXES, STATUS_WATCHDOG_INTERVAL_MS = 5000, STATUS_WATCHDOG_STALE_AFTER_MS = 30000;
26067
+ var JOB_TTL_DAYS, STALL_DETECTION_DEFAULTS, GITNEXUS_RISK_ORDER, MODEL_CONTEXT_WINDOWS, TERMINAL_COMPLIANCE_VERDICT_REGEX, PASS_COMPLIANCE_VERDICT_REGEX, AUTO_COMMIT_NOISE_PREFIXES, STATUS_WATCHDOG_INTERVAL_MS = 5000, STATUS_WATCHDOG_STALE_AFTER_MS = 30000;
25901
26068
  var init_supervisor = __esm(() => {
25902
26069
  init_job_root();
25903
26070
  init_timeline_events();
@@ -25925,8 +26092,9 @@ var init_supervisor = __esm(() => {
25925
26092
  { matcher: (model) => model.includes("qwen3.5") || model.includes("glm-5"), windowTokens: 128000 },
25926
26093
  { matcher: (model) => model.includes("claude"), windowTokens: 200000 }
25927
26094
  ];
25928
- TERMINAL_COMPLIANCE_VERDICT_REGEX = /## Compliance Verdict[\s\S]*?- Verdict: (PASS|PARTIAL|FAIL)/i;
25929
- AUTO_COMMIT_NOISE_PREFIXES = [".xtrm/", ".wolf/", ".specialists/jobs/", ".beads/"];
26095
+ TERMINAL_COMPLIANCE_VERDICT_REGEX = /## Compliance Verdict[\s\S]*?- Verdict:\s*\**\s*(PASS|PARTIAL|FAIL)\s*\**/i;
26096
+ PASS_COMPLIANCE_VERDICT_REGEX = /## Compliance Verdict[\s\S]*?- Verdict:\s*\**\s*PASS\s*\**/i;
26097
+ AUTO_COMMIT_NOISE_PREFIXES = [".xtrm/", ".wolf/", ".specialists/jobs/", ".beads/", ".pi/"];
25930
26098
  });
25931
26099
 
25932
26100
  // src/cli/list.ts
@@ -26652,8 +26820,20 @@ function assertXtrmPrerequisites(cwd) {
26652
26820
  const hasXtCli = isInstalled("xt");
26653
26821
  if (hasXtrmDir && hasXtCli)
26654
26822
  return;
26655
- console.error("specialists requires xtrm. Run: npm install -g xtrm-tools && xt install");
26656
- process.exit(1);
26823
+ if (!hasXtCli) {
26824
+ console.error("specialists init: missing xt CLI.");
26825
+ console.error("1. Install xtrm-tools globally: npm install -g xtrm-tools");
26826
+ console.error("2. Run xt install");
26827
+ console.error("3. Run xt init in this repo");
26828
+ console.error("4. Verify xt is available: xt --version");
26829
+ process.exit(1);
26830
+ }
26831
+ if (!hasXtrmDir) {
26832
+ console.error("specialists init: missing .xtrm/ in this repo.");
26833
+ console.error("1. Run xt init in this repo");
26834
+ console.error("2. Verify xt is available: xt --version");
26835
+ process.exit(1);
26836
+ }
26657
26837
  }
26658
26838
  function warnMissingOptionalPrerequisites() {
26659
26839
  const optionalTools = [
@@ -27103,22 +27283,51 @@ function ensureObservabilityDb(cwd) {
27103
27283
  }
27104
27284
  ensureGitignoreHasObservabilityDbEntries(location.gitRoot);
27105
27285
  }
27286
+ function extractSpecialistsBlockSpan(existing) {
27287
+ const start = existing.indexOf("<!-- specialists:start -->");
27288
+ if (start === -1)
27289
+ return null;
27290
+ const endMarker = "<!-- specialists:end -->";
27291
+ const endMarkerIndex = existing.indexOf(endMarker, start);
27292
+ if (endMarkerIndex === -1)
27293
+ return null;
27294
+ return { start, end: endMarkerIndex + endMarker.length };
27295
+ }
27106
27296
  function ensureAgentsMd(cwd) {
27107
27297
  const agentsPath = join11(cwd, "AGENTS.md");
27108
- if (existsSync12(agentsPath)) {
27109
- const existing = readFileSync10(agentsPath, "utf-8");
27110
- if (existing.includes(AGENTS_MARKER)) {
27111
- skip("AGENTS.md already has Specialists section");
27112
- } else {
27113
- writeFileSync4(agentsPath, existing.trimEnd() + `
27114
-
27115
- ` + AGENTS_BLOCK, "utf-8");
27116
- ok("appended Specialists section to AGENTS.md");
27117
- }
27118
- } else {
27298
+ if (!existsSync12(agentsPath)) {
27119
27299
  writeFileSync4(agentsPath, AGENTS_BLOCK, "utf-8");
27120
27300
  ok("created AGENTS.md with Specialists section");
27301
+ return;
27121
27302
  }
27303
+ const existing = readFileSync10(agentsPath, "utf-8");
27304
+ const span = extractSpecialistsBlockSpan(existing);
27305
+ if (span) {
27306
+ const next = existing.slice(0, span.start) + AGENTS_BLOCK + existing.slice(span.end);
27307
+ if (next === existing) {
27308
+ skip("AGENTS.md already has Specialists section");
27309
+ return;
27310
+ }
27311
+ writeFileSync4(agentsPath, next, "utf-8");
27312
+ ok("updated Specialists section in AGENTS.md");
27313
+ return;
27314
+ }
27315
+ if (existing.includes(AGENTS_MARKER)) {
27316
+ const markerIndex = existing.indexOf(AGENTS_MARKER);
27317
+ const nextH2Match = /^## /m.exec(existing.slice(markerIndex + AGENTS_MARKER.length));
27318
+ const nextH2Index = nextH2Match ? markerIndex + AGENTS_MARKER.length + nextH2Match.index : existing.length;
27319
+ const next = existing.slice(0, markerIndex).trimEnd() + `
27320
+
27321
+ ` + AGENTS_BLOCK + (nextH2Index < existing.length ? `
27322
+ ` + existing.slice(nextH2Index) : "");
27323
+ writeFileSync4(agentsPath, next, "utf-8");
27324
+ ok("migrated Specialists section in AGENTS.md");
27325
+ return;
27326
+ }
27327
+ writeFileSync4(agentsPath, existing.trimEnd() + `
27328
+
27329
+ ` + AGENTS_BLOCK, "utf-8");
27330
+ ok("appended Specialists section to AGENTS.md");
27122
27331
  }
27123
27332
  function readJsonObject(path) {
27124
27333
  if (!existsSync12(path))
@@ -27315,6 +27524,7 @@ var init_init = __esm(() => {
27315
27524
  init_memory_retrieval();
27316
27525
  init_canonical_asset_resolver();
27317
27526
  AGENTS_BLOCK = `
27527
+ <!-- specialists:start -->
27318
27528
  ## Specialists
27319
27529
 
27320
27530
  Use CLI commands via Bash to run and monitor specialists:
@@ -27340,6 +27550,7 @@ Canonical tracked flow:
27340
27550
  5. Close/update bead with outcome
27341
27551
 
27342
27552
  Add custom specialists to \`.specialists/user/\` to extend defaults.
27553
+ <!-- specialists:end -->
27343
27554
  `.trimStart();
27344
27555
  GITIGNORE_ENTRIES = [
27345
27556
  ".specialists/jobs/",
@@ -28373,6 +28584,37 @@ function resolveScriptSpecialistName(name) {
28373
28584
  return "changelog-drafter";
28374
28585
  return name;
28375
28586
  }
28587
+ function collectRequiredOutputKeys(spec) {
28588
+ const keys = new Set;
28589
+ const declared = spec.specialist.execution.expected_output_keys;
28590
+ if (Array.isArray(declared)) {
28591
+ for (const value of declared) {
28592
+ if (typeof value === "string" && value.length > 0)
28593
+ keys.add(value);
28594
+ }
28595
+ }
28596
+ if (spec.specialist.execution.response_format === "json") {
28597
+ const required2 = spec.specialist.prompt.output_schema?.required;
28598
+ if (Array.isArray(required2)) {
28599
+ for (const value of required2) {
28600
+ if (typeof value === "string" && value.length > 0)
28601
+ keys.add(value);
28602
+ }
28603
+ }
28604
+ }
28605
+ return Array.from(keys);
28606
+ }
28607
+ function detectTemplateFieldMisuse(template, specPrompt) {
28608
+ if (!specPrompt)
28609
+ return null;
28610
+ if (template.length > TEMPLATE_FIELD_MISUSE_MAX_LEN)
28611
+ return null;
28612
+ if (!TEMPLATE_FIELD_IDENTIFIER_RE.test(template))
28613
+ return null;
28614
+ if (!Object.prototype.hasOwnProperty.call(specPrompt, template))
28615
+ return null;
28616
+ return template;
28617
+ }
28376
28618
  async function runScriptSpecialist(input2, options) {
28377
28619
  const traceId = randomUUID();
28378
28620
  const startedAt = Date.now();
@@ -28383,6 +28625,25 @@ async function runScriptSpecialist(input2, options) {
28383
28625
  const skillPaths = options.trust?.allowSkills ? collectSkillPaths(spec) : [];
28384
28626
  const skillSources = options.trust?.allowSkills ? computeSkillSources(spec) : undefined;
28385
28627
  const template = input2.template ?? spec.specialist.prompt.task_template;
28628
+ if (input2.template !== undefined) {
28629
+ const misusedField = detectTemplateFieldMisuse(input2.template, spec.specialist.prompt);
28630
+ if (misusedField !== null) {
28631
+ const modelCandidates2 = collectModelCandidates(input2, spec, options);
28632
+ return {
28633
+ success: false,
28634
+ error: `template field misuse: input.template equals spec.prompt.${misusedField} key name (${input2.template.length} chars). The 'template' input field expects the literal template body, not a spec key. To use the spec's default, omit 'template'; to use a non-default template body, pass its full text inline.`,
28635
+ error_type: "template_field_misuse",
28636
+ meta: {
28637
+ specialist: resolvedSpecialist,
28638
+ requested_specialist: input2.requested_specialist ?? input2.specialist,
28639
+ resolved_specialist: resolvedSpecialist,
28640
+ model: modelCandidates2[0],
28641
+ duration_ms: Date.now() - startedAt,
28642
+ trace_id: traceId
28643
+ }
28644
+ };
28645
+ }
28646
+ }
28386
28647
  const prompt = applyOutputContract(renderTaskTemplate(template, input2.variables ?? {}), spec);
28387
28648
  const modelCandidates = collectModelCandidates(input2, spec, options);
28388
28649
  const promptLimitBytes = resolvePromptLimitBytes(spec);
@@ -28432,11 +28693,12 @@ async function runScriptSpecialist(input2, options) {
28432
28693
  writeTraceRow(observability2, resolvedSpecialist, model, traceId, parsed.text, durationMs2, skillSources, options.onAuditFailure);
28433
28694
  if (parsed.kind === "success") {
28434
28695
  let parsed_json;
28435
- if (spec.specialist.execution.response_format === "json") {
28696
+ const expectedKeys = collectRequiredOutputKeys(spec);
28697
+ const shouldParseJson = spec.specialist.execution.response_format === "json" || expectedKeys.length > 0;
28698
+ if (shouldParseJson) {
28436
28699
  try {
28437
28700
  parsed_json = JSON.parse(stripMarkdownFences(parsed.text));
28438
- const required2 = Array.isArray(spec.specialist.prompt.output_schema?.required) ? spec.specialist.prompt.output_schema.required.filter((value) => typeof value === "string") : [];
28439
- for (const key of required2) {
28701
+ for (const key of expectedKeys) {
28440
28702
  if (parsed_json === null || typeof parsed_json !== "object" || !(key in parsed_json))
28441
28703
  throw new Error(`Missing required output field: ${key}`);
28442
28704
  }
@@ -28586,7 +28848,7 @@ function classifyAttempt(attempt) {
28586
28848
  function isRetryableModelFailure(stderr, text) {
28587
28849
  return stderr.includes("0 tokens") || stderr.includes("quota") || stderr.includes("rate limit") || stderr.includes("403") || stderr.includes("401") || stderr.includes("insufficient_quota") || !text && !stderr.trim();
28588
28850
  }
28589
- var CompatGuardError, DEFAULT_PENDING_LINE_LIMIT_BYTES, DEFAULT_ASSISTANT_TEXT_LIMIT_BYTES, DEFAULT_STDERR_LIMIT_BYTES, DEFAULT_PROMPT_LIMIT_BYTES;
28851
+ var CompatGuardError, DEFAULT_PENDING_LINE_LIMIT_BYTES, DEFAULT_ASSISTANT_TEXT_LIMIT_BYTES, DEFAULT_STDERR_LIMIT_BYTES, DEFAULT_PROMPT_LIMIT_BYTES, TEMPLATE_FIELD_MISUSE_MAX_LEN = 30, TEMPLATE_FIELD_IDENTIFIER_RE;
28590
28852
  var init_script_runner = __esm(() => {
28591
28853
  init_observability_sqlite();
28592
28854
  CompatGuardError = class CompatGuardError extends Error {
@@ -28601,6 +28863,7 @@ var init_script_runner = __esm(() => {
28601
28863
  DEFAULT_ASSISTANT_TEXT_LIMIT_BYTES = 4 * 1024 * 1024;
28602
28864
  DEFAULT_STDERR_LIMIT_BYTES = 1 * 1024 * 1024;
28603
28865
  DEFAULT_PROMPT_LIMIT_BYTES = 4 * 1024 * 1024;
28866
+ TEMPLATE_FIELD_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
28604
28867
  });
28605
28868
 
28606
28869
  // src/cli/validate.ts
@@ -29203,12 +29466,22 @@ function createUserFork(source, targetName) {
29203
29466
  }
29204
29467
  async function resolveTargets(args) {
29205
29468
  const loader = new SpecialistLoader;
29206
- const allSpecialists = (await loader.list()).filter((specialist) => specialist.scope !== "package");
29469
+ const listedSpecialists = await loader.list();
29470
+ const allSpecialists = listedSpecialists.filter((specialist) => specialist.scope !== "package");
29207
29471
  if (args.all) {
29208
29472
  return allSpecialists;
29209
29473
  }
29210
29474
  const match = allSpecialists.find((specialist) => specialist.name === args.name && (args.scope === undefined || specialist.scope === args.scope));
29211
29475
  if (!match) {
29476
+ const packageMatch = args.scope === undefined ? listedSpecialists.find((specialist) => specialist.name === args.name && specialist.scope === "package") : undefined;
29477
+ if (packageMatch) {
29478
+ fail(`Error: specialist "${args.name}" lives in [package] tier and cannot be edited directly.
29479
+ ` + ` Fork to user tier first:
29480
+
29481
+ ` + ` ${yellow8(`specialists edit ${args.name} --fork-from ${args.name}`)}
29482
+
29483
+ ` + ` Then re-run your edit command.`);
29484
+ }
29212
29485
  const hint = args.scope ? ` (scope: ${args.scope})` : "";
29213
29486
  fail(`Error: specialist "${args.name}" not found${hint}
29214
29487
  Run ${yellow8("specialists list")} to see available specialists`);
@@ -29686,7 +29959,7 @@ var init_config = __esm(() => {
29686
29959
  });
29687
29960
 
29688
29961
  // src/specialist/worktree.ts
29689
- import { existsSync as existsSync16, symlinkSync as symlinkSync2, mkdirSync as mkdirSync8 } from "fs";
29962
+ import { existsSync as existsSync16, symlinkSync as symlinkSync2, mkdirSync as mkdirSync8, rmSync as rmSync2 } from "fs";
29690
29963
  import { join as join16, resolve as resolve9 } from "path";
29691
29964
  import { spawnSync as spawnSync11, execFileSync as execFileSync2 } from "child_process";
29692
29965
  function deriveBranchName(beadId, specialistName) {
@@ -29726,9 +29999,52 @@ function provisionWorktree(options) {
29726
29999
  const worktreeName = deriveWorktreeName(options.beadId, options.specialistName);
29727
30000
  const worktreePath = resolve9(join16(worktreeBase, worktreeName));
29728
30001
  createWorktreeViaBd(worktreePath, branch, commonRoot);
30002
+ normalizeParentHooksPath(commonRoot);
30003
+ try {
30004
+ rmSync2(join16(worktreePath, ".beads"), { recursive: true, force: true });
30005
+ markBeadsSkipWorktree(worktreePath);
30006
+ } catch {}
29729
30007
  symlinkPiNpmCache(commonRoot, worktreePath);
29730
30008
  return { branch, worktreePath, reused: false };
29731
30009
  }
30010
+ function normalizeParentHooksPath(mainRepoRoot) {
30011
+ try {
30012
+ const result = spawnSync11("git", ["-C", mainRepoRoot, "config", "--get", "core.hooksPath"], {
30013
+ stdio: "pipe",
30014
+ encoding: "utf8"
30015
+ });
30016
+ if (result.status !== 0)
30017
+ return;
30018
+ const current = (result.stdout ?? "").trim();
30019
+ if (!current)
30020
+ return;
30021
+ if (resolve9(current) === current)
30022
+ return;
30023
+ if (current !== ".beads/hooks" && current !== "./.beads/hooks")
30024
+ return;
30025
+ const absolute = join16(mainRepoRoot, ".beads", "hooks");
30026
+ spawnSync11("git", ["-C", mainRepoRoot, "config", "core.hooksPath", absolute], { stdio: "pipe" });
30027
+ } catch {}
30028
+ }
30029
+ function markBeadsSkipWorktree(worktreePath) {
30030
+ try {
30031
+ const trackedResult = spawnSync11("git", ["-C", worktreePath, "ls-files", "--", ".beads"], {
30032
+ cwd: worktreePath,
30033
+ stdio: "pipe",
30034
+ encoding: "utf8"
30035
+ });
30036
+ if (trackedResult.status !== 0)
30037
+ return;
30038
+ const trackedPaths = (trackedResult.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
30039
+ if (trackedPaths.length === 0)
30040
+ return;
30041
+ spawnSync11("git", ["-C", worktreePath, "update-index", "--skip-worktree", "--", ...trackedPaths], {
30042
+ cwd: worktreePath,
30043
+ stdio: "pipe",
30044
+ encoding: "utf8"
30045
+ });
30046
+ } catch {}
30047
+ }
29732
30048
  function symlinkPiNpmCache(commonRoot, worktreePath) {
29733
30049
  const source = join16(commonRoot, ".pi", "npm");
29734
30050
  const target = join16(worktreePath, ".pi", "npm");
@@ -29775,7 +30091,7 @@ var init_worktree = __esm(() => {
29775
30091
  });
29776
30092
 
29777
30093
  // src/specialist/epic-reconciler.ts
29778
- import { mkdirSync as mkdirSync9, openSync as openSync2, readFileSync as readFileSync15, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
30094
+ import { mkdirSync as mkdirSync9, openSync as openSync2, readFileSync as readFileSync15, rmSync as rmSync3, writeFileSync as writeFileSync7 } from "fs";
29779
30095
  import { join as join17 } from "path";
29780
30096
  function buildEpicLockPath(epicId) {
29781
30097
  const location = resolveObservabilityDbLocation();
@@ -29803,7 +30119,7 @@ function withEpicAdvisoryLock(epicId, action) {
29803
30119
  } finally {
29804
30120
  if (lockFd !== null) {
29805
30121
  try {
29806
- rmSync2(lockPath, { force: true });
30122
+ rmSync3(lockPath, { force: true });
29807
30123
  } catch {}
29808
30124
  }
29809
30125
  }
@@ -30015,11 +30331,25 @@ import { join as join18 } from "path";
30015
30331
  function parseOptions(argv) {
30016
30332
  let target = "";
30017
30333
  let rebuild = false;
30018
- for (const argument of argv) {
30334
+ let targetBranch = "";
30335
+ for (let index = 0;index < argv.length; index += 1) {
30336
+ const argument = argv[index];
30019
30337
  if (argument === "--rebuild") {
30020
30338
  rebuild = true;
30021
30339
  continue;
30022
30340
  }
30341
+ if (argument === "--target-branch") {
30342
+ const branchName = argv[index + 1];
30343
+ if (!branchName || branchName.startsWith("-")) {
30344
+ throw new Error("Missing value for --target-branch");
30345
+ }
30346
+ if (targetBranch) {
30347
+ throw new Error("Only one target branch is supported");
30348
+ }
30349
+ targetBranch = branchName;
30350
+ index += 1;
30351
+ continue;
30352
+ }
30023
30353
  if (argument.startsWith("-")) {
30024
30354
  throw new Error(`Unknown option: ${argument}`);
30025
30355
  }
@@ -30031,7 +30361,7 @@ function parseOptions(argv) {
30031
30361
  if (!target) {
30032
30362
  throw new Error("Missing merge target");
30033
30363
  }
30034
- return { target, rebuild };
30364
+ return { target, rebuild, targetBranch: targetBranch ? validateTargetBranchRef(targetBranch) : undefined };
30035
30365
  }
30036
30366
  function runCommand(command, args, cwd = process.cwd()) {
30037
30367
  return spawnSync12(command, args, {
@@ -30040,7 +30370,17 @@ function runCommand(command, args, cwd = process.cwd()) {
30040
30370
  stdio: ["ignore", "pipe", "pipe"]
30041
30371
  });
30042
30372
  }
30043
- function resolveDefaultBranchName(cwd = process.cwd()) {
30373
+ function validateTargetBranchRef(targetBranch, cwd = process.cwd()) {
30374
+ const verification = runCommand("git", ["rev-parse", "--verify", `${targetBranch}^{commit}`], cwd);
30375
+ if (verification.status !== 0) {
30376
+ const detail = verification.stderr.trim() || verification.stdout.trim() || "unknown git ref error";
30377
+ throw new Error(`Invalid --target-branch '${targetBranch}': ${detail}`);
30378
+ }
30379
+ return targetBranch;
30380
+ }
30381
+ function resolveDefaultBranchName(cwd = process.cwd(), overrideBranch) {
30382
+ if (overrideBranch)
30383
+ return overrideBranch;
30044
30384
  const symbolicRef = runCommand("git", ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], cwd);
30045
30385
  if (symbolicRef.status === 0) {
30046
30386
  const remoteHeadRef = symbolicRef.stdout.trim();
@@ -30170,7 +30510,7 @@ function checkEpicUnresolvedGuard(chainRootBeadId) {
30170
30510
  return {
30171
30511
  blocked: false,
30172
30512
  epicId: membership.epicId,
30173
- message: `Warning: unable to verify epic ${membership.epicId} status (observability DB unavailable). Proceeding with chain merge.`
30513
+ message: `Warning: unable to verify epic ${membership.epicId} readiness (observability DB unavailable). Proceeding with chain merge.`
30174
30514
  };
30175
30515
  }
30176
30516
  try {
@@ -30186,12 +30526,25 @@ function checkEpicUnresolvedGuard(chainRootBeadId) {
30186
30526
  if (!isEpicUnresolvedState(status)) {
30187
30527
  return { blocked: false, epicId: membership.epicId, epicStatus: status };
30188
30528
  }
30529
+ const summary = loadEpicReadinessSummary(sqliteClient, membership.epicId);
30530
+ const chain = summary.chains.find((entry) => entry.chain_root_bead_id === chainRootBeadId || entry.chain_id === chainRootBeadId);
30531
+ if (!chain) {
30532
+ return {
30533
+ blocked: true,
30534
+ epicId: membership.epicId,
30535
+ epicStatus: status,
30536
+ message: `Chain ${chainRootBeadId} belongs to epic ${membership.epicId} but has no derived readiness record. Use 'sp epic status ${membership.epicId}' to inspect migration state.`
30537
+ };
30538
+ }
30539
+ if (chain.state === "pass") {
30540
+ return { blocked: false, epicId: membership.epicId, epicStatus: status };
30541
+ }
30189
30542
  return {
30190
30543
  blocked: true,
30191
30544
  epicId: membership.epicId,
30192
30545
  epicStatus: status,
30193
- message: `Chain ${chainRootBeadId} belongs to unresolved epic ${membership.epicId} (status: ${status}).
30194
- Use 'sp epic merge ${membership.epicId}' to publish all chains together, or 'sp epic status ${membership.epicId}' to inspect the epic state.`
30546
+ message: `Chain ${chainRootBeadId} blocked by derived readiness: ${chain.blocking_reason ?? chain.state}.
30547
+ Use 'sp epic status ${membership.epicId}' to inspect epic state.`
30195
30548
  };
30196
30549
  } finally {
30197
30550
  sqliteClient.close();
@@ -30466,8 +30819,8 @@ function parseNameStatusLine(line) {
30466
30819
  function isNoisePath(path) {
30467
30820
  return NOISE_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
30468
30821
  }
30469
- function isBranchAlreadyPublished(branch, cwd = process.cwd()) {
30470
- const baseBranch = resolveDefaultBranchName(cwd);
30822
+ function isBranchAlreadyPublished(branch, cwd = process.cwd(), targetBranch) {
30823
+ const baseBranch = resolveDefaultBranchName(cwd, targetBranch);
30471
30824
  const ancestorCheck = runCommand("git", ["merge-base", "--is-ancestor", branch, baseBranch], cwd);
30472
30825
  if (ancestorCheck.status === 0) {
30473
30826
  return true;
@@ -30478,8 +30831,8 @@ function isBranchAlreadyPublished(branch, cwd = process.cwd()) {
30478
30831
  }
30479
30832
  return cherryPickCount.stdout.trim() === "0";
30480
30833
  }
30481
- function previewBranchMergeDelta(branch, cwd = process.cwd()) {
30482
- const baseBranch = resolveDefaultBranchName(cwd);
30834
+ function previewBranchMergeDelta(branch, cwd = process.cwd(), targetBranch) {
30835
+ const baseBranch = resolveDefaultBranchName(cwd, targetBranch);
30483
30836
  const mergeBase = runCommand("git", ["merge-base", baseBranch, branch], cwd);
30484
30837
  if (mergeBase.status !== 0) {
30485
30838
  throw new Error(`Unable to compute merge base for '${baseBranch}' and '${branch}'.`);
@@ -30503,12 +30856,12 @@ function previewBranchMergeDelta(branch, cwd = process.cwd()) {
30503
30856
  substantiveFiles
30504
30857
  };
30505
30858
  }
30506
- function evaluateMergeWorthiness(preview, branch, cwd = process.cwd()) {
30859
+ function evaluateMergeWorthiness(preview, branch, cwd = process.cwd(), targetBranch) {
30507
30860
  if (preview.files.length === 0) {
30508
- return isBranchAlreadyPublished(branch, cwd) ? { shouldMerge: false, reason: "already-published" } : { shouldMerge: false, reason: "empty-delta" };
30861
+ return isBranchAlreadyPublished(branch, cwd, targetBranch) ? { shouldMerge: false, reason: "already-published" } : { shouldMerge: false, reason: "empty-delta" };
30509
30862
  }
30510
30863
  if (preview.substantiveFiles.length === 0) {
30511
- return isBranchAlreadyPublished(branch, cwd) ? { shouldMerge: false, reason: "already-published" } : { shouldMerge: false, reason: "noise-only-delta" };
30864
+ return isBranchAlreadyPublished(branch, cwd, targetBranch) ? { shouldMerge: false, reason: "already-published" } : { shouldMerge: false, reason: "noise-only-delta" };
30512
30865
  }
30513
30866
  return { shouldMerge: true, reason: "ok" };
30514
30867
  }
@@ -30525,9 +30878,9 @@ function throwWorthinessBlockError(target, preview, decision) {
30525
30878
  throw new Error(`Refusing merge for '${target.branch}': ${reason}.
30526
30879
  ` + `Diagnostics: ${summary}`);
30527
30880
  }
30528
- function assertBranchMergeWorthiness(target, cwd = process.cwd()) {
30529
- const preview = previewBranchMergeDelta(target.branch, cwd);
30530
- const decision = evaluateMergeWorthiness(preview, target.branch, cwd);
30881
+ function assertBranchMergeWorthiness(target, cwd = process.cwd(), targetBranch) {
30882
+ const preview = previewBranchMergeDelta(target.branch, cwd, targetBranch);
30883
+ const decision = evaluateMergeWorthiness(preview, target.branch, cwd, targetBranch);
30531
30884
  if (decision.reason === "already-published" || decision.shouldMerge)
30532
30885
  return decision;
30533
30886
  throwWorthinessBlockError(target, preview, decision);
@@ -30553,8 +30906,8 @@ function getCurrentHeadBranch(cwd = process.cwd()) {
30553
30906
  function tryAbortRebase(cwd = process.cwd()) {
30554
30907
  runCommand("git", ["rebase", "--abort"], cwd);
30555
30908
  }
30556
- function rebaseBranchOntoMaster(branch, worktreePath) {
30557
- const baseBranch = resolveDefaultBranchName(worktreePath);
30909
+ function rebaseBranchOntoMaster(branch, worktreePath, targetBranch) {
30910
+ const baseBranch = resolveDefaultBranchName(worktreePath, targetBranch);
30558
30911
  const checkedOutBranch = getCurrentHeadBranch(worktreePath);
30559
30912
  if (checkedOutBranch !== branch) {
30560
30913
  throw new Error(`Expected branch '${branch}' in worktree '${worktreePath}', found '${checkedOutBranch}'.`);
@@ -30588,6 +30941,11 @@ ${conflicts.map((file) => `- ${file}`).join(`
30588
30941
  throw new Error(`Merge conflict while merging '${branch}'.${context}`);
30589
30942
  }
30590
30943
  function runTypecheckGate(cwd = process.cwd()) {
30944
+ const hasTypeScriptConfig = existsSync17(join18(cwd, "tsconfig.json")) || readdirSync6(cwd).some((entry) => entry.startsWith("tsconfig") && entry.endsWith(".json"));
30945
+ if (!hasTypeScriptConfig) {
30946
+ console.log("TypeScript gate: skipped (no tsconfig)");
30947
+ return;
30948
+ }
30591
30949
  const tsc = runCommand("bunx", ["tsc", "--noEmit"], cwd);
30592
30950
  if (tsc.status === 0)
30593
30951
  return;
@@ -30623,7 +30981,7 @@ function printSummary(steps, rebuild) {
30623
30981
  }
30624
30982
  function printUsageAndExit(message) {
30625
30983
  console.error(message);
30626
- console.error("Usage: specialists|sp merge <target-bead-id> [--rebuild]");
30984
+ console.error("Usage: specialists|sp merge <target-bead-id> [--rebuild] [--target-branch <name>]");
30627
30985
  process.exit(1);
30628
30986
  }
30629
30987
  function syncEpicStateAfterMerge(target) {
@@ -30641,6 +30999,7 @@ function syncEpicStateAfterMerge(target) {
30641
30999
  }
30642
31000
  function runMergePlan(targets, options) {
30643
31001
  const mainRepoRoot = resolveMainWorktreeRoot();
31002
+ const targetBranch = options.targetBranch ? validateTargetBranchRef(options.targetBranch, mainRepoRoot) : undefined;
30644
31003
  const shelved = options.mode === "direct" ? (() => {
30645
31004
  const dirtyState = classifyMainRepoDirtyState(targets.map((target) => target.branch), mainRepoRoot);
30646
31005
  if (dirtyState.overlappingPaths.length > 0) {
@@ -30654,7 +31013,7 @@ ${formatDirtyConflictMessage(dirtyState.overlappingPaths)}
30654
31013
  const mergedSteps = [];
30655
31014
  try {
30656
31015
  for (const target of targets) {
30657
- const worthiness = assertBranchMergeWorthiness(target, mainRepoRoot);
31016
+ const worthiness = assertBranchMergeWorthiness(target, mainRepoRoot, targetBranch);
30658
31017
  if (worthiness.reason === "already-published") {
30659
31018
  mergedSteps.push({
30660
31019
  beadId: target.beadId,
@@ -30663,7 +31022,7 @@ ${formatDirtyConflictMessage(dirtyState.overlappingPaths)}
30663
31022
  });
30664
31023
  continue;
30665
31024
  }
30666
- rebaseBranchOntoMaster(target.branch, target.worktreePath);
31025
+ rebaseBranchOntoMaster(target.branch, target.worktreePath, targetBranch);
30667
31026
  mergeBranch(target.branch, mainRepoRoot);
30668
31027
  runTypecheckGate(mainRepoRoot);
30669
31028
  syncEpicStateAfterMerge(target);
@@ -30755,18 +31114,21 @@ async function run13() {
30755
31114
  printUsageAndExit(message);
30756
31115
  }
30757
31116
  const targets = resolveMergeTargets(options.target);
30758
- const mergedSteps = runMergePlan(targets, { rebuild: options.rebuild });
31117
+ const mergedSteps = runMergePlan(targets, { rebuild: options.rebuild, targetBranch: options.targetBranch });
30759
31118
  printSummary(mergedSteps, options.rebuild);
30760
31119
  }
30761
31120
  var TERMINAL_STATUSES, NOISE_PATH_PREFIXES, MERGE_DIRTY_IGNORE_PREFIXES;
30762
31121
  var init_merge = __esm(() => {
30763
31122
  init_observability_sqlite();
31123
+ init_epic_readiness();
30764
31124
  init_epic_reconciler();
30765
31125
  init_epic_lifecycle();
30766
31126
  TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]);
30767
31127
  NOISE_PATH_PREFIXES = [".xtrm/reports/", ".wolf/", ".specialists/jobs/"];
30768
31128
  MERGE_DIRTY_IGNORE_PREFIXES = [
30769
31129
  ...NOISE_PATH_PREFIXES,
31130
+ ".beads/",
31131
+ ".xtrm/skills/active/",
30770
31132
  "dist/"
30771
31133
  ];
30772
31134
  });
@@ -30864,15 +31226,37 @@ function formatEventLine(event, options) {
30864
31226
  const detailParts = [];
30865
31227
  let detail = "";
30866
31228
  if (event.type === "meta") {
30867
- detailParts.push(`model=${event.model}`);
30868
- detailParts.push(`backend=${event.backend}`);
30869
- if (event.source)
30870
- detailParts.push(`source=${event.source}`);
31229
+ if (event.model === "gitnexus_analyze_started") {
31230
+ detailParts.push("gitnexus=analyze_started");
31231
+ detailParts.push(`source=${event.backend}`);
31232
+ } else if (event.model === "gitnexus_analyze_start_failed") {
31233
+ detailParts.push("gitnexus=analyze_start_failed");
31234
+ detailParts.push(`reason=${event.backend}`);
31235
+ } else {
31236
+ detailParts.push(`model=${event.model}`);
31237
+ detailParts.push(`backend=${event.backend}`);
31238
+ if (event.source)
31239
+ detailParts.push(`source=${event.source}`);
31240
+ }
30871
31241
  } else if (event.type === "tool") {
30872
31242
  detail = formatToolDetail(event);
30873
31243
  } else if (event.type === "error") {
30874
31244
  detailParts.push(`source=${event.source}`);
30875
31245
  detailParts.push(`error=${event.error_message}`);
31246
+ } else if (event.type === "auto_commit_success" || event.type === "auto_commit_skipped" || event.type === "auto_commit_failed") {
31247
+ const status = event.type.replace("auto_commit_", "");
31248
+ detailParts.push(`status=${status}`);
31249
+ if (event.commit_sha)
31250
+ detailParts.push(`commit=${event.commit_sha.slice(0, 12)}`);
31251
+ if (event.committed_files) {
31252
+ detailParts.push(`files=${event.committed_files.length}`);
31253
+ if (event.committed_files.length > 0) {
31254
+ const filePreview = event.committed_files.slice(0, 3).join(",");
31255
+ detailParts.push(`paths=${filePreview}${event.committed_files.length > 3 ? ",\u2026" : ""}`);
31256
+ }
31257
+ }
31258
+ if (event.reason)
31259
+ detailParts.push(`reason=${event.reason}`);
30876
31260
  } else if (event.type === "run_complete") {
30877
31261
  detailParts.push(`status=${event.status}`);
30878
31262
  detailParts.push(`elapsed=${formatElapsed(event.elapsed_s)}`);
@@ -30997,17 +31381,21 @@ var init_format_helpers = __esm(() => {
30997
31381
  turn_summary: "TURN+",
30998
31382
  compaction: "CMPCT",
30999
31383
  retry: "RETRY",
31000
- error: "ERROR"
31384
+ error: "ERROR",
31385
+ auto_commit_success: "AUTO+",
31386
+ auto_commit_skipped: "AUTO-",
31387
+ auto_commit_failed: "AUTO!"
31001
31388
  };
31002
31389
  });
31003
31390
 
31004
31391
  // src/cli/run.ts
31005
31392
  var exports_run = {};
31006
31393
  __export(exports_run, {
31007
- run: () => run14
31394
+ run: () => run14,
31395
+ buildInjectedReviewerDiffVariables: () => buildInjectedReviewerDiffVariables
31008
31396
  });
31009
31397
  import { join as join19 } from "path";
31010
- import { readFileSync as readFileSync17 } from "fs";
31398
+ import { existsSync as existsSync18, readFileSync as readFileSync17, readdirSync as readdirSync7, statSync as statSync3, writeFileSync as writeFileSync8 } from "fs";
31011
31399
  import { randomBytes } from "crypto";
31012
31400
  import { spawn as cpSpawn, execSync as execSync4 } from "child_process";
31013
31401
  async function parseArgs7(argv) {
@@ -31182,12 +31570,58 @@ function resolveEpicIdForBead(sqliteClient, beadId) {
31182
31570
  return;
31183
31571
  return bead.parent;
31184
31572
  }
31185
- function assertNoStaleBaseSiblings(beadId, forceStaleBase) {
31186
- const sqliteClient = createObservabilitySqliteClient();
31187
- if (!sqliteClient)
31573
+ function ensureObservabilityDb2(cwd = process.cwd()) {
31574
+ const existing = createObservabilitySqliteClient(cwd);
31575
+ if (existing) {
31576
+ existing.close();
31188
31577
  return;
31189
- try {
31190
- const epicId = resolveEpicIdForBead(sqliteClient, beadId);
31578
+ }
31579
+ const location = resolveObservabilityDbLocation(cwd);
31580
+ const bootstrapped = createObservabilitySqliteClientAtPath(location.dbPath);
31581
+ if (!bootstrapped)
31582
+ return;
31583
+ bootstrapped.close();
31584
+ }
31585
+ function resolveNewestJobIdFromDb(cwd, jobsDir, specialist, previousLatest, minStartedAtMs) {
31586
+ const sqliteClient = createObservabilitySqliteClient(cwd);
31587
+ if (!sqliteClient)
31588
+ return "";
31589
+ try {
31590
+ const newest = sqliteClient.listStatuses().filter((status) => {
31591
+ if (status.specialist !== specialist || status.id === previousLatest || status.started_at_ms < minStartedAtMs)
31592
+ return false;
31593
+ return existsSync18(join19(jobsDir, status.id, "status.json"));
31594
+ }).sort((left, right) => right.started_at_ms - left.started_at_ms || left.id.localeCompare(right.id))[0];
31595
+ return newest?.id ?? "";
31596
+ } catch {
31597
+ return "";
31598
+ } finally {
31599
+ sqliteClient.close();
31600
+ }
31601
+ }
31602
+ function resolveNewestJobIdFromJobsDir(jobsDir, previousLatest, minMtimeMs) {
31603
+ try {
31604
+ const entries = readdirSync7(jobsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && /^[a-f0-9]{6}$/.test(entry.name) && entry.name !== previousLatest).map((entry) => {
31605
+ const dirPath = join19(jobsDir, entry.name);
31606
+ const statusPath = join19(dirPath, "status.json");
31607
+ const stats = statSync3(dirPath);
31608
+ const statusStats = statSync3(statusPath);
31609
+ return {
31610
+ id: entry.name,
31611
+ mtimeMs: Math.max(stats.mtimeMs, statusStats.mtimeMs)
31612
+ };
31613
+ }).filter((entry) => entry.mtimeMs >= minMtimeMs).sort((left, right) => right.mtimeMs - left.mtimeMs || left.id.localeCompare(right.id));
31614
+ return entries[0]?.id ?? "";
31615
+ } catch {
31616
+ return "";
31617
+ }
31618
+ }
31619
+ function assertNoStaleBaseSiblings(beadId, forceStaleBase) {
31620
+ const sqliteClient = createObservabilitySqliteClient();
31621
+ if (!sqliteClient)
31622
+ return;
31623
+ try {
31624
+ const epicId = resolveEpicIdForBead(sqliteClient, beadId);
31191
31625
  if (!epicId)
31192
31626
  return;
31193
31627
  const siblingChains = sqliteClient.listEpicChainsWithLatestJob(epicId).filter((chain) => chain.chain_root_bead_id !== beadId && Boolean(chain.branch));
@@ -31417,7 +31851,7 @@ function buildInjectedReviewerDiffVariables(cwd, maxFiles = 20) {
31417
31851
  for (const src of sources) {
31418
31852
  const stat2 = read(src.statCmd);
31419
31853
  const files = read(src.namesCmd).split(`
31420
- `).map((line) => line.trim()).filter(Boolean).slice(0, maxFiles);
31854
+ `).map((line) => line.trim()).filter(Boolean).slice(0, maxFiles).filter((file) => !AUTO_COMMIT_NOISE_PREFIXES.some((prefix) => file.startsWith(prefix)));
31421
31855
  if (files.length === 0)
31422
31856
  continue;
31423
31857
  let remaining = MAX_TOTAL_HUNKS_CHARS;
@@ -31457,6 +31891,7 @@ ${truncated}` : `### ${file}
31457
31891
  }
31458
31892
  async function run14() {
31459
31893
  const args = await parseArgs7(process.argv.slice(3));
31894
+ ensureObservabilityDb2(process.cwd());
31460
31895
  const loader = new SpecialistLoader;
31461
31896
  const specialist = await loader.get(args.name).catch((err) => {
31462
31897
  process.stderr.write(`Error: ${err?.message ?? err}
@@ -31475,6 +31910,25 @@ async function run14() {
31475
31910
  `);
31476
31911
  process.exit(1);
31477
31912
  }
31913
+ if (args.beadId && !args.reuseJobId) {
31914
+ const sqliteClient = createObservabilitySqliteClient();
31915
+ if (sqliteClient) {
31916
+ try {
31917
+ const existing = sqliteClient.findActiveJob(args.beadId, args.name);
31918
+ if (existing?.job_id) {
31919
+ process.stderr.write(`Error: existing ${existing.status ?? "unknown"} job '${existing.job_id}' already targets bead '${args.beadId}' specialist '${args.name}'.
31920
+ ` + `To resume the keep-alive session: specialists run ${args.name} --job ${existing.job_id} ...
31921
+ ` + `To inspect: specialists ps ${existing.job_id} --json
31922
+ ` + `To cancel: specialists stop ${existing.job_id}
31923
+ `);
31924
+ sqliteClient.close();
31925
+ process.exit(1);
31926
+ }
31927
+ } finally {
31928
+ sqliteClient.close();
31929
+ }
31930
+ }
31931
+ }
31478
31932
  if (args.background) {
31479
31933
  const jobsDir2 = resolveJobsDir();
31480
31934
  const latestPath = join19(jobsDir2, "latest");
@@ -31486,27 +31940,50 @@ async function run14() {
31486
31940
  }
31487
31941
  })();
31488
31942
  const cwd = process.cwd();
31943
+ const launchStartedAt = Date.now();
31489
31944
  const innerArgs = process.argv.slice(2).filter((a) => a !== "--background");
31490
31945
  const cmd = `${process.execPath} ${process.argv[1]} ${innerArgs.map(shellQuote2).join(" ")}`;
31946
+ const tmuxCmd = `/bin/bash -lc ${shellQuote2(`cd ${shellQuote2(cwd)} && exec ${cmd}`)}`;
31491
31947
  let childPid;
31948
+ let childExitCode;
31949
+ let childExitPromise;
31950
+ let handoffPath;
31492
31951
  if (isTmuxAvailable()) {
31493
31952
  const suffix = randomBytes(3).toString("hex");
31494
31953
  const sessionName = buildSessionName(args.name, suffix);
31495
- createTmuxSession(sessionName, cwd, cmd);
31954
+ handoffPath = join19(jobsDir2, `.bg-job-id-${sessionName}`);
31955
+ createTmuxSession(sessionName, cwd, tmuxCmd, { [JOB_ID_HANDOFF_PATH_ENV]: handoffPath });
31496
31956
  } else {
31497
31957
  const child = cpSpawn(process.execPath, [process.argv[1], ...innerArgs], {
31498
31958
  detached: true,
31499
- stdio: "ignore",
31959
+ stdio: ["ignore", "ignore", "pipe"],
31500
31960
  cwd,
31501
31961
  env: process.env
31502
31962
  });
31963
+ const childStderr = child.stderr;
31964
+ if (childStderr) {
31965
+ childStderr.setEncoding("utf8");
31966
+ childStderr.on("data", (chunk) => {
31967
+ process.stderr.write(chunk);
31968
+ });
31969
+ }
31970
+ childExitPromise = new Promise((resolve10) => {
31971
+ child.on("exit", (code) => {
31972
+ childExitCode = code ?? 1;
31973
+ resolve10();
31974
+ });
31975
+ });
31503
31976
  child.unref();
31504
31977
  childPid = child.pid;
31505
31978
  }
31506
- const deadline = Date.now() + 5000;
31979
+ const pollTimeoutMs = isTmuxAvailable() ? 15000 : 5000;
31980
+ const deadline = Date.now() + pollTimeoutMs;
31507
31981
  let jobId2 = "";
31508
31982
  while (Date.now() < deadline) {
31509
- await new Promise((r) => setTimeout(r, 100));
31983
+ await Promise.race([
31984
+ new Promise((r) => setTimeout(r, 100)),
31985
+ childExitPromise
31986
+ ]);
31510
31987
  try {
31511
31988
  const current = readFileSync17(latestPath, "utf-8").trim();
31512
31989
  if (current && current !== oldLatest) {
@@ -31514,6 +31991,26 @@ async function run14() {
31514
31991
  break;
31515
31992
  }
31516
31993
  } catch {}
31994
+ if (!jobId2 && handoffPath) {
31995
+ try {
31996
+ const handoff = readFileSync17(handoffPath, "utf-8").trim();
31997
+ if (/^[a-f0-9]{6}$/.test(handoff)) {
31998
+ jobId2 = handoff;
31999
+ break;
32000
+ }
32001
+ } catch {}
32002
+ }
32003
+ if (childExitCode !== undefined)
32004
+ break;
32005
+ }
32006
+ if (!jobId2 && childExitCode !== undefined && childExitCode !== 0) {
32007
+ process.exit(childExitCode);
32008
+ }
32009
+ if (!jobId2) {
32010
+ jobId2 = resolveNewestJobIdFromDb(cwd, jobsDir2, args.name, oldLatest, launchStartedAt - 1000);
32011
+ }
32012
+ if (!jobId2) {
32013
+ jobId2 = resolveNewestJobIdFromJobsDir(jobsDir2, oldLatest, launchStartedAt - 1000);
31517
32014
  }
31518
32015
  if (jobId2) {
31519
32016
  process.stdout.write(`${jobId2}
@@ -31640,6 +32137,13 @@ async function run14() {
31640
32137
  onJobStarted: ({ id }) => {
31641
32138
  process.stderr.write(dim9(`[job started: ${id}]
31642
32139
  `));
32140
+ const handoffPath = process.env[JOB_ID_HANDOFF_PATH_ENV];
32141
+ if (handoffPath) {
32142
+ try {
32143
+ writeFileSync8(handoffPath, `${id}
32144
+ `, "utf-8");
32145
+ } catch {}
32146
+ }
31643
32147
  if (args.outputMode !== "raw") {
31644
32148
  stopTailer = startEventTailer(id, jobsDir, args.outputMode, args.name, effectiveBeadId);
31645
32149
  }
@@ -31701,7 +32205,7 @@ ${green9("\u2713")} ${footer}
31701
32205
  `));
31702
32206
  process.exit(0);
31703
32207
  }
31704
- var bold11 = (s) => `\x1B[1m${s}\x1B[0m`, dim9 = (s) => `\x1B[2m${s}\x1B[0m`, green9 = (s) => `\x1B[32m${s}\x1B[0m`, cyan6 = (s) => `\x1B[36m${s}\x1B[0m`, BLOCKED_JOB_REUSE_STATUSES;
32208
+ var bold11 = (s) => `\x1B[1m${s}\x1B[0m`, dim9 = (s) => `\x1B[2m${s}\x1B[0m`, green9 = (s) => `\x1B[32m${s}\x1B[0m`, cyan6 = (s) => `\x1B[36m${s}\x1B[0m`, JOB_ID_HANDOFF_PATH_ENV = "SPECIALISTS_BG_JOB_ID_PATH", BLOCKED_JOB_REUSE_STATUSES;
31705
32209
  var init_run = __esm(() => {
31706
32210
  init_loader();
31707
32211
  init_runner();
@@ -31712,6 +32216,7 @@ var init_run = __esm(() => {
31712
32216
  init_job_root();
31713
32217
  init_worktree();
31714
32218
  init_observability_sqlite();
32219
+ init_observability_db();
31715
32220
  init_merge();
31716
32221
  init_format_helpers();
31717
32222
  init_tmux_utils();
@@ -31769,7 +32274,7 @@ var init_node_resolve = __esm(() => {
31769
32274
  });
31770
32275
 
31771
32276
  // src/specialist/job-control.ts
31772
- import { existsSync as existsSync18, readFileSync as readFileSync18, writeFileSync as writeFileSync8 } from "fs";
32277
+ import { existsSync as existsSync19, readFileSync as readFileSync18, writeFileSync as writeFileSync9 } from "fs";
31773
32278
  import { join as join20 } from "path";
31774
32279
 
31775
32280
  class JobControl {
@@ -31845,7 +32350,7 @@ class JobControl {
31845
32350
  return sqliteResult;
31846
32351
  } catch {}
31847
32352
  const resultPath = this.resultPath(jobId);
31848
- if (!existsSync18(resultPath))
32353
+ if (!existsSync19(resultPath))
31849
32354
  return null;
31850
32355
  try {
31851
32356
  return readFileSync18(resultPath, "utf-8");
@@ -31881,7 +32386,7 @@ class JobControl {
31881
32386
  }
31882
32387
  const jsonLine = `${JSON.stringify(payload)}
31883
32388
  `;
31884
- writeFileSync8(status.fifo_path, jsonLine, { flag: "a" });
32389
+ writeFileSync9(status.fifo_path, jsonLine, { flag: "a" });
31885
32390
  }
31886
32391
  resultPath(jobId) {
31887
32392
  return join20(this.jobsDir, jobId, "result.txt");
@@ -33959,7 +34464,7 @@ var exports_node = {};
33959
34464
  __export(exports_node, {
33960
34465
  handleNodeCommand: () => handleNodeCommand
33961
34466
  });
33962
- import { existsSync as existsSync19, readFileSync as readFileSync19, readdirSync as readdirSync7 } from "fs";
34467
+ import { existsSync as existsSync20, readFileSync as readFileSync19, readdirSync as readdirSync8 } from "fs";
33963
34468
  import { randomUUID as randomUUID2 } from "crypto";
33964
34469
  import { spawnSync as spawnSync14 } from "child_process";
33965
34470
  import { basename as basename5, join as join21, resolve as resolve10 } from "path";
@@ -34152,9 +34657,9 @@ function discoverNodeConfigs(cwd) {
34152
34657
  const discoveredByName = new Map;
34153
34658
  for (const directory of NODE_DISCOVERY_DIRS) {
34154
34659
  const absoluteDir = resolve10(cwd, directory.path);
34155
- if (!existsSync19(absoluteDir))
34660
+ if (!existsSync20(absoluteDir))
34156
34661
  continue;
34157
- const files = readdirSync7(absoluteDir).filter((fileName) => fileName.endsWith(NODE_CONFIG_SUFFIX));
34662
+ const files = readdirSync8(absoluteDir).filter((fileName) => fileName.endsWith(NODE_CONFIG_SUFFIX));
34158
34663
  for (const fileName of files) {
34159
34664
  const path = join21(absoluteDir, fileName);
34160
34665
  const name = toNodeName(fileName);
@@ -34174,7 +34679,7 @@ function getNodeDiscoverySummary() {
34174
34679
  }
34175
34680
  function resolveNodeConfigPath(cwd, input2) {
34176
34681
  const explicitPath = resolve10(cwd, input2);
34177
- if (existsSync19(explicitPath)) {
34682
+ if (existsSync20(explicitPath)) {
34178
34683
  return { name: toNodeName(explicitPath), path: explicitPath, source: "repo" };
34179
34684
  }
34180
34685
  const normalizedName = input2.endsWith(NODE_CONFIG_SUFFIX) ? input2.slice(0, -NODE_CONFIG_SUFFIX.length) : input2;
@@ -34824,7 +35329,6 @@ var exports_epic = {};
34824
35329
  __export(exports_epic, {
34825
35330
  handleEpicSyncCommand: () => handleEpicSyncCommand,
34826
35331
  handleEpicStatusCommand: () => handleEpicStatusCommand,
34827
- handleEpicResolveCommand: () => handleEpicResolveCommand,
34828
35332
  handleEpicMergeCommand: () => handleEpicMergeCommand,
34829
35333
  handleEpicListCommand: () => handleEpicListCommand,
34830
35334
  handleEpicCommand: () => handleEpicCommand,
@@ -34858,7 +35362,9 @@ function parseMergeOptions(argv) {
34858
35362
  let rebuild = false;
34859
35363
  let json = false;
34860
35364
  let pr = false;
34861
- for (const argument of argv) {
35365
+ let targetBranch = "";
35366
+ for (let index = 0;index < argv.length; index += 1) {
35367
+ const argument = argv[index];
34862
35368
  if (argument === "--rebuild") {
34863
35369
  rebuild = true;
34864
35370
  continue;
@@ -34871,11 +35377,23 @@ function parseMergeOptions(argv) {
34871
35377
  pr = true;
34872
35378
  continue;
34873
35379
  }
35380
+ if (argument === "--target-branch") {
35381
+ const branchName = argv[index + 1];
35382
+ if (!branchName || branchName.startsWith("-")) {
35383
+ throw new Error("Missing value for --target-branch");
35384
+ }
35385
+ if (targetBranch) {
35386
+ throw new Error("Only one target branch is supported");
35387
+ }
35388
+ targetBranch = branchName;
35389
+ index += 1;
35390
+ continue;
35391
+ }
34874
35392
  if (argument.startsWith("-") && argument !== "--rebuild" && argument !== "--json" && argument !== "--pr") {
34875
35393
  throw new Error(`Unknown option: ${argument}`);
34876
35394
  }
34877
35395
  }
34878
- return { epicId, rebuild, json, pr };
35396
+ return { epicId, rebuild, json, pr, targetBranch: targetBranch || undefined };
34879
35397
  }
34880
35398
  function parseListOptions(argv) {
34881
35399
  let unresolvedOnly = false;
@@ -34909,25 +35427,6 @@ function parseStatusOptions(argv) {
34909
35427
  }
34910
35428
  return { epicId, json };
34911
35429
  }
34912
- function parseResolveOptions(argv) {
34913
- const epicId = parseEpicId(argv);
34914
- let dryRun = false;
34915
- let json = false;
34916
- for (const argument of argv) {
34917
- if (argument === "--dry-run") {
34918
- dryRun = true;
34919
- continue;
34920
- }
34921
- if (argument === "--json") {
34922
- json = true;
34923
- continue;
34924
- }
34925
- if (argument.startsWith("-") && argument !== "--dry-run" && argument !== "--json") {
34926
- throw new Error(`Unknown option: ${argument}`);
34927
- }
34928
- }
34929
- return { epicId, dryRun, json };
34930
- }
34931
35430
  function parseSyncOptions(argv) {
34932
35431
  const epicId = parseEpicId(argv);
34933
35432
  let apply = false;
@@ -35072,12 +35571,9 @@ function gatherEpicContext(options) {
35072
35571
  }
35073
35572
  function validateEpicMergeReadiness(context) {
35074
35573
  const epicState = context.epicRecord?.status ?? "open";
35075
- if (isEpicTerminalState(epicState)) {
35574
+ if (epicState === "merged" || epicState === "abandoned") {
35076
35575
  throw new Error(`Epic ${context.epicId} is already in terminal state '${epicState}'. No further merges allowed.`);
35077
35576
  }
35078
- if (epicState !== "resolving" && epicState !== "merge_ready") {
35079
- throw new Error(`Epic ${context.epicId} is in state '${epicState}'. Must be 'resolving' or 'merge_ready' before publication.`);
35080
- }
35081
35577
  const chainStatuses = [...context.chainJobStatuses.entries()].map(([chainId, status]) => ({
35082
35578
  chainId,
35083
35579
  hasRunningJob: status.hasRunningJob
@@ -35115,11 +35611,12 @@ function updateEpicState(epicId, fromState, toState) {
35115
35611
  sqlite.close();
35116
35612
  }
35117
35613
  }
35118
- function mergeEpicChains(context, rebuild, pr) {
35614
+ function mergeEpicChains(context, rebuild, pr, targetBranch) {
35119
35615
  return executePublicationPlan(context.chainTargets, {
35120
35616
  rebuild,
35121
35617
  mode: pr ? "pr" : "direct",
35122
- publicationLabel: `epic-${context.epicId}`
35618
+ publicationLabel: `epic-${context.epicId}`,
35619
+ targetBranch
35123
35620
  });
35124
35621
  }
35125
35622
  function printEpicMergeSummary(result, rebuild, pr) {
@@ -35203,75 +35700,6 @@ async function handleEpicListCommand(argv) {
35203
35700
  sqlite.close();
35204
35701
  }
35205
35702
  }
35206
- async function handleEpicResolveCommand(argv) {
35207
- let options;
35208
- try {
35209
- options = parseResolveOptions(argv);
35210
- } catch (error2) {
35211
- const message = error2 instanceof Error ? error2.message : String(error2);
35212
- console.error(message);
35213
- console.error("Usage: specialists epic resolve <epic-id> [--dry-run] [--json]");
35214
- process.exit(1);
35215
- }
35216
- const sqlite = createObservabilitySqliteClient();
35217
- if (!sqlite) {
35218
- const message = "Observability SQLite database not available. Run `sp db setup` first.";
35219
- if (options.json) {
35220
- console.log(JSON.stringify({ error: message }, null, 2));
35221
- } else {
35222
- console.error(message);
35223
- }
35224
- process.exit(1);
35225
- }
35226
- try {
35227
- const now = Date.now();
35228
- const existing = sqlite.readEpicRun(options.epicId);
35229
- const fromState = existing?.status ?? "open";
35230
- let toState;
35231
- try {
35232
- toState = transitionEpicState(fromState, "resolving");
35233
- } catch (error2) {
35234
- const message = error2 instanceof Error ? error2.message : String(error2);
35235
- if (options.json) {
35236
- console.log(JSON.stringify({ epic_id: options.epicId, from_state: fromState, error: message }, null, 2));
35237
- } else {
35238
- console.error(`Resolve blocked: ${message}`);
35239
- }
35240
- process.exit(1);
35241
- return;
35242
- }
35243
- if (!options.dryRun) {
35244
- sqlite.upsertEpicRun({
35245
- epic_id: options.epicId,
35246
- status: toState,
35247
- status_json: JSON.stringify({
35248
- epic_id: options.epicId,
35249
- status: toState,
35250
- previous_status: fromState,
35251
- transitioned_at_ms: now
35252
- }),
35253
- updated_at_ms: now
35254
- });
35255
- }
35256
- const transitionSummary = summarizeEpicTransition(options.epicId, fromState, toState);
35257
- if (options.json) {
35258
- console.log(JSON.stringify({
35259
- epic_id: options.epicId,
35260
- from_state: fromState,
35261
- to_state: toState,
35262
- dry_run: options.dryRun,
35263
- summary: transitionSummary
35264
- }, null, 2));
35265
- return;
35266
- }
35267
- console.log(transitionSummary);
35268
- if (options.dryRun) {
35269
- console.log("(dry-run: no state persisted)");
35270
- }
35271
- } finally {
35272
- sqlite.close();
35273
- }
35274
- }
35275
35703
  async function handleEpicMergeCommand(argv) {
35276
35704
  let options;
35277
35705
  try {
@@ -35280,7 +35708,7 @@ async function handleEpicMergeCommand(argv) {
35280
35708
  const message = error2 instanceof Error ? error2.message : String(error2);
35281
35709
  console.error(message);
35282
35710
  console.error("");
35283
- console.error("Usage: specialists epic merge <epic-id> [--rebuild] [--pr] [--json]");
35711
+ console.error("Usage: specialists epic merge <epic-id> [--rebuild] [--pr] [--json] [--target-branch <name>]");
35284
35712
  process.exit(1);
35285
35713
  }
35286
35714
  let context;
@@ -35308,20 +35736,12 @@ async function handleEpicMergeCommand(argv) {
35308
35736
  process.exit(1);
35309
35737
  }
35310
35738
  const fromState = currentState;
35311
- if (currentState === "resolving") {
35312
- const nextState = transitionEpicState(currentState, "merge_ready");
35313
- updateEpicState(context.epicId, currentState, nextState);
35314
- if (!options.json) {
35315
- console.log(summarizeEpicTransition(context.epicId, currentState, nextState));
35316
- }
35317
- currentState = nextState;
35318
- }
35319
35739
  let mergedChains = [];
35320
35740
  let mergeError;
35321
35741
  let toState = currentState;
35322
35742
  let pullRequestUrl;
35323
35743
  try {
35324
- const publicationResult = mergeEpicChains(context, options.rebuild, options.pr);
35744
+ const publicationResult = mergeEpicChains(context, options.rebuild, options.pr, options.targetBranch);
35325
35745
  mergedChains = publicationResult.steps;
35326
35746
  pullRequestUrl = publicationResult.pullRequestUrl;
35327
35747
  toState = options.pr ? currentState : transitionEpicState(currentState, "merged");
@@ -35488,12 +35908,7 @@ async function handleEpicStatusCommand(argv) {
35488
35908
  }
35489
35909
  console.log("");
35490
35910
  console.log(`Epic: ${options.epicId}`);
35491
- if (epicRecord) {
35492
- console.log(`State: ${epicRecord.status}`);
35493
- console.log(`Updated: ${new Date(epicRecord.updated_at_ms).toISOString()}`);
35494
- } else {
35495
- console.log("State: (not tracked in SQLite, defaults to open)");
35496
- }
35911
+ console.log(`State: ${epicRecord?.status ?? "(derived)"}`);
35497
35912
  console.log(`Readiness: ${readiness.isReady ? "ready" : "blocked"}`);
35498
35913
  console.log(`Summary: ${readiness.summary}`);
35499
35914
  console.log("");
@@ -35522,22 +35937,21 @@ async function handleEpicCommand(argv) {
35522
35937
  if (!subcommand || subcommand === "--help" || subcommand === "-h") {
35523
35938
  console.log([
35524
35939
  "",
35525
- "Usage: specialists epic <list|status|resolve|sync|abandon|merge> [options]",
35940
+ "Usage: specialists epic <list|status|sync|abandon|merge> [options]",
35526
35941
  "",
35527
35942
  "Commands:",
35528
- " list [--unresolved] [--json] List epics with lifecycle and readiness summary",
35529
- " status <epic-id> [--json] Show epic state, chain statuses, and merge readiness",
35530
- " resolve <epic-id> [--dry-run] [--json] Transition epic from open to resolving",
35531
- " sync <epic-id> [--apply] [--json] Reconcile epic drift (dry-run by default)",
35943
+ " list [--unresolved] [--json] List epics with readiness summary",
35944
+ " status <epic-id> [--json] Show derived readiness and chain status",
35945
+ " sync <epic-id> [--apply] [--json] Reconcile epic drift (dry-run by default)",
35532
35946
  " abandon <epic-id> --reason <text> [--force] [--json] Transition epic to abandoned",
35533
35947
  " merge <epic-id> [--rebuild] [--pr] [--json] Publish epic-owned chains in dependency order",
35534
35948
  "",
35535
- "Epic lifecycle states:",
35536
- " open \u2192 resolving \u2192 merge_ready \u2192 merged",
35537
- " (any) \u2192 failed / abandoned (terminal)",
35949
+ "Epic readiness:",
35950
+ " status reflects derived readiness from live chain state",
35951
+ " persisted epic state is compatibility metadata only",
35538
35952
  "",
35539
35953
  "Merge behavior:",
35540
- " - Requires epic state: resolving or merge_ready",
35954
+ " - Requires derived readiness: ready chains only",
35541
35955
  " - All chain jobs must be terminal before publication",
35542
35956
  " - Chains merged in topological dependency order",
35543
35957
  " - Use --pr to publish via pull request instead of direct merge",
@@ -35547,13 +35961,13 @@ async function handleEpicCommand(argv) {
35547
35961
  "Examples:",
35548
35962
  " specialists epic list",
35549
35963
  " specialists epic list --unresolved --json",
35550
- " specialists epic resolve unitAI-3f7b",
35551
35964
  " specialists epic status unitAI-3f7b --json",
35552
35965
  " specialists epic sync unitAI-3f7b",
35553
35966
  " specialists epic sync unitAI-3f7b --apply",
35554
35967
  ' specialists epic abandon unitAI-3f7b --reason "scope changed"',
35555
35968
  " specialists epic merge unitAI-3f7b --rebuild",
35556
35969
  " specialists epic merge unitAI-3f7b --pr",
35970
+ " specialists epic merge unitAI-3f7b --target-branch feature/foo",
35557
35971
  ""
35558
35972
  ].join(`
35559
35973
  `));
@@ -35563,10 +35977,6 @@ async function handleEpicCommand(argv) {
35563
35977
  await handleEpicListCommand(argv.slice(1));
35564
35978
  return;
35565
35979
  }
35566
- if (subcommand === "resolve") {
35567
- await handleEpicResolveCommand(argv.slice(1));
35568
- return;
35569
- }
35570
35980
  if (subcommand === "sync") {
35571
35981
  await handleEpicSyncCommand(argv.slice(1));
35572
35982
  return;
@@ -35584,7 +35994,7 @@ async function handleEpicCommand(argv) {
35584
35994
  return;
35585
35995
  }
35586
35996
  console.error(`Unknown epic subcommand: ${subcommand}`);
35587
- console.error("Usage: specialists epic <list|status|resolve|sync|abandon|merge>");
35997
+ console.error("Usage: specialists epic <list|status|sync|abandon|merge>");
35588
35998
  process.exit(1);
35589
35999
  }
35590
36000
  var RUNNING_STATUSES;
@@ -35598,7 +36008,7 @@ var init_epic = __esm(() => {
35598
36008
 
35599
36009
  // src/cli/version-check.ts
35600
36010
  import { spawnSync as spawnSync16 } from "child_process";
35601
- import { existsSync as existsSync20, mkdirSync as mkdirSync10, readFileSync as readFileSync20, writeFileSync as writeFileSync9 } from "fs";
36011
+ import { existsSync as existsSync21, mkdirSync as mkdirSync10, readFileSync as readFileSync20, writeFileSync as writeFileSync10 } from "fs";
35602
36012
  import { dirname as dirname9, join as join22 } from "path";
35603
36013
  import { createRequire as createRequire3 } from "module";
35604
36014
  function readBundledPackageVersion(requireFn = require3) {
@@ -35621,7 +36031,7 @@ function shouldRunVersionCheck() {
35621
36031
  return true;
35622
36032
  }
35623
36033
  function readCache() {
35624
- if (!existsSync20(CACHE_PATH))
36034
+ if (!existsSync21(CACHE_PATH))
35625
36035
  return null;
35626
36036
  try {
35627
36037
  return JSON.parse(readFileSync20(CACHE_PATH, "utf8"));
@@ -35634,7 +36044,7 @@ function readCachedVersionCheck() {
35634
36044
  }
35635
36045
  function writeCache(cache) {
35636
36046
  mkdirSync10(dirname9(CACHE_PATH), { recursive: true });
35637
- writeFileSync9(CACHE_PATH, `${JSON.stringify(cache, null, 2)}
36047
+ writeFileSync10(CACHE_PATH, `${JSON.stringify(cache, null, 2)}
35638
36048
  `, "utf8");
35639
36049
  }
35640
36050
  function isFresh(cache) {
@@ -35731,7 +36141,7 @@ __export(exports_status, {
35731
36141
  detectJobOutputMode: () => detectJobOutputMode
35732
36142
  });
35733
36143
  import { spawnSync as spawnSync17 } from "child_process";
35734
- import { existsSync as existsSync21, readFileSync as readFileSync21 } from "fs";
36144
+ import { existsSync as existsSync22, readFileSync as readFileSync21 } from "fs";
35735
36145
  import { join as join23 } from "path";
35736
36146
  function ok2(msg) {
35737
36147
  console.log(` ${green8("\u2713")} ${msg}`);
@@ -35830,7 +36240,7 @@ function countJobEvents(sqliteClient, jobsDir, jobId) {
35830
36240
  return 0;
35831
36241
  }
35832
36242
  const eventsFile = join23(jobsDir, jobId, "events.jsonl");
35833
- if (!existsSync21(eventsFile))
36243
+ if (!existsSync22(eventsFile))
35834
36244
  return 0;
35835
36245
  const raw = readFileSync21(eventsFile, "utf-8").trim();
35836
36246
  if (!raw)
@@ -35867,7 +36277,7 @@ function getLatestContextSnapshot(sqliteClient, jobsDir, jobId) {
35867
36277
  return null;
35868
36278
  }
35869
36279
  const eventsFile = join23(jobsDir, jobId, "events.jsonl");
35870
- if (!existsSync21(eventsFile))
36280
+ if (!existsSync22(eventsFile))
35871
36281
  return null;
35872
36282
  const lines = readFileSync21(eventsFile, "utf-8").split(`
35873
36283
  `);
@@ -35974,12 +36384,12 @@ async function run15() {
35974
36384
  `).slice(1).map((line) => line.split(/\s+/)[0]).filter(Boolean)) : new Set;
35975
36385
  const bdInstalled = isInstalled2("bd");
35976
36386
  const bdVersion = bdInstalled ? cmd("bd", ["--version"]) : null;
35977
- const beadsPresent = existsSync21(join23(process.cwd(), ".beads"));
36387
+ const beadsPresent = existsSync22(join23(process.cwd(), ".beads"));
35978
36388
  const specialistsBin = cmd("which", ["specialists"]);
35979
36389
  const jobsDir = resolveJobsDir();
35980
36390
  const jobFileOutputMode = detectJobFileOutputMode();
35981
36391
  let jobs = [];
35982
- if (existsSync21(jobsDir)) {
36392
+ if (existsSync22(jobsDir)) {
35983
36393
  supervisor = new Supervisor({
35984
36394
  runner: null,
35985
36395
  runOptions: null,
@@ -36145,14 +36555,346 @@ var init_status = __esm(() => {
36145
36555
  init_version_check();
36146
36556
  });
36147
36557
 
36558
+ // src/specialist/process-health.ts
36559
+ import { existsSync as existsSync23, readdirSync as readdirSync9, readFileSync as readFileSync22, readlinkSync as readlinkSync2 } from "fs";
36560
+ import { join as join24 } from "path";
36561
+ function parseThreshold(raw, fallback) {
36562
+ if (!raw)
36563
+ return fallback;
36564
+ const parsed = Number(raw);
36565
+ if (!Number.isFinite(parsed) || parsed < 0)
36566
+ return fallback;
36567
+ return parsed;
36568
+ }
36569
+ function getProcessHealthThresholds(env = process.env) {
36570
+ const warnPct = parseThreshold(env.SPECIALISTS_HEALTH_WARN_PCT, DEFAULT_WARN_PCT);
36571
+ const refusePct = parseThreshold(env.SPECIALISTS_HEALTH_REFUSE_PCT, DEFAULT_REFUSE_PCT);
36572
+ return { warnPct, refusePct };
36573
+ }
36574
+ function readMemAvailableBytes(meminfoPath) {
36575
+ try {
36576
+ const content = readFileSync22(meminfoPath, "utf-8");
36577
+ const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(content);
36578
+ if (!match)
36579
+ return 0;
36580
+ return Number(match[1]) * 1024;
36581
+ } catch {
36582
+ return 0;
36583
+ }
36584
+ }
36585
+ function readProcStringOrNull(path) {
36586
+ try {
36587
+ return readFileSync22(path, "utf-8");
36588
+ } catch {
36589
+ return null;
36590
+ }
36591
+ }
36592
+ function readProcCwdOrNull(pid, procRoot) {
36593
+ try {
36594
+ return readlinkSync2(join24(procRoot, String(pid), "cwd"));
36595
+ } catch {
36596
+ return null;
36597
+ }
36598
+ }
36599
+ function parseStat(stat2) {
36600
+ const closeParen = stat2.lastIndexOf(")");
36601
+ if (closeParen < 0)
36602
+ return null;
36603
+ const fields = stat2.slice(closeParen + 2).trim().replace(/\s+/g, " ").split(" ");
36604
+ const ppid = Number(fields[1]);
36605
+ const utimeTicks = Number(fields[11]);
36606
+ const stimeTicks = Number(fields[12]);
36607
+ const startTimeTicks = Number(fields[19]);
36608
+ if (![ppid, utimeTicks, stimeTicks, startTimeTicks].every(Number.isFinite))
36609
+ return null;
36610
+ return { ppid, utimeTicks, stimeTicks, startTimeTicks };
36611
+ }
36612
+ function readProcUptimeSecondsOrNull(procRoot) {
36613
+ try {
36614
+ const match = /^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)$/.exec(readFileSync22(join24(procRoot, "uptime"), "utf-8").trim());
36615
+ if (!match)
36616
+ return null;
36617
+ return Number(match[1]);
36618
+ } catch {
36619
+ return null;
36620
+ }
36621
+ }
36622
+ function readProcessLiveness(pid, procRoot) {
36623
+ if (!existsSync23(join24(procRoot, String(pid))))
36624
+ return "dead";
36625
+ try {
36626
+ process.kill(pid, 0);
36627
+ return "alive";
36628
+ } catch (error2) {
36629
+ if (error2 instanceof Error && "code" in error2 && error2.code === "ESRCH")
36630
+ return "dead";
36631
+ return "alive";
36632
+ }
36633
+ }
36634
+ function readProcessSnapshot(pid, procRoot, uptimeSeconds) {
36635
+ const basePath = join24(procRoot, String(pid));
36636
+ const cmdlineRaw = readProcStringOrNull(join24(basePath, "cmdline"));
36637
+ if (!cmdlineRaw)
36638
+ return null;
36639
+ const cmdline = cmdlineRaw.replace(/\0/g, " ").trim();
36640
+ if (!cmdline)
36641
+ return null;
36642
+ const comm = (readProcStringOrNull(join24(basePath, "comm")) ?? "").trim();
36643
+ const stat2 = readProcStringOrNull(join24(basePath, "stat"));
36644
+ const parsedStat = stat2 ? parseStat(stat2) : null;
36645
+ if (!parsedStat)
36646
+ return null;
36647
+ const status = readProcStringOrNull(join24(basePath, "status")) ?? "";
36648
+ const rssMatch = /VmRSS:\s+(\d+)\s+kB/m.exec(status);
36649
+ const rssBytes = rssMatch ? Number(rssMatch[1]) * 1024 : 0;
36650
+ const cwd = readProcCwdOrNull(pid, procRoot);
36651
+ const cpuSeconds = (parsedStat.utimeTicks + parsedStat.stimeTicks) / CLOCK_TICKS_PER_SECOND;
36652
+ const ageSeconds = Math.max(0, uptimeSeconds - parsedStat.startTimeTicks / CLOCK_TICKS_PER_SECOND);
36653
+ const cpuPct = ageSeconds > 0 ? cpuSeconds / ageSeconds * 100 : 0;
36654
+ return { pid, ppid: parsedStat.ppid, cmdline, comm, cwd, rssBytes, cpuPct, ageSeconds };
36655
+ }
36656
+ function listPids(procRoot) {
36657
+ if (!existsSync23(procRoot))
36658
+ return [];
36659
+ const pids = [];
36660
+ for (const entry of readdirSync9(procRoot, { withFileTypes: true })) {
36661
+ if (!entry.isDirectory())
36662
+ continue;
36663
+ const pid = Number(entry.name);
36664
+ if (Number.isInteger(pid) && pid > 0)
36665
+ pids.push(pid);
36666
+ }
36667
+ return pids;
36668
+ }
36669
+ function getWorktreeFromCwd(cwd) {
36670
+ if (!cwd)
36671
+ return null;
36672
+ const marker = "/.worktrees/";
36673
+ const index = cwd.indexOf(marker);
36674
+ if (index < 0)
36675
+ return null;
36676
+ const tail = cwd.slice(index + marker.length);
36677
+ const slash = tail.indexOf("/");
36678
+ return cwd.slice(0, index + marker.length + (slash < 0 ? tail.length : slash));
36679
+ }
36680
+ function basename6(command) {
36681
+ return command.split("/").pop() ?? command;
36682
+ }
36683
+ function isShellWrapper(command) {
36684
+ return ["sh", "bash", "zsh", "fish"].includes(basename6(command));
36685
+ }
36686
+ function isSpecialistRunCommand(cmdline) {
36687
+ const tokens = cmdline.split(/\s+/).filter(Boolean);
36688
+ if (tokens.length === 0 || isShellWrapper(tokens[0]))
36689
+ return false;
36690
+ const commandIndex = tokens.findIndex((token) => ["specialists", "sp"].includes(basename6(token)));
36691
+ return commandIndex >= 0 && tokens[commandIndex + 1] === "run";
36692
+ }
36693
+ function isPiAgentProcess(snapshot) {
36694
+ return snapshot.cmdline.includes("pi-coding-agent");
36695
+ }
36696
+ function isPiOrphanCandidate(snapshot) {
36697
+ return snapshot.comm === "pi" || isPiAgentProcess(snapshot);
36698
+ }
36699
+ function isDeletedCwdToolProcess(snapshot) {
36700
+ if (!snapshot.cwd?.includes("(deleted)"))
36701
+ return false;
36702
+ return isPiOrphanCandidate(snapshot) || isSpecialistRunCommand(snapshot.cmdline) || snapshot.cmdline.includes("gitnexus") || snapshot.cmdline.includes("serena") || snapshot.cmdline.includes("tsserver");
36703
+ }
36704
+ function classifyProcess(snapshot) {
36705
+ const { cmdline, ppid } = snapshot;
36706
+ if (cmdline.includes("dolt sql-server"))
36707
+ return "dolt";
36708
+ if (cmdline.includes("serena") && (cmdline.includes("language-server") || cmdline.includes("lsp")))
36709
+ return "serena-lsp";
36710
+ if (isDeletedCwdToolProcess(snapshot))
36711
+ return "orphan";
36712
+ if ((isPiOrphanCandidate(snapshot) || cmdline.includes("gitnexus") && cmdline.includes("mcp")) && ppid === 1)
36713
+ return "orphan";
36714
+ if (isPiAgentProcess(snapshot) || isSpecialistRunCommand(cmdline))
36715
+ return "specialist";
36716
+ return null;
36717
+ }
36718
+ function getOrphanReason(snapshot) {
36719
+ if (snapshot.cmdline.includes("dolt sql-server"))
36720
+ return "dolt-worktree-local";
36721
+ if (isDeletedCwdToolProcess(snapshot))
36722
+ return "deleted-worktree-process";
36723
+ if (snapshot.cmdline.includes("gitnexus") && snapshot.cmdline.includes("mcp") && snapshot.ppid === 1)
36724
+ return "gitnexus-orphan";
36725
+ if (isPiOrphanCandidate(snapshot) && snapshot.ppid === 1)
36726
+ return "pi-orphan";
36727
+ return null;
36728
+ }
36729
+ function toProcessHealthProcess(snapshot, kind) {
36730
+ return {
36731
+ pid: snapshot.pid,
36732
+ ppid: snapshot.ppid,
36733
+ kind,
36734
+ role: kind,
36735
+ cmdline: snapshot.cmdline,
36736
+ cwd: snapshot.cwd,
36737
+ rssBytes: snapshot.rssBytes,
36738
+ cpuPct: snapshot.cpuPct,
36739
+ ageSeconds: snapshot.ageSeconds,
36740
+ worktree: getWorktreeFromCwd(snapshot.cwd)
36741
+ };
36742
+ }
36743
+ function collectProcessHealth(options = {}) {
36744
+ const procRoot = options.procRoot ?? "/proc";
36745
+ const meminfoPath = options.meminfoPath ?? "/proc/meminfo";
36746
+ const uptimeSeconds = readProcUptimeSecondsOrNull(procRoot) ?? (options.nowMs ?? Date.now()) / 1000;
36747
+ const thresholds = getProcessHealthThresholds();
36748
+ const memAvailableBytes = readMemAvailableBytes(meminfoPath);
36749
+ const processes = [];
36750
+ for (const pid of listPids(procRoot)) {
36751
+ const snapshot = readProcessSnapshot(pid, procRoot, uptimeSeconds);
36752
+ if (!snapshot)
36753
+ continue;
36754
+ const kind = classifyProcess(snapshot);
36755
+ if (!kind)
36756
+ continue;
36757
+ const process3 = toProcessHealthProcess(snapshot, kind);
36758
+ if (kind === "orphan")
36759
+ process3.reason = getOrphanReason(snapshot) ?? undefined;
36760
+ processes.push(process3);
36761
+ }
36762
+ const specialistProcesses = processes.filter((process3) => process3.kind === "specialist");
36763
+ const doltProcesses = processes.filter((process3) => process3.kind === "dolt");
36764
+ const serenaProcesses = processes.filter((process3) => process3.kind === "serena-lsp");
36765
+ const orphanProcesses = processes.filter((process3) => process3.kind === "orphan");
36766
+ const serenaMap = new Map;
36767
+ for (const process3 of serenaProcesses) {
36768
+ const workspace = process3.worktree ?? process3.cwd ?? "unknown";
36769
+ if (!serenaMap.has(workspace))
36770
+ serenaMap.set(workspace, []);
36771
+ serenaMap.get(workspace).push(process3);
36772
+ }
36773
+ const warnLimitBytes = Math.floor(memAvailableBytes * (thresholds.warnPct / 100));
36774
+ const refuseLimitBytes = Math.floor(memAvailableBytes * (thresholds.refusePct / 100));
36775
+ const totalRssBytes = processes.reduce((sum, process3) => sum + process3.rssBytes, 0);
36776
+ const totalCpuPct = processes.reduce((sum, process3) => sum + process3.cpuPct, 0);
36777
+ const thresholdPct = memAvailableBytes > 0 ? totalRssBytes / memAvailableBytes * 100 : 0;
36778
+ const statusReasons = [];
36779
+ if (thresholdPct >= thresholds.refusePct)
36780
+ statusReasons.push(`rss >= refuse threshold (${thresholds.refusePct}%)`);
36781
+ else if (thresholdPct >= thresholds.warnPct)
36782
+ statusReasons.push(`rss >= warn threshold (${thresholds.warnPct}%)`);
36783
+ if (doltProcesses.length > 1)
36784
+ statusReasons.push(`dolt sql-server count ${doltProcesses.length} > expected 1`);
36785
+ if (orphanProcesses.length > 0)
36786
+ statusReasons.push(`orphan process count ${orphanProcesses.length} > 0`);
36787
+ const status = thresholdPct >= thresholds.refusePct ? "REFUSE" : statusReasons.length > 0 ? "WARN" : "OK";
36788
+ return {
36789
+ status,
36790
+ statusReasons,
36791
+ memAvailableBytes,
36792
+ totalRssBytes,
36793
+ totalCpuPct,
36794
+ specialistCount: specialistProcesses.length,
36795
+ doltCount: doltProcesses.length,
36796
+ serenaLspCount: serenaProcesses.length,
36797
+ orphanCount: orphanProcesses.length,
36798
+ thresholdPct,
36799
+ warnPct: thresholds.warnPct,
36800
+ refusePct: thresholds.refusePct,
36801
+ warnLimitBytes,
36802
+ refuseLimitBytes,
36803
+ specialistProcesses,
36804
+ doltProcesses,
36805
+ serenaWorkspaces: [...serenaMap.entries()].map(([workspace, workspaceProcesses]) => ({
36806
+ workspace,
36807
+ count: workspaceProcesses.length,
36808
+ rssBytes: workspaceProcesses.reduce((sum, process3) => sum + process3.rssBytes, 0),
36809
+ processes: workspaceProcesses
36810
+ })).sort((left, right) => right.rssBytes - left.rssBytes),
36811
+ orphanProcesses
36812
+ };
36813
+ }
36814
+ function withReason(process3, reason) {
36815
+ return { ...process3, reason };
36816
+ }
36817
+ function hasDeletedCwd(process3) {
36818
+ return Boolean(process3.cwd?.includes("(deleted)"));
36819
+ }
36820
+ function collectOrphanProcesses(options = {}) {
36821
+ const health = collectProcessHealth(options);
36822
+ const reaped = new Map;
36823
+ for (const process3 of health.orphanProcesses)
36824
+ reaped.set(process3.pid, process3);
36825
+ for (const process3 of health.doltProcesses) {
36826
+ if (hasDeletedCwd(process3))
36827
+ reaped.set(process3.pid, withReason(process3, "dolt-worktree-local"));
36828
+ }
36829
+ for (const process3 of health.specialistProcesses) {
36830
+ if (hasDeletedCwd(process3))
36831
+ reaped.set(process3.pid, withReason(process3, "deleted-worktree-process"));
36832
+ }
36833
+ for (const workspace of health.serenaWorkspaces) {
36834
+ for (const process3 of workspace.processes) {
36835
+ if (hasDeletedCwd(process3))
36836
+ reaped.set(process3.pid, withReason(process3, "deleted-worktree-process"));
36837
+ }
36838
+ }
36839
+ return [...reaped.values()].sort((left, right) => left.pid - right.pid);
36840
+ }
36841
+ function collectStaleSpecialistJobs(options = {}) {
36842
+ const procRoot = options.procRoot ?? "/proc";
36843
+ const nowMs = options.nowMs ?? Date.now();
36844
+ const minKeepAliveAgeMs = options.minKeepAliveAgeMs ?? 30 * 60 * 1000;
36845
+ const observabilityClient = options.observabilityClient ?? createObservabilitySqliteClient();
36846
+ const statuses = observabilityClient?.listStatuses() ?? [];
36847
+ const staleStatuses = statuses.filter((status) => ["starting", "running", "waiting"].includes(status.status));
36848
+ const uptimeSeconds = readProcUptimeSecondsOrNull(procRoot) ?? nowMs / 1000;
36849
+ const candidates = [];
36850
+ for (const status of staleStatuses) {
36851
+ const pid = status.pid;
36852
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
36853
+ continue;
36854
+ const ageMs = Math.max(0, nowMs - (status.updated_at_ms ?? nowMs));
36855
+ const snapshot = readProcessSnapshot(pid, procRoot, uptimeSeconds);
36856
+ if (!snapshot) {
36857
+ if (readProcessLiveness(pid, procRoot) === "dead" && ageMs >= minKeepAliveAgeMs) {
36858
+ candidates.push({ jobId: status.id, pid, beadId: status.bead_id ?? null, specialist: status.specialist, cwd: null, ageMs, reason: "dead-pid" });
36859
+ continue;
36860
+ }
36861
+ const basePath = join24(procRoot, String(pid));
36862
+ const cmdlineRaw = readProcStringOrNull(join24(basePath, "cmdline"));
36863
+ const statRaw = readProcStringOrNull(join24(basePath, "stat"));
36864
+ const parsedStat = statRaw ? parseStat(statRaw) : null;
36865
+ if (status.status === "waiting" && parsedStat?.ppid === 1 && cmdlineRaw && isSpecialistRunCommand(cmdlineRaw.replace(/\0/g, " "))) {
36866
+ candidates.push({ jobId: status.id, pid, beadId: status.bead_id ?? null, specialist: status.specialist, cwd: readProcCwdOrNull(pid, procRoot), ageMs, reason: "orphaned-keep-alive" });
36867
+ }
36868
+ continue;
36869
+ }
36870
+ if (status.status === "waiting" && snapshot.ppid === 1 && isSpecialistRunCommand(snapshot.cmdline) && ageMs >= minKeepAliveAgeMs) {
36871
+ candidates.push({ jobId: status.id, pid, beadId: status.bead_id ?? null, specialist: status.specialist, cwd: snapshot.cwd, ageMs, reason: "orphaned-keep-alive" });
36872
+ continue;
36873
+ }
36874
+ if (status.status !== "running" && status.status !== "waiting")
36875
+ continue;
36876
+ if (ageMs < minKeepAliveAgeMs)
36877
+ continue;
36878
+ const lastActivityMs = observabilityClient?.getLastActivityTimestampMs?.(status.id) ?? null;
36879
+ if (lastActivityMs !== null && nowMs - lastActivityMs < minKeepAliveAgeMs)
36880
+ continue;
36881
+ candidates.push({ jobId: status.id, pid, beadId: status.bead_id ?? null, specialist: status.specialist, cwd: snapshot.cwd, ageMs, reason: "dead-toolchain" });
36882
+ }
36883
+ return candidates.sort((left, right) => left.pid - right.pid);
36884
+ }
36885
+ var DEFAULT_WARN_PCT = 70, DEFAULT_REFUSE_PCT = 85, CLOCK_TICKS_PER_SECOND = 100;
36886
+ var init_process_health = __esm(() => {
36887
+ init_observability_sqlite();
36888
+ });
36889
+
36148
36890
  // src/cli/ps.ts
36149
36891
  var exports_ps = {};
36150
36892
  __export(exports_ps, {
36151
36893
  run: () => run16
36152
36894
  });
36153
36895
  import { spawnSync as spawnSync18 } from "child_process";
36154
- import { existsSync as existsSync22, readdirSync as readdirSync8, readFileSync as readFileSync22 } from "fs";
36155
- import { join as join24 } from "path";
36896
+ import { existsSync as existsSync24, readdirSync as readdirSync10, readFileSync as readFileSync23 } from "fs";
36897
+ import { join as join25 } from "path";
36156
36898
  function loadBeadIdsForCurrentUser() {
36157
36899
  const ids = new Set;
36158
36900
  try {
@@ -36182,12 +36924,30 @@ function parseSinceArg(value) {
36182
36924
  return Date.now() - ms;
36183
36925
  }
36184
36926
  function parseArgs8(argv) {
36927
+ const allowedBooleanFlags = new Set([
36928
+ "--json",
36929
+ "--all",
36930
+ "--follow",
36931
+ "-f",
36932
+ "--include-terminal",
36933
+ "--include-merged",
36934
+ "--include-cleaned",
36935
+ "--active",
36936
+ "--running",
36937
+ "--mine",
36938
+ "--health"
36939
+ ]);
36940
+ const valueFlags = new Set(["--node", "--bead", "--since"]);
36185
36941
  let nodeId;
36186
36942
  let beadFilter;
36187
36943
  let sinceMs;
36188
36944
  const positional = [];
36189
36945
  for (let i = 0;i < argv.length; i += 1) {
36190
36946
  const token = argv[i];
36947
+ if (token.startsWith("-") && !allowedBooleanFlags.has(token) && !valueFlags.has(token)) {
36948
+ const hint = token === "--ps" ? " Did you mean `sp clean --ps`?" : "";
36949
+ throw new Error(`Unknown ps option: ${token}.${hint}`);
36950
+ }
36191
36951
  if (token === "--node" && argv[i + 1]) {
36192
36952
  nodeId = argv[i + 1];
36193
36953
  i += 1;
@@ -36213,39 +36973,44 @@ function parseArgs8(argv) {
36213
36973
  all: argv.includes("--all"),
36214
36974
  follow: argv.includes("--follow") || argv.includes("-f"),
36215
36975
  includeTerminal,
36216
- running: argv.includes("--running"),
36976
+ includeCleaned: argv.includes("--include-cleaned"),
36977
+ active: argv.includes("--active"),
36978
+ running: argv.includes("--running") || argv.includes("--active"),
36217
36979
  mine: argv.includes("--mine"),
36980
+ health: argv.includes("--health"),
36218
36981
  beadFilter,
36219
36982
  sinceMs,
36220
36983
  nodeId,
36221
36984
  inspectId: positional[0]
36222
36985
  };
36223
36986
  }
36224
- function isVisibleStatus(status, all) {
36225
- if (all)
36226
- return true;
36227
- return ACTIVE_STATES.includes(status);
36987
+ function isPsCleaned(job) {
36988
+ const typed = job;
36989
+ return Boolean(typed.ps_hidden_at ?? typed.ps_hidden_from_dashboard_at);
36990
+ }
36991
+ function isDefaultActionableTerminal(job) {
36992
+ return job.status === "error" || job.status === "cancelled";
36228
36993
  }
36229
36994
  function readStatusesFromFiles(jobsDir) {
36230
- if (!existsSync22(jobsDir))
36995
+ if (!existsSync24(jobsDir))
36231
36996
  return [];
36232
36997
  const statuses = [];
36233
- for (const entry of readdirSync8(jobsDir)) {
36234
- const statusPath = join24(jobsDir, entry, "status.json");
36235
- if (!existsSync22(statusPath))
36998
+ for (const entry of readdirSync10(jobsDir)) {
36999
+ const statusPath = join25(jobsDir, entry, "status.json");
37000
+ if (!existsSync24(statusPath))
36236
37001
  continue;
36237
37002
  try {
36238
- statuses.push(JSON.parse(readFileSync22(statusPath, "utf-8")));
37003
+ statuses.push(JSON.parse(readFileSync23(statusPath, "utf-8")));
36239
37004
  } catch {}
36240
37005
  }
36241
37006
  return statuses.sort((a, b) => b.started_at_ms - a.started_at_ms);
36242
37007
  }
36243
37008
  function readLastToolEventFromFile(jobsDir, jobId) {
36244
- const eventsPath = join24(jobsDir, jobId, "events.jsonl");
36245
- if (!existsSync22(eventsPath))
37009
+ const eventsPath = join25(jobsDir, jobId, "events.jsonl");
37010
+ if (!existsSync24(eventsPath))
36246
37011
  return;
36247
37012
  try {
36248
- const lines = readFileSync22(eventsPath, "utf-8").split(`
37013
+ const lines = readFileSync23(eventsPath, "utf-8").split(`
36249
37014
  `);
36250
37015
  for (let index = lines.length - 1;index >= 0; index -= 1) {
36251
37016
  const line = lines[index]?.trim();
@@ -36536,7 +37301,7 @@ function statusLabel(status) {
36536
37301
  }
36537
37302
  function epicStateLabel(state) {
36538
37303
  if (state === "merge_ready")
36539
- return green8("merge_ready");
37304
+ return green8("pass");
36540
37305
  if (state === "merged")
36541
37306
  return dim8("merged");
36542
37307
  if (state === "failed")
@@ -36544,10 +37309,10 @@ function epicStateLabel(state) {
36544
37309
  if (state === "blocked")
36545
37310
  return yellow10("blocked");
36546
37311
  if (state === "resolving")
36547
- return cyan5("resolving");
37312
+ return cyan5("merge_ready");
36548
37313
  if (state === "abandoned")
36549
37314
  return dim8("abandoned");
36550
- return magenta3("unresolved");
37315
+ return magenta3("no pass yet");
36551
37316
  }
36552
37317
  function withPidLiveness(statuses) {
36553
37318
  return statuses.map((job) => ({
@@ -36704,6 +37469,50 @@ function renderTreeNodes(nodes, beadTitles, prefix, renderedJobIds) {
36704
37469
  }
36705
37470
  }
36706
37471
  }
37472
+ function formatProcessRow(process3) {
37473
+ const cwd = process3.cwd ?? "--";
37474
+ const rssMb = `${(process3.rssBytes / (1024 * 1024)).toFixed(1)}MB`;
37475
+ const cpu = `${process3.cpuPct.toFixed(1)}%`;
37476
+ const age = `${Math.floor(process3.ageSeconds / 60)}m`;
37477
+ return ` ${String(process3.pid).padEnd(7)} ${process3.role.padEnd(14)} ${rssMb.padEnd(8)} ${cpu.padEnd(7)} ${age.padEnd(5)} ${cwd}`;
37478
+ }
37479
+ function renderProcessHealthBlock(report, includeDetails) {
37480
+ const percent = report.thresholdPct.toFixed(1);
37481
+ const severity = report.status === "REFUSE" ? red2("REFUSE") : report.status === "WARN" ? yellow10("WARN") : green8("OK");
37482
+ console.log(bold10(cyan5("System health")));
37483
+ console.log(` ${severity} rss=${(report.totalRssBytes / (1024 * 1024)).toFixed(1)}MB avail=${(report.memAvailableBytes / (1024 * 1024)).toFixed(1)}MB used=${percent}% warn=${report.warnPct}% refuse=${report.refusePct}% cpu=${report.totalCpuPct.toFixed(1)}%`);
37484
+ console.log(` specialists=${report.specialistCount} dolt=${report.doltCount} serena-lsp=${report.serenaLspCount} orphans=${report.orphanCount}`);
37485
+ if (report.statusReasons.length > 0)
37486
+ console.log(` alerts=${report.statusReasons.join("; ")}`);
37487
+ if (!includeDetails) {
37488
+ console.log("");
37489
+ return;
37490
+ }
37491
+ if (report.doltProcesses.length > 0) {
37492
+ console.log(bold10(" Dolt sql-server"));
37493
+ for (const process3 of report.doltProcesses)
37494
+ console.log(formatProcessRow(process3));
37495
+ }
37496
+ if (report.serenaWorkspaces.length > 0) {
37497
+ console.log(bold10(" Serena LSP"));
37498
+ for (const workspace of report.serenaWorkspaces) {
37499
+ console.log(` ${workspace.workspace} \xB7 ${workspace.count} procs \xB7 ${(workspace.rssBytes / (1024 * 1024)).toFixed(1)}MB`);
37500
+ for (const process3 of workspace.processes)
37501
+ console.log(formatProcessRow(process3));
37502
+ }
37503
+ }
37504
+ if (report.specialistProcesses.length > 0) {
37505
+ console.log(bold10(" Specialists"));
37506
+ for (const process3 of report.specialistProcesses)
37507
+ console.log(formatProcessRow(process3));
37508
+ }
37509
+ if (report.orphanProcesses.length > 0) {
37510
+ console.log(bold10(" Orphans"));
37511
+ for (const process3 of report.orphanProcesses)
37512
+ console.log(formatProcessRow(process3));
37513
+ }
37514
+ console.log("");
37515
+ }
36707
37516
  function resolveEpicReadinessMap(jobs, includeTerminal) {
36708
37517
  const epicIds = new Set(jobs.map((job) => job.epic_id).filter((epicId) => Boolean(epicId)));
36709
37518
  const sqlite = createObservabilitySqliteClient();
@@ -36730,12 +37539,12 @@ function resolveEpicReadinessMap(jobs, includeTerminal) {
36730
37539
  sqlite.close();
36731
37540
  }
36732
37541
  }
36733
- function renderHuman(jobs, nodes, trees, all, includeTerminal, epicReadiness) {
37542
+ function renderHuman(jobs, nodes, trees, all, includeTerminal, epicReadiness, health, includeHealthDetails) {
36734
37543
  const beadTitles = buildBeadTitleCache(jobs);
36735
37544
  const renderedJobIds = new Set;
36736
37545
  const epicGroups = buildEpicGroups(jobs, epicReadiness);
36737
37546
  const renderedEpicIds = new Set(epicGroups.map((epic) => epic.epic_id));
36738
- console.log("");
37547
+ renderProcessHealthBlock(health, includeHealthDetails);
36739
37548
  for (const epic of epicGroups) {
36740
37549
  const prepCount = epic.prep_jobs.length;
36741
37550
  const chainCount = epic.chains.length;
@@ -36744,9 +37553,9 @@ function renderHuman(jobs, nodes, trees, all, includeTerminal, epicReadiness) {
36744
37553
  const persistedState = readiness?.persisted_state ?? "open";
36745
37554
  const prepSummary = readiness?.prep ? `prep ${readiness.prep.done}/${readiness.prep.total} done${readiness.prep.running > 0 ? ` ${readiness.prep.running} running` : ""}${readiness.prep.failed > 0 ? ` ${readiness.prep.failed} failed` : ""}` : `prep ${prepCount}`;
36746
37555
  const chainSummary = readiness?.chains ? `chains ${readiness.chains.filter((chain) => chain.state === "pass").length}/${readiness.chains.length} pass` : `chains ${chainCount}`;
36747
- const epicBanner = bold10(cyan5(`\u250F\u2501 EPIC ${epic.epic_id} \u2501 ${String(readinessState).toUpperCase()} \u2501 ${prepSummary} \u2501 ${chainSummary}`));
37556
+ const epicBanner = bold10(cyan5(`\u250F\u2501 EPIC ${epic.epic_id} \u2501 ${epicStateLabel(readiness?.readiness_state)} \u2501 ${prepSummary} \u2501 ${chainSummary}`));
36748
37557
  console.log(epicBanner);
36749
- console.log(` ${dim8(`state:${persistedState}`)} \xB7 ${epicStateLabel(readiness?.readiness_state)}`);
37558
+ console.log(` ${dim8(`derived:${readinessState}`)} \xB7 ${dim8(`stored:${persistedState}`)}`);
36750
37559
  console.log(` ${bold10("Prep")}`);
36751
37560
  if (epic.prep_jobs.length === 0) {
36752
37561
  console.log(dim8(" (none)"));
@@ -36891,7 +37700,7 @@ ${job.id} ${job.specialist} ${getStatusIcon(toJobNode(job))} ${statusLabel(job
36891
37700
  console.log(`
36892
37701
  ${dim8(inspectActions.join(" | "))}`);
36893
37702
  }
36894
- function renderJson(jobs, nodes, trees, _all, epicReadiness, args) {
37703
+ function renderJson(jobs, nodes, trees, _all, epicReadiness, args, health) {
36895
37704
  console.log(JSON.stringify({
36896
37705
  generated_at_ms: Date.now(),
36897
37706
  include_terminal: args.includeTerminal,
@@ -36929,7 +37738,8 @@ function renderJson(jobs, nodes, trees, _all, epicReadiness, args) {
36929
37738
  nodes,
36930
37739
  trees,
36931
37740
  epics: buildEpicGroups(jobs, epicReadiness),
36932
- epic_readiness: Object.fromEntries([...epicReadiness.entries()].map(([epicId, summary]) => [epicId, summary]))
37741
+ epic_readiness: Object.fromEntries([...epicReadiness.entries()].map(([epicId, summary]) => [epicId, summary])),
37742
+ process_health: health
36933
37743
  }, null, 2));
36934
37744
  }
36935
37745
  function dedupeStatusesById(statuses) {
@@ -36963,30 +37773,31 @@ function render(args) {
36963
37773
  return false;
36964
37774
  if (mineBeadIds && (!job.bead_id || !mineBeadIds.has(job.bead_id)))
36965
37775
  return false;
36966
- const epicTerminal = readinessState === "merged" || readinessState === "abandoned";
36967
- if (epicTerminal && !args.includeTerminal && !args.all)
36968
- return false;
37776
+ const cleaned = isPsCleaned(job);
36969
37777
  if (args.all)
36970
37778
  return true;
36971
- if (job.is_dead)
37779
+ if (cleaned && !args.includeCleaned)
36972
37780
  return false;
36973
- if (isVisibleStatus(job.status, false))
37781
+ if (cleaned && args.includeCleaned && TERMINAL_STATES.includes(job.status))
36974
37782
  return true;
36975
- if (!job.epic_id)
36976
- return false;
36977
- if (!TERMINAL_STATES.includes(job.status))
37783
+ if (job.is_dead)
36978
37784
  return false;
36979
- if (epicTerminal)
37785
+ if (ACTIVE_STATES.includes(job.status))
37786
+ return true;
37787
+ if (args.active)
36980
37788
  return false;
36981
- return true;
37789
+ if (args.includeTerminal && TERMINAL_STATES.includes(job.status))
37790
+ return true;
37791
+ return isDefaultActionableTerminal(job);
36982
37792
  });
36983
37793
  const nodes = groupByNode(visibleStatuses);
36984
37794
  const trees = groupByTree(visibleStatuses);
37795
+ const health = collectProcessHealth();
36985
37796
  if (args.json) {
36986
- renderJson(visibleStatuses, nodes, trees, args.all, epicReadiness, args);
37797
+ renderJson(visibleStatuses, nodes, trees, args.all, epicReadiness, args, health);
36987
37798
  return;
36988
37799
  }
36989
- renderHuman(visibleStatuses, nodes, trees, args.all, args.includeTerminal, epicReadiness);
37800
+ renderHuman(visibleStatuses, nodes, trees, args.all, args.includeTerminal, epicReadiness, health, args.health);
36990
37801
  }
36991
37802
  function renderBuffered(args) {
36992
37803
  const lines = [];
@@ -37046,6 +37857,7 @@ var init_ps = __esm(() => {
37046
37857
  init_timeline_events();
37047
37858
  init_node_resolve();
37048
37859
  init_epic_readiness();
37860
+ init_process_health();
37049
37861
  ACTIVE_STATES = ["starting", "running", "waiting"];
37050
37862
  TERMINAL_STATES = ["done", "error", "cancelled"];
37051
37863
  BEAD_TITLE_CACHE = new Map;
@@ -37064,8 +37876,8 @@ var exports_result = {};
37064
37876
  __export(exports_result, {
37065
37877
  run: () => run17
37066
37878
  });
37067
- import { existsSync as existsSync23, readFileSync as readFileSync23 } from "fs";
37068
- import { join as join25 } from "path";
37879
+ import { existsSync as existsSync25, readFileSync as readFileSync24 } from "fs";
37880
+ import { join as join26 } from "path";
37069
37881
  function parseArgs9(argv) {
37070
37882
  let jobId;
37071
37883
  let nodeId;
@@ -37155,10 +37967,10 @@ function readTimelineEventsForResult(sqliteClient, jobsDir, jobId) {
37155
37967
  return sqliteClient.readEvents(jobId);
37156
37968
  } catch {}
37157
37969
  }
37158
- const eventsPath = join25(jobsDir, jobId, "events.jsonl");
37159
- if (!existsSync23(eventsPath))
37970
+ const eventsPath = join26(jobsDir, jobId, "events.jsonl");
37971
+ if (!existsSync25(eventsPath))
37160
37972
  return [];
37161
- return readFileSync23(eventsPath, "utf-8").split(`
37973
+ return readFileSync24(eventsPath, "utf-8").split(`
37162
37974
  `).map((line) => line.trim()).filter(Boolean).map((line) => parseTimelineEvent(line)).filter((event) => event !== null);
37163
37975
  }
37164
37976
  function deriveStartupSnapshot(status, events) {
@@ -37277,7 +38089,7 @@ async function run17() {
37277
38089
  error: error2
37278
38090
  }, null, 2));
37279
38091
  };
37280
- const jobsDir = join25(process.cwd(), ".specialists", "jobs");
38092
+ const jobsDir = join26(process.cwd(), ".specialists", "jobs");
37281
38093
  const supervisor = new Supervisor({ runner: null, runOptions: null, jobsDir });
37282
38094
  const sqliteClient = createObservabilitySqliteClient();
37283
38095
  const emitHumanResult = (output2, status, startupContext, trailingFooter) => {
@@ -37312,7 +38124,7 @@ async function run17() {
37312
38124
  const resolvedNodeId = args.nodeId ? resolveNodeRefWithClient(args.nodeId, sqliteClient) : resolveSingleActiveNodeRef(sqliteClient);
37313
38125
  return resolveJobIdFromNodeMember(sqliteClient, resolvedNodeId, args.memberKey);
37314
38126
  })();
37315
- const resultPath = join25(jobsDir, jobId, "result.txt");
38127
+ const resultPath = join26(jobsDir, jobId, "result.txt");
37316
38128
  const readResultOutput = () => {
37317
38129
  try {
37318
38130
  const sqliteResult = sqliteClient?.readResult(jobId) ?? null;
@@ -37321,8 +38133,8 @@ async function run17() {
37321
38133
  } catch (error2) {
37322
38134
  console.warn(`SQLite result read failed for job ${jobId}; falling back to result.txt`, error2);
37323
38135
  }
37324
- if (existsSync23(resultPath)) {
37325
- return readFileSync23(resultPath, "utf-8");
38136
+ if (existsSync25(resultPath)) {
38137
+ return readFileSync24(resultPath, "utf-8");
37326
38138
  }
37327
38139
  try {
37328
38140
  const events2 = readTimelineEventsForResult(sqliteClient, jobsDir, jobId);
@@ -37507,10 +38319,10 @@ var init_result = __esm(() => {
37507
38319
  });
37508
38320
 
37509
38321
  // src/specialist/timeline-query.ts
37510
- import { existsSync as existsSync24, readdirSync as readdirSync9, readFileSync as readFileSync24 } from "fs";
37511
- import { basename as basename6, join as join26 } from "path";
38322
+ import { existsSync as existsSync26, readdirSync as readdirSync11, readFileSync as readFileSync25 } from "fs";
38323
+ import { basename as basename7, join as join27 } from "path";
37512
38324
  function readJobEvents(jobDir) {
37513
- const jobId = basename6(jobDir);
38325
+ const jobId = basename7(jobDir);
37514
38326
  try {
37515
38327
  const sqliteEvents = createObservabilitySqliteClient()?.readEvents(jobId) ?? [];
37516
38328
  if (sqliteEvents.length > 0) {
@@ -37520,10 +38332,10 @@ function readJobEvents(jobDir) {
37520
38332
  } catch {}
37521
38333
  if (process.env.SPECIALISTS_JOB_FILE_OUTPUT !== "on")
37522
38334
  return [];
37523
- const eventsPath = join26(jobDir, "events.jsonl");
37524
- if (!existsSync24(eventsPath))
38335
+ const eventsPath = join27(jobDir, "events.jsonl");
38336
+ if (!existsSync26(eventsPath))
37525
38337
  return [];
37526
- const content = readFileSync24(eventsPath, "utf-8");
38338
+ const content = readFileSync25(eventsPath, "utf-8");
37527
38339
  const lines = content.split(`
37528
38340
  `).filter(Boolean);
37529
38341
  const events = [];
@@ -37535,9 +38347,21 @@ function readJobEvents(jobDir) {
37535
38347
  events.sort(compareTimelineEvents);
37536
38348
  return events;
37537
38349
  }
37538
- function readAllJobEvents(jobsDir) {
38350
+ function readAllJobEvents(jobsDir, jobId) {
37539
38351
  const sqliteClient = createObservabilitySqliteClient();
37540
38352
  try {
38353
+ if (jobId !== undefined && sqliteClient) {
38354
+ const events = sqliteClient.readEvents(jobId);
38355
+ if (events.length === 0)
38356
+ return [];
38357
+ const status = typeof sqliteClient.getStatus === "function" ? sqliteClient.getStatus(jobId) : undefined;
38358
+ return [{
38359
+ jobId,
38360
+ specialist: status?.specialist ?? "unknown",
38361
+ beadId: status?.bead_id,
38362
+ events
38363
+ }];
38364
+ }
37541
38365
  const statuses = typeof sqliteClient?.listStatuses === "function" ? sqliteClient.listStatuses() : [];
37542
38366
  if (statuses.length > 0 && sqliteClient) {
37543
38367
  return statuses.flatMap((status) => {
@@ -37555,12 +38379,12 @@ function readAllJobEvents(jobsDir) {
37555
38379
  } catch {}
37556
38380
  if (process.env.SPECIALISTS_JOB_FILE_OUTPUT !== "on")
37557
38381
  return [];
37558
- if (!existsSync24(jobsDir))
38382
+ if (!existsSync26(jobsDir))
37559
38383
  return [];
37560
38384
  const batches = [];
37561
- const entries = readdirSync9(jobsDir);
38385
+ const entries = readdirSync11(jobsDir);
37562
38386
  for (const entry of entries) {
37563
- const jobDir = join26(jobsDir, entry);
38387
+ const jobDir = join27(jobsDir, entry);
37564
38388
  try {
37565
38389
  const stat2 = __require("fs").statSync(jobDir);
37566
38390
  if (!stat2.isDirectory())
@@ -37568,20 +38392,20 @@ function readAllJobEvents(jobsDir) {
37568
38392
  } catch {
37569
38393
  continue;
37570
38394
  }
37571
- const jobId = entry;
37572
- const statusPath = join26(jobDir, "status.json");
38395
+ const jobId2 = entry;
38396
+ const statusPath = join27(jobDir, "status.json");
37573
38397
  let specialist = "unknown";
37574
38398
  let beadId;
37575
- if (existsSync24(statusPath)) {
38399
+ if (existsSync26(statusPath)) {
37576
38400
  try {
37577
- const status = JSON.parse(readFileSync24(statusPath, "utf-8"));
38401
+ const status = JSON.parse(readFileSync25(statusPath, "utf-8"));
37578
38402
  specialist = status.specialist ?? "unknown";
37579
38403
  beadId = status.bead_id;
37580
38404
  } catch {}
37581
38405
  }
37582
38406
  const events = readJobEvents(jobDir);
37583
38407
  if (events.length > 0) {
37584
- batches.push({ jobId, specialist, beadId, events });
38408
+ batches.push({ jobId: jobId2, specialist, beadId, events });
37585
38409
  }
37586
38410
  }
37587
38411
  return batches;
@@ -37626,14 +38450,9 @@ function filterTimelineEvents(merged, filter) {
37626
38450
  return result;
37627
38451
  }
37628
38452
  function queryTimeline(jobsDir, filter = {}) {
37629
- let batches = readAllJobEvents(jobsDir);
37630
- if (filter.jobId !== undefined) {
37631
- batches = batches.filter((b) => b.jobId === filter.jobId);
37632
- }
37633
- if (filter.specialist !== undefined) {
37634
- batches = batches.filter((b) => b.specialist === filter.specialist);
37635
- }
37636
- const merged = mergeTimelineEvents(batches);
38453
+ const batches = readAllJobEvents(jobsDir, filter.jobId);
38454
+ const filteredBatches = filter.specialist !== undefined ? batches.filter((b) => b.specialist === filter.specialist) : batches;
38455
+ const merged = mergeTimelineEvents(filteredBatches);
37637
38456
  return filterTimelineEvents(merged, filter);
37638
38457
  }
37639
38458
  var init_timeline_query = __esm(() => {
@@ -37671,13 +38490,13 @@ __export(exports_feed, {
37671
38490
  });
37672
38491
  import {
37673
38492
  closeSync as closeSync2,
37674
- existsSync as existsSync25,
38493
+ existsSync as existsSync27,
37675
38494
  openSync as openSync3,
37676
- readFileSync as readFileSync25,
37677
- readdirSync as readdirSync10,
37678
- statSync as statSync3
38495
+ readFileSync as readFileSync26,
38496
+ readdirSync as readdirSync12,
38497
+ statSync as statSync4
37679
38498
  } from "fs";
37680
- import { join as join27 } from "path";
38499
+ import { join as join28 } from "path";
37681
38500
  function getHumanEventKey(event) {
37682
38501
  switch (event.type) {
37683
38502
  case "meta":
@@ -37839,7 +38658,7 @@ function readFileFresh(filePath) {
37839
38658
  let fd = null;
37840
38659
  try {
37841
38660
  fd = openSync3(filePath, "r");
37842
- return readFileSync25(fd, "utf-8");
38661
+ return readFileSync26(fd, "utf-8");
37843
38662
  } catch {
37844
38663
  return null;
37845
38664
  } finally {
@@ -37856,7 +38675,7 @@ function readStatusJson(sqliteClient, jobsDir, jobId) {
37856
38675
  } catch (error2) {
37857
38676
  console.warn(`SQLite status read failed for job ${jobId}; falling back to status.json`, error2);
37858
38677
  }
37859
- const statusPath = join27(jobsDir, jobId, "status.json");
38678
+ const statusPath = join28(jobsDir, jobId, "status.json");
37860
38679
  const raw = readFileFresh(statusPath);
37861
38680
  if (!raw)
37862
38681
  return null;
@@ -37866,13 +38685,15 @@ function readStatusJson(sqliteClient, jobsDir, jobId) {
37866
38685
  return null;
37867
38686
  }
37868
38687
  }
37869
- function isTerminalJobStatus(sqliteClient, jobsDir, jobId) {
37870
- const status = readStatusJson(sqliteClient, jobsDir, jobId);
37871
- return status?.status === "done" || status?.status === "error" || status?.status === "cancelled";
37872
- }
37873
38688
  function isKeepAliveJobStatus(status) {
37874
38689
  return status?.status === "waiting";
37875
38690
  }
38691
+ function isTerminalStatus(status) {
38692
+ return status?.status === "done" || status?.status === "error" || status?.status === "cancelled";
38693
+ }
38694
+ function isTerminalEquivalentForFollow(status, isGlobalFollow) {
38695
+ return isTerminalStatus(status ?? null) || isGlobalFollow && isKeepAliveJobStatus(status ?? null);
38696
+ }
37876
38697
  function isJobCompleteForFollow(sqliteClient, jobsDir, jobId, events) {
37877
38698
  const status = readStatusJson(sqliteClient, jobsDir, jobId);
37878
38699
  if (isKeepAliveJobStatus(status)) {
@@ -37978,8 +38799,13 @@ function parseArgs10(argv) {
37978
38799
  }
37979
38800
  function printSnapshot(sqliteClient, merged, options, jobsDir) {
37980
38801
  if (merged.length === 0) {
37981
- if (!options.json)
37982
- console.log(dim8("No events found."));
38802
+ if (!options.json) {
38803
+ if (options.jobId && sqliteClient) {
38804
+ console.log(dim8(`job ${options.jobId} not found in .specialists/db/observability.db`));
38805
+ } else {
38806
+ console.log(dim8("No events found."));
38807
+ }
38808
+ }
37983
38809
  return;
37984
38810
  }
37985
38811
  const colorMap = new JobColorMap;
@@ -38065,13 +38891,13 @@ function filterMergedEventsByNode(sqliteClient, jobsDir, merged, nodeId) {
38065
38891
  });
38066
38892
  }
38067
38893
  function listMatchingJobIds(sqliteClient, jobsDir, options) {
38068
- if (!existsSync25(jobsDir))
38894
+ if (!existsSync27(jobsDir))
38069
38895
  return [];
38070
38896
  const jobIds = [];
38071
- for (const entry of readdirSync10(jobsDir)) {
38072
- const jobDir = join27(jobsDir, entry);
38897
+ for (const entry of readdirSync12(jobsDir)) {
38898
+ const jobDir = join28(jobsDir, entry);
38073
38899
  try {
38074
- if (!statSync3(jobDir).isDirectory())
38900
+ if (!statSync4(jobDir).isDirectory())
38075
38901
  continue;
38076
38902
  } catch {
38077
38903
  continue;
@@ -38106,7 +38932,7 @@ function readJobEventsFresh(sqliteClient, jobsDir, jobId) {
38106
38932
  } catch (error2) {
38107
38933
  console.warn(`SQLite events read failed for job ${jobId}; falling back to events.jsonl`, error2);
38108
38934
  }
38109
- const eventsPath = join27(jobsDir, jobId, "events.jsonl");
38935
+ const eventsPath = join28(jobsDir, jobId, "events.jsonl");
38110
38936
  const content = readFileFresh(eventsPath);
38111
38937
  if (!content)
38112
38938
  return [];
@@ -38130,10 +38956,10 @@ function readJobEventsIncremental(sqliteClient, jobsDir, jobId, afterSeq, fileCa
38130
38956
  } catch (error2) {
38131
38957
  console.warn(`SQLite incremental events read failed for job ${jobId}; falling back to events.jsonl`, error2);
38132
38958
  }
38133
- const eventsPath = join27(jobsDir, jobId, "events.jsonl");
38959
+ const eventsPath = join28(jobsDir, jobId, "events.jsonl");
38134
38960
  let stats;
38135
38961
  try {
38136
- stats = statSync3(eventsPath);
38962
+ stats = statSync4(eventsPath);
38137
38963
  } catch {
38138
38964
  return [];
38139
38965
  }
@@ -38167,7 +38993,11 @@ async function followMerged(sqliteClient, jobsDir, options) {
38167
38993
  const fileEventCache = new Map;
38168
38994
  const initialMatchingJobIds = listMatchingJobIds(sqliteClient, jobsDir, options);
38169
38995
  const hasInitialMatchingJobs = initialMatchingJobIds.length > 0;
38170
- const trackedJobs = new Set(initialMatchingJobIds.filter((jobId) => !isTerminalJobStatus(sqliteClient, jobsDir, jobId)));
38996
+ const isGlobalFollow = options.jobId === undefined;
38997
+ const trackedJobs = new Set(initialMatchingJobIds.filter((jobId) => {
38998
+ const status = readStatusJson(sqliteClient, jobsDir, jobId);
38999
+ return !isTerminalStatus(status) && !(isGlobalFollow && isKeepAliveJobStatus(status));
39000
+ }));
38171
39001
  const completedJobs = new Set;
38172
39002
  const filteredBatches = () => readFilteredBatchesFresh(sqliteClient, jobsDir, options);
38173
39003
  const initial = filterMergedEventsByCursor(filterMergedEventsByNode(sqliteClient, jobsDir, queryTimeline(jobsDir, {
@@ -38213,14 +39043,11 @@ async function followMerged(sqliteClient, jobsDir, options) {
38213
39043
  for (const jobId of currentJobIds) {
38214
39044
  const status = readStatusJson(sqliteClient, jobsDir, jobId);
38215
39045
  statusByJobId.set(jobId, status);
38216
- const isTerminal2 = status?.status === "done" || status?.status === "error" || status?.status === "cancelled";
38217
- if (!isKeepAliveJobStatus(status) && isTerminal2) {
39046
+ if (isTerminalEquivalentForFollow(status, isGlobalFollow)) {
38218
39047
  completedJobs.add(jobId);
38219
39048
  continue;
38220
39049
  }
38221
- if (!isTerminal2) {
38222
- trackedJobs.add(jobId);
38223
- }
39050
+ trackedJobs.add(jobId);
38224
39051
  }
38225
39052
  const newEvents = [];
38226
39053
  for (const jobId of currentJobIds) {
@@ -38240,8 +39067,7 @@ async function followMerged(sqliteClient, jobsDir, options) {
38240
39067
  if (isKeepAliveJobStatus(status ?? null)) {
38241
39068
  continue;
38242
39069
  }
38243
- const isTerminal2 = status?.status === "done" || status?.status === "error" || status?.status === "cancelled";
38244
- if (events.some(isRunCompleteEvent) || isTerminal2) {
39070
+ if (events.some(isRunCompleteEvent) || isTerminalStatus(status ?? null) || isGlobalFollow && isKeepAliveJobStatus(status ?? null)) {
38245
39071
  completedJobs.add(jobId);
38246
39072
  }
38247
39073
  }
@@ -38290,7 +39116,7 @@ async function followMerged(sqliteClient, jobsDir, options) {
38290
39116
  if (!options.forever && trackedJobs.size > 0) {
38291
39117
  const allTrackedTerminal = [...trackedJobs].every((jobId) => {
38292
39118
  const status = statusByJobId.get(jobId) ?? readStatusJson(sqliteClient, jobsDir, jobId);
38293
- return status?.status === "done" || status?.status === "error" || status?.status === "cancelled";
39119
+ return isTerminalEquivalentForFollow(status, isGlobalFollow);
38294
39120
  });
38295
39121
  if (completedJobs.size === trackedJobs.size || allTrackedTerminal) {
38296
39122
  clearInterval(interval);
@@ -38304,9 +39130,13 @@ async function run18() {
38304
39130
  const options = parseArgs10(process.argv.slice(3));
38305
39131
  const sqliteClient = createObservabilitySqliteClient();
38306
39132
  try {
38307
- const jobsDir = join27(process.cwd(), ".specialists", "jobs");
38308
- if (!existsSync25(jobsDir)) {
38309
- console.log(dim8("No jobs directory found."));
39133
+ const jobsDir = join28(process.cwd(), ".specialists", "jobs");
39134
+ if (!existsSync27(jobsDir)) {
39135
+ if (options.jobId && sqliteClient) {
39136
+ console.log(dim8(`job ${options.jobId} not found in .specialists/db/observability.db`));
39137
+ } else {
39138
+ console.log(dim8("No jobs directory found."));
39139
+ }
38310
39140
  return;
38311
39141
  }
38312
39142
  const resolvedOptions = {
@@ -38344,7 +39174,7 @@ var exports_steer = {};
38344
39174
  __export(exports_steer, {
38345
39175
  run: () => run19
38346
39176
  });
38347
- import { writeFileSync as writeFileSync10 } from "fs";
39177
+ import { writeFileSync as writeFileSync11 } from "fs";
38348
39178
  async function run19() {
38349
39179
  const jobId = process.argv[3];
38350
39180
  const message = process.argv[4];
@@ -38375,7 +39205,7 @@ async function run19() {
38375
39205
  try {
38376
39206
  const payload = JSON.stringify({ type: "steer", message }) + `
38377
39207
  `;
38378
- writeFileSync10(status.fifo_path, payload, { flag: "a" });
39208
+ writeFileSync11(status.fifo_path, payload, { flag: "a" });
38379
39209
  process.stdout.write(`${green10("\u2713")} Steer message sent to job ${jobId}
38380
39210
  `);
38381
39211
  } catch (err) {
@@ -38398,7 +39228,7 @@ var exports_resume = {};
38398
39228
  __export(exports_resume, {
38399
39229
  run: () => run20
38400
39230
  });
38401
- import { writeFileSync as writeFileSync11 } from "fs";
39231
+ import { writeFileSync as writeFileSync12 } from "fs";
38402
39232
  async function run20() {
38403
39233
  const jobId = process.argv[3];
38404
39234
  const task = process.argv[4];
@@ -38415,9 +39245,9 @@ async function run20() {
38415
39245
  process.exit(1);
38416
39246
  }
38417
39247
  if (status.status !== "waiting") {
38418
- process.stderr.write(`${red5("Error:")} Job ${jobId} is not in waiting state (status: ${status.status}).
39248
+ process.stderr.write(`${red5("Error:")} Job ${jobId} is already finalized (${status.status}).
38419
39249
  `);
38420
- process.stderr.write(`resume is only valid for keep-alive jobs in waiting state. Use steer for running jobs.
39250
+ process.stderr.write(`resume only works for true waiting jobs. Finalized work is terminal; use sp ps to inspect chain state.
38421
39251
  `);
38422
39252
  process.exit(1);
38423
39253
  }
@@ -38429,7 +39259,7 @@ async function run20() {
38429
39259
  try {
38430
39260
  const payload = JSON.stringify({ type: "resume", task }) + `
38431
39261
  `;
38432
- writeFileSync11(status.fifo_path, payload, { flag: "a" });
39262
+ writeFileSync12(status.fifo_path, payload, { flag: "a" });
38433
39263
  process.stdout.write(`${green11("\u2713")} Resume sent to job ${jobId}
38434
39264
  `);
38435
39265
  process.stdout.write(` Use 'specialists feed ${jobId} --follow' to watch the response.
@@ -38461,15 +39291,15 @@ async function run21() {
38461
39291
  }
38462
39292
 
38463
39293
  // src/specialist/worktree-gc.ts
38464
- import { existsSync as existsSync26, readdirSync as readdirSync11, readFileSync as readFileSync26 } from "fs";
38465
- import { join as join28 } from "path";
39294
+ import { existsSync as existsSync28, readdirSync as readdirSync13, readFileSync as readFileSync27 } from "fs";
39295
+ import { join as join29 } from "path";
38466
39296
  import { spawnSync as spawnSync19 } from "child_process";
38467
39297
  function readJobStatus2(jobDir) {
38468
- const statusPath = join28(jobDir, "status.json");
38469
- if (!existsSync26(statusPath))
39298
+ const statusPath = join29(jobDir, "status.json");
39299
+ if (!existsSync28(statusPath))
38470
39300
  return null;
38471
39301
  try {
38472
- return JSON.parse(readFileSync26(statusPath, "utf-8"));
39302
+ return JSON.parse(readFileSync27(statusPath, "utf-8"));
38473
39303
  } catch {
38474
39304
  return null;
38475
39305
  }
@@ -38491,7 +39321,7 @@ function collectWorktreeGcCandidates(jobsDir) {
38491
39321
  const worktreePath = status.worktree_path;
38492
39322
  if (!worktreePath)
38493
39323
  return null;
38494
- if (!existsSync26(worktreePath))
39324
+ if (!existsSync28(worktreePath))
38495
39325
  return null;
38496
39326
  return {
38497
39327
  jobId: status.id,
@@ -38503,13 +39333,13 @@ function collectWorktreeGcCandidates(jobsDir) {
38503
39333
  }
38504
39334
  if (!getFileFallbackEnabled())
38505
39335
  return [];
38506
- if (!existsSync26(jobsDir))
39336
+ if (!existsSync28(jobsDir))
38507
39337
  return [];
38508
39338
  const candidates = [];
38509
- for (const entry of readdirSync11(jobsDir, { withFileTypes: true })) {
39339
+ for (const entry of readdirSync13(jobsDir, { withFileTypes: true })) {
38510
39340
  if (!entry.isDirectory())
38511
39341
  continue;
38512
- const status = readJobStatus2(join28(jobsDir, entry.name));
39342
+ const status = readJobStatus2(join29(jobsDir, entry.name));
38513
39343
  if (!status)
38514
39344
  continue;
38515
39345
  if (isActive(status.status))
@@ -38519,7 +39349,7 @@ function collectWorktreeGcCandidates(jobsDir) {
38519
39349
  const { worktree_path: worktreePath, branch } = status;
38520
39350
  if (!worktreePath)
38521
39351
  continue;
38522
- if (!existsSync26(worktreePath))
39352
+ if (!existsSync28(worktreePath))
38523
39353
  continue;
38524
39354
  candidates.push({
38525
39355
  jobId: status.id,
@@ -38565,8 +39395,32 @@ var exports_clean = {};
38565
39395
  __export(exports_clean, {
38566
39396
  run: () => run22
38567
39397
  });
38568
- import { existsSync as existsSync27, readFileSync as readFileSync27, readdirSync as readdirSync12, rmSync as rmSync3, statSync as statSync4 } from "fs";
38569
- import { join as join29 } from "path";
39398
+ import { existsSync as existsSync29, readFileSync as readFileSync28, readdirSync as readdirSync14, rmSync as rmSync4, statSync as statSync5 } from "fs";
39399
+ import { join as join30 } from "path";
39400
+ function parseDuration2(raw) {
39401
+ const match = /^(\d+)(ms|s|m|h|d)$/i.exec(raw.trim());
39402
+ if (!match)
39403
+ return null;
39404
+ const amount = Number(match[1]);
39405
+ const unit = match[2].toLowerCase();
39406
+ const multipliers = {
39407
+ ms: 1,
39408
+ s: 1000,
39409
+ m: 60000,
39410
+ h: 3600000,
39411
+ d: 86400000
39412
+ };
39413
+ return amount * multipliers[unit];
39414
+ }
39415
+ function parseBeforeArgument2(raw) {
39416
+ const durationMs = parseDuration2(raw);
39417
+ if (durationMs !== null)
39418
+ return Date.now() - durationMs;
39419
+ const isoMs = Date.parse(raw);
39420
+ if (Number.isFinite(isoMs))
39421
+ return isoMs;
39422
+ throw new Error(`Invalid --before value '${raw}'. Use ISO date or duration like 7d.`);
39423
+ }
38570
39424
  function parseTtlDaysFromEnvironment() {
38571
39425
  const rawValue = process.env.SPECIALISTS_JOB_TTL_DAYS ?? process.env.JOB_TTL_DAYS;
38572
39426
  if (!rawValue)
@@ -38583,6 +39437,11 @@ function parseOptions2(argv) {
38583
39437
  let aggressivePrune = false;
38584
39438
  let staleProcessesOnly = false;
38585
39439
  let staleAfterHours = DEFAULT_STALE_AFTER_HOURS;
39440
+ let reapOrphans = false;
39441
+ let psDashboard = false;
39442
+ let observability = false;
39443
+ let observabilityBeforeMs = null;
39444
+ let includeEpics = false;
38586
39445
  for (let index = 0;index < argv.length; index += 1) {
38587
39446
  const argument = argv[index];
38588
39447
  if (argument === "--all") {
@@ -38597,6 +39456,34 @@ function parseOptions2(argv) {
38597
39456
  staleProcessesOnly = true;
38598
39457
  continue;
38599
39458
  }
39459
+ if (argument === "--reap-orphans") {
39460
+ reapOrphans = true;
39461
+ continue;
39462
+ }
39463
+ if (argument === "--ps") {
39464
+ psDashboard = true;
39465
+ continue;
39466
+ }
39467
+ if (argument === "--observability") {
39468
+ observability = true;
39469
+ continue;
39470
+ }
39471
+ if (argument === "--include-epics") {
39472
+ includeEpics = true;
39473
+ continue;
39474
+ }
39475
+ if (argument === "--before") {
39476
+ const value = argv[index + 1];
39477
+ if (!value)
39478
+ throw new Error("Missing value for --before");
39479
+ observabilityBeforeMs = parseBeforeArgument2(value);
39480
+ index += 1;
39481
+ continue;
39482
+ }
39483
+ if (argument.startsWith("--before=")) {
39484
+ observabilityBeforeMs = parseBeforeArgument2(argument.slice("--before=".length));
39485
+ continue;
39486
+ }
38600
39487
  if (argument === "--aggressive-prune") {
38601
39488
  aggressivePrune = true;
38602
39489
  continue;
@@ -38644,20 +39531,35 @@ function parseOptions2(argv) {
38644
39531
  if (staleProcessesOnly && (removeAllCompleted || keepRecentCount !== null)) {
38645
39532
  throw new Error("--processes cannot be combined with --all or --keep");
38646
39533
  }
38647
- return { removeAllCompleted, dryRun, keepRecentCount, aggressivePrune, staleProcessesOnly, staleAfterHours };
39534
+ if (reapOrphans && (removeAllCompleted || keepRecentCount !== null || staleProcessesOnly || psDashboard)) {
39535
+ throw new Error("--reap-orphans cannot be combined with --all, --keep, --processes, or --ps");
39536
+ }
39537
+ if (psDashboard && (removeAllCompleted || keepRecentCount !== null || staleProcessesOnly || aggressivePrune || observability)) {
39538
+ throw new Error("--ps cannot be combined with --all, --keep, --processes, --aggressive-prune, or --observability");
39539
+ }
39540
+ if (observability && (removeAllCompleted || keepRecentCount !== null || staleProcessesOnly || reapOrphans || psDashboard || aggressivePrune)) {
39541
+ throw new Error("--observability cannot be combined with --all, --keep, --processes, --reap-orphans, --ps, or --aggressive-prune");
39542
+ }
39543
+ if (!observability && (observabilityBeforeMs !== null || includeEpics)) {
39544
+ throw new Error("--before and --include-epics require --observability");
39545
+ }
39546
+ if (observability && observabilityBeforeMs === null) {
39547
+ throw new Error("--observability requires --before <iso|duration>");
39548
+ }
39549
+ return { removeAllCompleted, dryRun, keepRecentCount, aggressivePrune, staleProcessesOnly, staleAfterHours, reapOrphans, psDashboard, observability, observabilityBeforeMs, includeEpics };
38648
39550
  }
38649
39551
  function readDirectorySizeBytes(directoryPath) {
38650
39552
  let totalBytes = 0;
38651
- for (const entry of readdirSync12(directoryPath, { withFileTypes: true })) {
38652
- const entryPath = join29(directoryPath, entry.name);
38653
- const stats = statSync4(entryPath);
39553
+ for (const entry of readdirSync14(directoryPath, { withFileTypes: true })) {
39554
+ const entryPath = join30(directoryPath, entry.name);
39555
+ const stats = statSync5(entryPath);
38654
39556
  totalBytes += stats.isDirectory() ? readDirectorySizeBytes(entryPath) : stats.size;
38655
39557
  }
38656
39558
  return totalBytes;
38657
39559
  }
38658
39560
  function containsProtectedSqliteArtifact(directoryPath) {
38659
- for (const entry of readdirSync12(directoryPath, { withFileTypes: true })) {
38660
- const entryPath = join29(directoryPath, entry.name);
39561
+ for (const entry of readdirSync14(directoryPath, { withFileTypes: true })) {
39562
+ const entryPath = join30(directoryPath, entry.name);
38661
39563
  if (entry.isDirectory()) {
38662
39564
  if (containsProtectedSqliteArtifact(entryPath))
38663
39565
  return true;
@@ -38678,15 +39580,15 @@ function getJobTimestamps(status) {
38678
39580
  function readCompletedJobDirectory(baseDirectory, entry) {
38679
39581
  if (!entry.isDirectory())
38680
39582
  return null;
38681
- const directoryPath = join29(baseDirectory, entry.name);
39583
+ const directoryPath = join30(baseDirectory, entry.name);
38682
39584
  if (containsProtectedSqliteArtifact(directoryPath))
38683
39585
  return null;
38684
- const statusFilePath = join29(directoryPath, "status.json");
38685
- if (!existsSync27(statusFilePath))
39586
+ const statusFilePath = join30(directoryPath, "status.json");
39587
+ if (!existsSync29(statusFilePath))
38686
39588
  return null;
38687
39589
  let statusData;
38688
39590
  try {
38689
- statusData = JSON.parse(readFileSync27(statusFilePath, "utf-8"));
39591
+ statusData = JSON.parse(readFileSync28(statusFilePath, "utf-8"));
38690
39592
  } catch {
38691
39593
  return null;
38692
39594
  }
@@ -38700,8 +39602,8 @@ function collectCompletedJobs(jobsDirectoryPath) {
38700
39602
  const statuses = sqliteClient?.listStatuses() ?? [];
38701
39603
  if (statuses.length > 0) {
38702
39604
  return statuses.filter((status) => COMPLETED_STATUSES.has(status.status)).map((status) => {
38703
- const directoryPath = join29(jobsDirectoryPath, status.id);
38704
- if (!existsSync27(directoryPath) || containsProtectedSqliteArtifact(directoryPath))
39605
+ const directoryPath = join30(jobsDirectoryPath, status.id);
39606
+ if (!existsSync29(directoryPath) || containsProtectedSqliteArtifact(directoryPath))
38705
39607
  return null;
38706
39608
  const { createdAtMs, completedAtMs } = getJobTimestamps(status);
38707
39609
  return { id: status.id, directoryPath, completedAtMs, createdAtMs, sizeBytes: readDirectorySizeBytes(directoryPath) };
@@ -38709,7 +39611,7 @@ function collectCompletedJobs(jobsDirectoryPath) {
38709
39611
  }
38710
39612
  if (process.env.SPECIALISTS_JOB_FILE_OUTPUT !== "on")
38711
39613
  return [];
38712
- return readdirSync12(jobsDirectoryPath, { withFileTypes: true }).map((entry) => readCompletedJobDirectory(jobsDirectoryPath, entry)).filter((job) => job !== null);
39614
+ return readdirSync14(jobsDirectoryPath, { withFileTypes: true }).map((entry) => readCompletedJobDirectory(jobsDirectoryPath, entry)).filter((job) => job !== null);
38713
39615
  }
38714
39616
  function selectJobsToRemove(completedJobs, options, protectedJobIds) {
38715
39617
  const jobsByNewest = [...completedJobs].sort((left, right) => {
@@ -38781,6 +39683,17 @@ function renderSummary(removedCount, freedBytes, dryRun) {
38781
39683
  const noun = removedCount === 1 ? "directory" : "directories";
38782
39684
  return `${action} ${removedCount} job ${noun} (${formatBytes2(freedBytes)} freed)`;
38783
39685
  }
39686
+ function printObservabilityPruneReport(report) {
39687
+ console.log(report.dryRun ? "Would prune observability SQLite rows:" : "Pruned observability SQLite rows:");
39688
+ console.log(` before: ${new Date(report.beforeMs).toISOString()}`);
39689
+ console.log(` events cutoff: ${new Date(report.eventsCutoffMs).toISOString()}`);
39690
+ console.log(` specialist_events: ${report.deletedEvents}`);
39691
+ console.log(` specialist_results: ${report.deletedResults}`);
39692
+ console.log(` specialist_jobs: ${report.deletedJobs}`);
39693
+ console.log(` extracted jobs: ${report.extractedJobs}`);
39694
+ console.log(` epic_runs: ${report.deletedEpicRuns}${report.includeEpics ? "" : " (skipped, use --include-epics)"}`);
39695
+ console.log(` skipped active-chain jobs: ${report.skippedActiveChainJobs}`);
39696
+ }
38784
39697
  function printDryRunPlan(jobs) {
38785
39698
  if (jobs.length === 0)
38786
39699
  return;
@@ -38812,15 +39725,142 @@ function printWorktreeGcSummary(removed, skipped) {
38812
39725
  }
38813
39726
  function printUsageAndExit2(message) {
38814
39727
  console.error(message);
38815
- console.error("Usage: specialists|sp clean [--all] [--keep <n>] [--aggressive-prune] [--processes [--stale-after <hours>]] [--dry-run]");
39728
+ console.error("Usage: specialists|sp clean [--all] [--keep <n>] [--aggressive-prune] [--processes [--stale-after <hours>]] [--reap-orphans] [--observability --before <iso|duration> [--include-epics]] [--dry-run]");
38816
39729
  process.exit(1);
38817
39730
  }
39731
+ function findOrphanProcesses() {
39732
+ return collectOrphanProcesses().map((process3) => ({
39733
+ pid: process3.pid,
39734
+ ppid: process3.ppid,
39735
+ comm: process3.cmdline.split(" ")[0] ?? process3.role,
39736
+ cmdline: process3.cmdline,
39737
+ cwd: process3.cwd,
39738
+ reason: process3.reason ?? "pi-orphan"
39739
+ }));
39740
+ }
39741
+ async function killOrphanProcesses(orphans, dryRun) {
39742
+ if (dryRun)
39743
+ return orphans.length;
39744
+ let killed = 0;
39745
+ for (const orphan of orphans) {
39746
+ try {
39747
+ process.kill(orphan.pid, "SIGTERM");
39748
+ } catch {}
39749
+ }
39750
+ if (orphans.length > 0)
39751
+ await new Promise((resolve11) => setTimeout(resolve11, 1500));
39752
+ for (const orphan of orphans) {
39753
+ try {
39754
+ process.kill(orphan.pid, 0);
39755
+ try {
39756
+ process.kill(orphan.pid, "SIGKILL");
39757
+ } catch {}
39758
+ } catch {}
39759
+ killed += 1;
39760
+ }
39761
+ return killed;
39762
+ }
39763
+ function printOrphanPlan(orphans) {
39764
+ if (orphans.length === 0) {
39765
+ console.log("No orphan/stale leaked processes found.");
39766
+ return;
39767
+ }
39768
+ const action = "Would reap";
39769
+ console.log(`${action} ${orphans.length} orphan/stale leaked process(es):`);
39770
+ for (const orphan of orphans) {
39771
+ const cwdSuffix = orphan.cwd ? ` cwd=${orphan.cwd}` : "";
39772
+ console.log(` - pid=${orphan.pid} ppid=${orphan.ppid} reason=${orphan.reason} comm=${orphan.comm}${cwdSuffix}`);
39773
+ }
39774
+ }
39775
+ function printStaleJobPlan(jobs) {
39776
+ if (jobs.length === 0) {
39777
+ console.log("No stale specialist jobs found.");
39778
+ return;
39779
+ }
39780
+ console.log(`Would reap ${jobs.length} stale specialist job(s):`);
39781
+ for (const job of jobs) {
39782
+ const ageMinutes = Math.round(job.ageMs / 60000);
39783
+ const bead = job.beadId ? ` bead=${job.beadId}` : "";
39784
+ const cwd = job.cwd ? ` cwd=${job.cwd}` : "";
39785
+ console.log(` - job=${job.jobId} pid=${job.pid}${bead} age=${ageMinutes}m reason=${job.reason}${cwd}`);
39786
+ }
39787
+ }
39788
+ async function reapStaleSpecialistJobs(jobs, dryRun) {
39789
+ const sqliteClient = createObservabilitySqliteClient();
39790
+ if (!sqliteClient)
39791
+ return 0;
39792
+ if (dryRun)
39793
+ return jobs.length;
39794
+ let reapedCount = 0;
39795
+ for (const job of jobs) {
39796
+ if (job.reason === "orphaned-keep-alive") {
39797
+ try {
39798
+ process.kill(job.pid, "SIGTERM");
39799
+ } catch {}
39800
+ await new Promise((resolve11) => setTimeout(resolve11, 500));
39801
+ try {
39802
+ process.kill(job.pid, 0);
39803
+ try {
39804
+ process.kill(job.pid, "SIGKILL");
39805
+ } catch {}
39806
+ } catch {}
39807
+ }
39808
+ sqliteClient.markSpecialistJobCancelled(job.jobId, `cleanup: stale-reaper:${job.reason}`);
39809
+ reapedCount += 1;
39810
+ }
39811
+ return reapedCount;
39812
+ }
39813
+ function printOrphanSummary(killedCount) {
39814
+ if (killedCount === 0)
39815
+ return;
39816
+ const noun = killedCount === 1 ? "process" : "processes";
39817
+ console.log(`Reaped ${killedCount} orphan/stale leaked ${noun}.`);
39818
+ }
39819
+ function printStaleJobSummary(reapedCount) {
39820
+ const noun = reapedCount === 1 ? "job" : "jobs";
39821
+ console.log(`Reaped ${reapedCount} stale specialist ${noun}.`);
39822
+ }
38818
39823
  function deleteJobDirectories(jobs) {
38819
39824
  for (const job of jobs) {
38820
- rmSync3(job.directoryPath, { recursive: true, force: true });
39825
+ rmSync4(job.directoryPath, { recursive: true, force: true });
38821
39826
  }
38822
39827
  return jobs.length;
38823
39828
  }
39829
+ function isPsHidden(status) {
39830
+ const typed = status;
39831
+ return Boolean(typed.ps_hidden_at ?? typed.ps_hidden_from_dashboard_at);
39832
+ }
39833
+ function selectPsDashboardCleanCandidates(statuses) {
39834
+ return statuses.filter((status) => COMPLETED_STATUSES.has(status.status) && !isPsHidden(status));
39835
+ }
39836
+ function printPsDashboardCleanPlan(statuses) {
39837
+ if (statuses.length === 0) {
39838
+ console.log("No terminal ps rows to hide.");
39839
+ return;
39840
+ }
39841
+ console.log(`Would hide ${statuses.length} terminal row(s) from default ps:`);
39842
+ for (const status of statuses) {
39843
+ const bead = status.bead_id ? ` bead=${status.bead_id}` : "";
39844
+ const branch = status.branch ? ` branch=${status.branch}` : "";
39845
+ console.log(` - ${status.id} ${status.specialist} status=${status.status}${bead}${branch}`);
39846
+ }
39847
+ }
39848
+ function hidePsDashboardRows(statuses, dryRun) {
39849
+ const sqliteClient = createObservabilitySqliteClient();
39850
+ if (!sqliteClient)
39851
+ return 0;
39852
+ if (dryRun)
39853
+ return statuses.length;
39854
+ const now = Date.now();
39855
+ for (const status of statuses) {
39856
+ sqliteClient.upsertStatus({
39857
+ ...status,
39858
+ ps_hidden_at: now,
39859
+ ps_hidden_reason: "sp clean --ps"
39860
+ });
39861
+ }
39862
+ return statuses.length;
39863
+ }
38824
39864
  function removeStaleProcesses(statuses, dryRun) {
38825
39865
  const sqliteClient = createObservabilitySqliteClient();
38826
39866
  if (!sqliteClient)
@@ -38849,13 +39889,54 @@ async function run22() {
38849
39889
  const message = error2 instanceof Error ? error2.message : String(error2);
38850
39890
  printUsageAndExit2(message);
38851
39891
  }
38852
- const jobsDirectoryPath = resolveJobsDir();
38853
- if (!existsSync27(jobsDirectoryPath)) {
38854
- console.log("No jobs directory found.");
39892
+ if (options.reapOrphans) {
39893
+ const orphans = findOrphanProcesses();
39894
+ const staleJobs = collectStaleSpecialistJobs();
39895
+ if (options.dryRun) {
39896
+ printOrphanPlan(orphans);
39897
+ printStaleJobPlan(staleJobs);
39898
+ return;
39899
+ }
39900
+ if (orphans.length === 0 && staleJobs.length === 0) {
39901
+ console.log("No orphan/stale leaked processes found.");
39902
+ return;
39903
+ }
39904
+ printOrphanPlan(orphans);
39905
+ printStaleJobPlan(staleJobs);
39906
+ const killedCount = await killOrphanProcesses(orphans, false);
39907
+ const staleCount = await reapStaleSpecialistJobs(staleJobs, false);
39908
+ printOrphanSummary(killedCount);
39909
+ printStaleJobSummary(staleCount);
38855
39910
  return;
38856
39911
  }
39912
+ const jobsDirectoryPath = resolveJobsDir();
38857
39913
  const sqliteClient = createObservabilitySqliteClient();
38858
39914
  const statuses = sqliteClient?.listStatuses() ?? [];
39915
+ if (options.observability) {
39916
+ if (!sqliteClient)
39917
+ throw new Error("Failed to initialize observability SQLite schema. Run `specialists db setup` first.");
39918
+ const report = sqliteClient.pruneObservabilityData({
39919
+ beforeMs: options.observabilityBeforeMs,
39920
+ includeEpics: options.includeEpics,
39921
+ apply: !options.dryRun
39922
+ });
39923
+ printObservabilityPruneReport(report);
39924
+ return;
39925
+ }
39926
+ if (options.psDashboard) {
39927
+ const candidates = selectPsDashboardCleanCandidates(statuses);
39928
+ if (options.dryRun) {
39929
+ printPsDashboardCleanPlan(candidates);
39930
+ return;
39931
+ }
39932
+ const hiddenCount = hidePsDashboardRows(candidates, false);
39933
+ console.log(`Hid ${hiddenCount} terminal row(s) from default ps.`);
39934
+ return;
39935
+ }
39936
+ if (!existsSync29(jobsDirectoryPath)) {
39937
+ console.log("No jobs directory found.");
39938
+ return;
39939
+ }
38859
39940
  const worktreeCandidates = collectWorktreeGcCandidates(jobsDirectoryPath);
38860
39941
  const protectedJobIds = options.keepRecentCount !== null && !options.aggressivePrune && sqliteClient ? new Set(sqliteClient.listReferencedChainRootJobIds()) : new Set;
38861
39942
  if (options.staleProcessesOnly) {
@@ -38894,6 +39975,7 @@ var MS_PER_DAY = 86400000, DEFAULT_TTL_DAYS = 7, DEFAULT_STALE_AFTER_HOURS = 24,
38894
39975
  var init_clean = __esm(() => {
38895
39976
  init_job_root();
38896
39977
  init_observability_sqlite();
39978
+ init_process_health();
38897
39979
  init_worktree_gc();
38898
39980
  COMPLETED_STATUSES = new Set(["done", "error", "cancelled"]);
38899
39981
  STALE_PROCESS_STATUSES = new Set(["running", "starting", "waiting"]);
@@ -39095,7 +40177,7 @@ async function run24() {
39095
40177
  process.exit(1);
39096
40178
  }
39097
40179
  if (status.status === "done" || status.status === "error" || status.status === "cancelled") {
39098
- process.stderr.write(`${dim11(`Job ${jobId} is already ${status.status}.`)}
40180
+ process.stderr.write(`${dim11(`Job ${jobId} already finalized (${status.status}).`)}
39099
40181
  `);
39100
40182
  return;
39101
40183
  }
@@ -39183,21 +40265,127 @@ var init_stop = __esm(() => {
39183
40265
  init_tmux_utils();
39184
40266
  });
39185
40267
 
40268
+ // src/cli/finalize.ts
40269
+ var exports_finalize = {};
40270
+ __export(exports_finalize, {
40271
+ run: () => run25
40272
+ });
40273
+ function createFinalizeSupervisor(jobsDir) {
40274
+ const runner = {
40275
+ run: async () => {
40276
+ throw new Error("finalize supervisor runner is not used");
40277
+ }
40278
+ };
40279
+ const runOptions = {};
40280
+ return new Supervisor({ runner, runOptions, jobsDir });
40281
+ }
40282
+ function parseFinalizeArgs(argv) {
40283
+ const jobId = argv.find((token) => !token.startsWith("-"));
40284
+ return { jobId };
40285
+ }
40286
+ function findReviewerPassInChain(supervisor, chainId) {
40287
+ const jobIds = supervisor.listChainJobIds(chainId);
40288
+ for (const id of jobIds) {
40289
+ const status = supervisor.readStatus(id);
40290
+ if (!status || status.specialist !== "reviewer")
40291
+ continue;
40292
+ const output2 = supervisor.readResult(id) ?? "";
40293
+ if (PASS_COMPLIANCE_VERDICT_REGEX2.test(output2)) {
40294
+ return { reviewerJobId: id };
40295
+ }
40296
+ }
40297
+ return null;
40298
+ }
40299
+ async function run25() {
40300
+ const parsed = parseFinalizeArgs(process.argv.slice(3));
40301
+ const jobId = parsed.jobId;
40302
+ if (!jobId) {
40303
+ console.error("Usage: specialists|sp finalize <job-id>");
40304
+ process.exit(1);
40305
+ }
40306
+ const jobsDir = resolveJobsDir();
40307
+ const supervisor = createFinalizeSupervisor(jobsDir);
40308
+ try {
40309
+ const status = supervisor.readStatus(jobId);
40310
+ if (!status) {
40311
+ console.error(`No job found: ${jobId}`);
40312
+ process.exit(1);
40313
+ }
40314
+ const chainId = status.chain_id ?? status.chain_root_job_id;
40315
+ if (!chainId) {
40316
+ process.stderr.write(`${red7("Error:")} Job ${jobId} has no chain identity (chain_id missing).
40317
+ `);
40318
+ process.exit(1);
40319
+ }
40320
+ const reviewerPass = findReviewerPassInChain(supervisor, chainId);
40321
+ if (!reviewerPass) {
40322
+ process.stderr.write(`${red7("Error:")} No reviewer with PASS compliance verdict found in chain ${chainId}.
40323
+ `);
40324
+ process.stderr.write(`${dim12("finalize only closes keep-alive chains after reviewer PASS.")}
40325
+ `);
40326
+ process.exit(1);
40327
+ }
40328
+ const chainJobIds = supervisor.listChainJobIds(chainId);
40329
+ const finalized = [];
40330
+ const skipped = [];
40331
+ for (const id of chainJobIds) {
40332
+ const memberStatus = supervisor.readStatus(id);
40333
+ if (!memberStatus) {
40334
+ skipped.push({ id, reason: "missing" });
40335
+ continue;
40336
+ }
40337
+ if (memberStatus.status !== "waiting") {
40338
+ skipped.push({ id, reason: memberStatus.status });
40339
+ continue;
40340
+ }
40341
+ const result = supervisor.finalizeWaitingJob(id);
40342
+ if (result) {
40343
+ finalized.push(id);
40344
+ } else {
40345
+ skipped.push({ id, reason: "finalize-failed" });
40346
+ }
40347
+ }
40348
+ if (finalized.length === 0) {
40349
+ process.stderr.write(`${red7("Error:")} No waiting keep-alive jobs to finalize in chain ${chainId}.
40350
+ `);
40351
+ process.exit(1);
40352
+ }
40353
+ process.stdout.write(`${green13("\u2713")} Finalized chain ${chainId} (reviewer PASS: ${reviewerPass.reviewerJobId})
40354
+ `);
40355
+ for (const id of finalized) {
40356
+ process.stdout.write(` ${green13("\u2713")} ${id}
40357
+ `);
40358
+ }
40359
+ for (const { id, reason } of skipped) {
40360
+ process.stdout.write(` ${dim12(`\xB7 ${id} (${reason})`)}
40361
+ `);
40362
+ }
40363
+ } finally {
40364
+ await supervisor.dispose();
40365
+ }
40366
+ }
40367
+ var green13 = (s) => `\x1B[32m${s}\x1B[0m`, red7 = (s) => `\x1B[31m${s}\x1B[0m`, dim12 = (s) => `\x1B[2m${s}\x1B[0m`, PASS_COMPLIANCE_VERDICT_REGEX2;
40368
+ var init_finalize = __esm(() => {
40369
+ init_supervisor();
40370
+ init_job_root();
40371
+ PASS_COMPLIANCE_VERDICT_REGEX2 = /## Compliance Verdict[\s\S]*?- Verdict:\s*\**\s*PASS\s*\**/i;
40372
+ });
40373
+
39186
40374
  // src/cli/attach.ts
39187
40375
  var exports_attach = {};
39188
40376
  __export(exports_attach, {
39189
- run: () => run25
40377
+ run: () => run26
39190
40378
  });
39191
40379
  import { execFileSync as execFileSync3, spawnSync as spawnSync21 } from "child_process";
39192
- import { readFileSync as readFileSync28 } from "fs";
39193
- import { join as join30 } from "path";
40380
+ import { readFileSync as readFileSync29 } from "fs";
40381
+ import { join as join31 } from "path";
39194
40382
  function exitWithError(message) {
39195
40383
  console.error(message);
39196
40384
  process.exit(1);
39197
40385
  }
39198
40386
  function readStatus(statusPath, jobId) {
39199
40387
  try {
39200
- return JSON.parse(readFileSync28(statusPath, "utf-8"));
40388
+ return JSON.parse(readFileSync29(statusPath, "utf-8"));
39201
40389
  } catch (error2) {
39202
40390
  if (error2 && typeof error2 === "object" && "code" in error2 && error2.code === "ENOENT") {
39203
40391
  exitWithError(`Job \`${jobId}\` not found. Run \`specialists status\` to see active jobs in current mode.`);
@@ -39206,13 +40394,13 @@ function readStatus(statusPath, jobId) {
39206
40394
  exitWithError(`Failed to read status for job \`${jobId}\`: ${details}`);
39207
40395
  }
39208
40396
  }
39209
- async function run25() {
40397
+ async function run26() {
39210
40398
  const [jobId] = process.argv.slice(3);
39211
40399
  if (!jobId) {
39212
40400
  exitWithError("Usage: specialists attach <job-id> (normal runtime is DB-backed; job files are legacy/operator-only)");
39213
40401
  }
39214
- const jobsDir = join30(process.cwd(), ".specialists", "jobs");
39215
- const statusPath = join30(jobsDir, jobId, "status.json");
40402
+ const jobsDir = join31(process.cwd(), ".specialists", "jobs");
40403
+ const statusPath = join31(jobsDir, jobId, "status.json");
39216
40404
  const status = readStatus(statusPath, jobId);
39217
40405
  if (status.status === "done" || status.status === "error") {
39218
40406
  exitWithError(`Job \`${jobId}\` has already completed (status: ${status.status}). Use \`specialists result ${jobId}\` to read output.`);
@@ -39234,15 +40422,15 @@ async function run25() {
39234
40422
  var init_attach = () => {};
39235
40423
 
39236
40424
  // src/specialist/drift-detector.ts
39237
- import { existsSync as existsSync28, readFileSync as readFileSync29, readdirSync as readdirSync13, rmSync as rmSync4 } from "fs";
39238
- import { join as join31, resolve as resolve11, relative as relative3 } from "path";
40425
+ import { existsSync as existsSync30, readFileSync as readFileSync30, readdirSync as readdirSync15, rmSync as rmSync5 } from "fs";
40426
+ import { join as join32, resolve as resolve11, relative as relative3 } from "path";
39239
40427
  function listFiles(root) {
39240
- if (!existsSync28(root))
40428
+ if (!existsSync30(root))
39241
40429
  return [];
39242
40430
  const out = [];
39243
40431
  const visit2 = (dir) => {
39244
- for (const entry of readdirSync13(dir, { withFileTypes: true })) {
39245
- const full = join31(dir, entry.name);
40432
+ for (const entry of readdirSync15(dir, { withFileTypes: true })) {
40433
+ const full = join32(dir, entry.name);
39246
40434
  if (entry.isDirectory()) {
39247
40435
  visit2(full);
39248
40436
  continue;
@@ -39277,14 +40465,14 @@ function detectDriftForRepo(repoRoot) {
39277
40465
  { scope: "user", dir: resolve11(repoRoot, ".specialists/user") }
39278
40466
  ];
39279
40467
  for (const { scope, dir } of scopes) {
39280
- if (!existsSync28(dir))
40468
+ if (!existsSync30(dir))
39281
40469
  continue;
39282
40470
  for (const file of listFiles(dir)) {
39283
40471
  const rel = relPath(file, dir);
39284
- const canonicalPath = join31(asset.canonicalDir, rel);
39285
- if (!existsSync28(canonicalPath))
40472
+ const canonicalPath = join32(asset.canonicalDir, rel);
40473
+ if (!existsSync30(canonicalPath))
39286
40474
  continue;
39287
- const bytesEqual = readFileSync29(file).equals(readFileSync29(canonicalPath));
40475
+ const bytesEqual = readFileSync30(file).equals(readFileSync30(canonicalPath));
39288
40476
  findings.push(makeFinding(repoRoot, asset.kind, scope, file, canonicalPath, bytesEqual));
39289
40477
  }
39290
40478
  }
@@ -39303,12 +40491,12 @@ function detectDriftUnderRoot(root) {
39303
40491
  repos.push({ root: dir, findings });
39304
40492
  return;
39305
40493
  }
39306
- for (const entry of readdirSync13(dir, { withFileTypes: true })) {
40494
+ for (const entry of readdirSync15(dir, { withFileTypes: true })) {
39307
40495
  if (!entry.isDirectory())
39308
40496
  continue;
39309
40497
  if (entry.name === "node_modules" || entry.name === ".git")
39310
40498
  continue;
39311
- visit2(join31(dir, entry.name));
40499
+ visit2(join32(dir, entry.name));
39312
40500
  }
39313
40501
  };
39314
40502
  visit2(resolve11(root));
@@ -39330,7 +40518,7 @@ function pruneStaleDefaults(repoRoot, dryRun) {
39330
40518
  const targets = detectDriftForRepo(repoRoot).filter((f) => f.scope === "default" && f.bytes_equal === true).map((f) => f.path);
39331
40519
  if (!dryRun) {
39332
40520
  for (const target of targets)
39333
- rmSync4(target, { recursive: true, force: true });
40521
+ rmSync5(target, { recursive: true, force: true });
39334
40522
  }
39335
40523
  return targets;
39336
40524
  }
@@ -39348,7 +40536,7 @@ var init_drift_detector = __esm(() => {
39348
40536
  // src/cli/prune-stale-defaults.ts
39349
40537
  var exports_prune_stale_defaults = {};
39350
40538
  __export(exports_prune_stale_defaults, {
39351
- run: () => run26
40539
+ run: () => run27
39352
40540
  });
39353
40541
  import { resolve as resolve12 } from "path";
39354
40542
  function parseArgs11(argv) {
@@ -39382,7 +40570,7 @@ function printHelp() {
39382
40570
  console.log(" --dry-run List stale default snapshots without pruning");
39383
40571
  console.log(" --root Repo root to scan");
39384
40572
  }
39385
- async function run26(argv = process.argv.slice(3)) {
40573
+ async function run27(argv = process.argv.slice(3)) {
39386
40574
  const { dryRun, root, help } = parseArgs11(argv);
39387
40575
  if (help) {
39388
40576
  printHelp();
@@ -39409,33 +40597,39 @@ var init_prune_stale_defaults = __esm(() => {
39409
40597
  // src/cli/quickstart.ts
39410
40598
  var exports_quickstart = {};
39411
40599
  __export(exports_quickstart, {
39412
- run: () => run27
40600
+ run: () => run28
39413
40601
  });
39414
40602
  function section2(title) {
39415
40603
  const bar = "\u2500".repeat(60);
39416
40604
  return `
39417
40605
  ${bold12(cyan7(title))}
39418
- ${dim12(bar)}`;
40606
+ ${dim13(bar)}`;
39419
40607
  }
39420
40608
  function cmd2(s) {
39421
40609
  return yellow11(s);
39422
40610
  }
39423
40611
  function flag(s) {
39424
- return green13(s);
40612
+ return green14(s);
39425
40613
  }
39426
- async function run27() {
40614
+ async function run28() {
39427
40615
  const lines = [
39428
40616
  "",
39429
40617
  bold12("specialists \xB7 Quick Start Guide"),
39430
- dim12("One MCP server. Multiple AI backends. Intelligent orchestration."),
39431
- dim12("Tip: sp is a shorter alias \u2014 sp run, sp list, sp feed etc. work identically."),
40618
+ dim13("One MCP server. Multiple AI backends. Intelligent orchestration."),
40619
+ dim13("Tip: sp is a shorter alias \u2014 sp run, sp list, sp feed etc. work identically."),
39432
40620
  ""
39433
40621
  ];
39434
40622
  lines.push(section2("1. Installation"));
39435
40623
  lines.push("");
40624
+ lines.push(` ${bold12("Prerequisite: Bun")} ${cmd2("bun --version")} # verify Bun >=1.0.0`);
40625
+ lines.push(` ${cmd2("curl -fsSL https://bun.sh/install | bash")} # install Bun if missing`);
40626
+ lines.push(` ${cmd2("npm install -g xtrm-tools")} # install runtime prerequisite`);
40627
+ lines.push(` ${cmd2("xt install")} # install xtrm-managed assets`);
40628
+ lines.push(` ${cmd2("xt init")} # initialize .xtrm/ in this repo`);
40629
+ lines.push(` ${dim13("sp list, sp doctor --check-drift, sp prune-stale-defaults are Category A and do NOT require xt or .xtrm/")}`);
39436
40630
  lines.push(` ${cmd2("npm install -g @jaggerxtrm/specialists")} # install globally`);
39437
- lines.push(` ${cmd2("specialists init")} # project setup:`);
39438
- lines.push(` ${dim12(" # creates dirs, wires MCP + hooks, injects context")}`);
40631
+ lines.push(` ${cmd2("sp init")} # project setup:`);
40632
+ lines.push(` ${dim13(" # creates dirs, wires MCP + hooks, injects context")}`);
39439
40633
  lines.push("");
39440
40634
  lines.push(` Verify everything is healthy:`);
39441
40635
  lines.push(` ${cmd2("specialists status")} # shows pi, beads, MCP, active jobs`);
@@ -39443,13 +40637,14 @@ async function run27() {
39443
40637
  lines.push(section2("2. Initialize a Project"));
39444
40638
  lines.push("");
39445
40639
  lines.push(` Run once per project root:`);
39446
- lines.push(` ${cmd2("specialists init")} # creates .specialists/, wires MCP + AGENTS.md`);
40640
+ lines.push(` ${cmd2("sp init")} # creates .specialists/, wires MCP + AGENTS.md`);
40641
+ lines.push(` ${dim13(" # requires xt init first so .xtrm/ already exists")}`);
39447
40642
  lines.push("");
39448
40643
  lines.push(` What this creates:`);
39449
- lines.push(` ${dim12(".specialists/default/")} \u2014 canonical specialists (from init)`);
39450
- lines.push(` ${dim12(".specialists/user/")} \u2014 custom .specialist.json files`);
39451
- lines.push(` ${dim12(".specialists/jobs|ready")} \u2014 runtime data \u2014 gitignored`);
39452
- lines.push(` ${dim12("AGENTS.md")} \u2014 context block injected into Claude sessions`);
40644
+ lines.push(` ${dim13(".specialists/default/")} \u2014 canonical specialists (from init)`);
40645
+ lines.push(` ${dim13(".specialists/user/")} \u2014 custom .specialist.json files`);
40646
+ lines.push(` ${dim13(".specialists/jobs|ready")} \u2014 runtime data \u2014 gitignored`);
40647
+ lines.push(` ${dim13("AGENTS.md")} \u2014 context block injected into Claude sessions`);
39453
40648
  lines.push("");
39454
40649
  lines.push(section2("3. Discover Specialists"));
39455
40650
  lines.push("");
@@ -39466,17 +40661,17 @@ async function run27() {
39466
40661
  lines.push(section2("4. Running a Specialist"));
39467
40662
  lines.push("");
39468
40663
  lines.push(` ${bold12("Foreground")} (streams output to stdout):`);
39469
- lines.push(` ${cmd2("specialists run code-review")} ${flag("--prompt")} ${dim12('"Review src/api.ts for security issues"')}`);
40664
+ lines.push(` ${cmd2("specialists run code-review")} ${flag("--prompt")} ${dim13('"Review src/api.ts for security issues"')}`);
39470
40665
  lines.push("");
39471
40666
  lines.push(` ${bold12("Tracked run")} (linked to a beads issue for workflow integration):`);
39472
- lines.push(` ${cmd2("specialists run code-review")} ${flag("--bead")} ${dim12("unitAI-abc")}`);
39473
- lines.push(` ${dim12(" # uses bead description as prompt, tracks result in issue")}`);
40667
+ lines.push(` ${cmd2("specialists run code-review")} ${flag("--bead")} ${dim13("unitAI-abc")}`);
40668
+ lines.push(` ${dim13(" # uses bead description as prompt, tracks result in issue")}`);
39474
40669
  lines.push("");
39475
40670
  lines.push(` Override model for one run:`);
39476
- lines.push(` ${cmd2("specialists run code-review")} ${flag("--model")} ${dim12("anthropic/claude-opus-4-6")} ${flag("--prompt")} ${dim12('"..."')}`);
40671
+ lines.push(` ${cmd2("specialists run code-review")} ${flag("--model")} ${dim13("anthropic/claude-opus-4-6")} ${flag("--prompt")} ${dim13('"..."')}`);
39477
40672
  lines.push("");
39478
40673
  lines.push(` Run without beads issue tracking:`);
39479
- lines.push(` ${cmd2("specialists run code-review")} ${flag("--no-beads")} ${flag("--prompt")} ${dim12('"..."')}`);
40674
+ lines.push(` ${cmd2("specialists run code-review")} ${flag("--no-beads")} ${flag("--prompt")} ${dim13('"..."')}`);
39480
40675
  lines.push("");
39481
40676
  lines.push(` Pipe a prompt from stdin:`);
39482
40677
  lines.push(` ${cmd2("cat my-brief.md | specialists run code-review")}`);
@@ -39484,7 +40679,7 @@ async function run27() {
39484
40679
  lines.push(section2("5. Async Job Lifecycle"));
39485
40680
  lines.push("");
39486
40681
  lines.push(` ${bold12("MCP pattern")}: ${cmd2("use_specialist")} (foreground, returns result directly)`);
39487
- lines.push(` ${bold12("CLI pattern")}: ${cmd2('specialists run <name> --prompt "..."')} prints ${dim12("[job started: <id>]")} to stderr`);
40682
+ lines.push(` ${bold12("CLI pattern")}: ${cmd2('specialists run <name> --prompt "..."')} prints ${dim13("[job started: <id>]")} to stderr`);
39488
40683
  lines.push(` ${bold12("Shell pattern")}: ${cmd2('specialists run <name> --prompt "..." &')} for native backgrounding`);
39489
40684
  lines.push("");
39490
40685
  lines.push(` ${bold12("Watch progress")} \u2014 stream events as they arrive:`);
@@ -39496,11 +40691,11 @@ async function run27() {
39496
40691
  lines.push("");
39497
40692
  lines.push(` ${bold12("Steer a running job")} \u2014 redirect the agent mid-run without cancelling:`);
39498
40693
  lines.push(` ${cmd2("specialists steer job_a1b2c3d4")} ${flag('"focus only on supervisor.ts"')}`);
39499
- lines.push(` ${dim12(" # delivered after current tool calls finish, before the next LLM call")}`);
40694
+ lines.push(` ${dim13(" # delivered after current tool calls finish, before the next LLM call")}`);
39500
40695
  lines.push("");
39501
40696
  lines.push(` ${bold12("Keep-alive multi-turn")} \u2014 start with ${flag("--keep-alive")}, then follow up:`);
39502
40697
  lines.push(` ${cmd2("specialists run debugger")} ${flag("--bead unitAI-abc --keep-alive")}`);
39503
- lines.push(` ${dim12(" # \u2192 status: waiting after first turn")}`);
40698
+ lines.push(` ${dim13(" # \u2192 status: waiting after first turn")}`);
39504
40699
  lines.push(` ${cmd2("specialists result a1b2c3")} # read first turn`);
39505
40700
  lines.push(` ${cmd2("specialists follow-up a1b2c3")} ${flag('"now write the fix"')} # next turn, same Pi context`);
39506
40701
  lines.push(` ${cmd2("specialists feed a1b2c3")} ${flag("--follow")} # watch response`);
@@ -39508,23 +40703,23 @@ async function run27() {
39508
40703
  lines.push(` ${bold12("Cancel a job")}:`);
39509
40704
  lines.push(` ${cmd2("specialists stop job_a1b2c3d4")} # sends SIGTERM to the agent process`);
39510
40705
  lines.push("");
39511
- lines.push(` ${bold12("Job files")} in ${dim12(".specialists/jobs/<job-id>/")}:`);
39512
- lines.push(` ${dim12("status.json")} \u2014 id, specialist, status, pid, started_at, elapsed_s, current_tool`);
39513
- lines.push(` ${dim12("events.jsonl")} \u2014 one JSON event per line (tool_use, text, agent_end, error \u2026)`);
39514
- lines.push(` ${dim12("result.txt")} \u2014 final output (written when status=done)`);
39515
- lines.push(` ${dim12("steer.pipe")} \u2014 named FIFO for mid-run steering (removed on job completion)`);
40706
+ lines.push(` ${bold12("Job files")} in ${dim13(".specialists/jobs/<job-id>/")}:`);
40707
+ lines.push(` ${dim13("status.json")} \u2014 id, specialist, status, pid, started_at, elapsed_s, current_tool`);
40708
+ lines.push(` ${dim13("events.jsonl")} \u2014 one JSON event per line (tool_use, text, agent_end, error \u2026)`);
40709
+ lines.push(` ${dim13("result.txt")} \u2014 final output (written when status=done)`);
40710
+ lines.push(` ${dim13("steer.pipe")} \u2014 named FIFO for mid-run steering (removed on job completion)`);
39516
40711
  lines.push("");
39517
40712
  lines.push(section2("6. Editing Specialists"));
39518
40713
  lines.push("");
39519
40714
  lines.push(` Change a field without opening the YAML manually:`);
39520
- lines.push(` ${cmd2("specialists edit code-review")} ${flag("--model")} ${dim12("anthropic/claude-sonnet-4-6")}`);
39521
- lines.push(` ${cmd2("specialists edit code-review")} ${flag("--description")} ${dim12('"Updated description"')}`);
39522
- lines.push(` ${cmd2("specialists edit code-review")} ${flag("--timeout")} ${dim12("120000")}`);
39523
- lines.push(` ${cmd2("specialists edit code-review")} ${flag("--permission")} ${dim12("HIGH")}`);
39524
- lines.push(` ${cmd2("specialists edit code-review")} ${flag("--tags")} ${dim12("analysis,security,review")}`);
40715
+ lines.push(` ${cmd2("specialists edit code-review")} ${flag("--model")} ${dim13("anthropic/claude-sonnet-4-6")}`);
40716
+ lines.push(` ${cmd2("specialists edit code-review")} ${flag("--description")} ${dim13('"Updated description"')}`);
40717
+ lines.push(` ${cmd2("specialists edit code-review")} ${flag("--timeout")} ${dim13("120000")}`);
40718
+ lines.push(` ${cmd2("specialists edit code-review")} ${flag("--permission")} ${dim13("HIGH")}`);
40719
+ lines.push(` ${cmd2("specialists edit code-review")} ${flag("--tags")} ${dim13("analysis,security,review")}`);
39525
40720
  lines.push("");
39526
40721
  lines.push(` Preview without writing:`);
39527
- lines.push(` ${cmd2("specialists edit code-review")} ${flag("--model")} ${dim12("...")} ${flag("--dry-run")}`);
40722
+ lines.push(` ${cmd2("specialists edit code-review")} ${flag("--model")} ${dim13("...")} ${flag("--dry-run")}`);
39528
40723
  lines.push("");
39529
40724
  lines.push(section2("7. .specialist.json Schema"));
39530
40725
  lines.push("");
@@ -39571,12 +40766,12 @@ async function run27() {
39571
40766
  " priority: 2 # 0=critical \u2026 4=backlog"
39572
40767
  ];
39573
40768
  for (const l of schemaLines) {
39574
- lines.push(` ${dim12(l)}`);
40769
+ lines.push(` ${dim13(l)}`);
39575
40770
  }
39576
40771
  lines.push("");
39577
40772
  lines.push(section2("8. Hook System"));
39578
40773
  lines.push("");
39579
- lines.push(` Specialists emits lifecycle events to ${dim12(".specialists/trace.jsonl")}:`);
40774
+ lines.push(` Specialists emits lifecycle events to ${dim13(".specialists/trace.jsonl")}:`);
39580
40775
  lines.push("");
39581
40776
  lines.push(` ${bold12("Hook point")} ${bold12("When fired")}`);
39582
40777
  lines.push(` ${yellow11("specialist:start")} before the agent session begins`);
@@ -39585,7 +40780,7 @@ async function run27() {
39585
40780
  lines.push(` ${yellow11("specialist:error")} on failure or timeout`);
39586
40781
  lines.push("");
39587
40782
  lines.push(` Each event line in trace.jsonl:`);
39588
- lines.push(` ${dim12('{"t":"<ISO>","hook":"specialist:done","specialist":"code-review","durationMs":4120}')}`);
40783
+ lines.push(` ${dim13('{"t":"<ISO>","hook":"specialist:done","specialist":"code-review","durationMs":4120}')}`);
39589
40784
  lines.push("");
39590
40785
  lines.push(` Tail the trace file to observe all activity:`);
39591
40786
  lines.push(` ${cmd2("tail -f .specialists/trace.jsonl | jq .")}`);
@@ -39610,7 +40805,7 @@ async function run27() {
39610
40805
  lines.push("");
39611
40806
  lines.push(` ${bold12("Tracked run with beads integration:")}`);
39612
40807
  lines.push(` ${cmd2("specialists run deep-analysis --bead unitAI-abc")}`);
39613
- lines.push(` ${dim12(" # prompt from bead, result tracked in bead")}`);
40808
+ lines.push(` ${dim13(" # prompt from bead, result tracked in bead")}`);
39614
40809
  lines.push("");
39615
40810
  lines.push(` ${bold12("Steer a job mid-run:")}`);
39616
40811
  lines.push(` ${cmd2('specialists steer <job-id> "focus only on the auth module"')}`);
@@ -39625,20 +40820,21 @@ async function run27() {
39625
40820
  lines.push(` ${bold12("Override model for a single run:")}`);
39626
40821
  lines.push(` ${cmd2('specialists run code-review --model anthropic/claude-opus-4-6 --prompt "..."')}`);
39627
40822
  lines.push("");
39628
- lines.push(dim12("\u2500".repeat(62)));
39629
- lines.push(` ${dim12("specialists help")} command list ${dim12("specialists <cmd> --help")} per-command flags`);
39630
- lines.push(` ${dim12("specialists status")} health check ${dim12("specialists models")} available models`);
40823
+ lines.push(dim13("\u2500".repeat(62)));
40824
+ lines.push(` ${dim13("specialists help")} command list ${dim13("specialists <cmd> --help")} per-command flags`);
40825
+ lines.push(` ${dim13("specialists status")} health check ${dim13("specialists models")} available models`);
39631
40826
  lines.push("");
39632
40827
  console.log(lines.join(`
39633
40828
  `));
39634
40829
  }
39635
- var bold12 = (s) => `\x1B[1m${s}\x1B[0m`, dim12 = (s) => `\x1B[2m${s}\x1B[0m`, yellow11 = (s) => `\x1B[33m${s}\x1B[0m`, cyan7 = (s) => `\x1B[36m${s}\x1B[0m`, blue4 = (s) => `\x1B[34m${s}\x1B[0m`, green13 = (s) => `\x1B[32m${s}\x1B[0m`;
40830
+ var bold12 = (s) => `\x1B[1m${s}\x1B[0m`, dim13 = (s) => `\x1B[2m${s}\x1B[0m`, yellow11 = (s) => `\x1B[33m${s}\x1B[0m`, cyan7 = (s) => `\x1B[36m${s}\x1B[0m`, blue4 = (s) => `\x1B[34m${s}\x1B[0m`, green14 = (s) => `\x1B[32m${s}\x1B[0m`;
39636
40831
 
39637
40832
  // src/cli/doctor.ts
39638
40833
  var exports_doctor = {};
39639
40834
  __export(exports_doctor, {
39640
40835
  setStatusError: () => setStatusError,
39641
- run: () => run28,
40836
+ run: () => run29,
40837
+ resolvePackageAssetDir: () => resolvePackageAssetDir,
39642
40838
  renderProcessSummary: () => renderProcessSummary,
39643
40839
  parseVersionTuple: () => parseVersionTuple,
39644
40840
  compareVersions: () => compareVersions2,
@@ -39646,22 +40842,22 @@ __export(exports_doctor, {
39646
40842
  });
39647
40843
  import { createHash as createHash5 } from "crypto";
39648
40844
  import { spawnSync as spawnSync22 } from "child_process";
39649
- import { existsSync as existsSync29, lstatSync as lstatSync2, mkdirSync as mkdirSync11, readdirSync as readdirSync14, readFileSync as readFileSync30, readlinkSync as readlinkSync2, writeFileSync as writeFileSync12 } from "fs";
39650
- import { dirname as dirname10, join as join32, relative as relative4, resolve as resolve13 } from "path";
40845
+ import { existsSync as existsSync31, lstatSync as lstatSync2, mkdirSync as mkdirSync11, readdirSync as readdirSync16, readFileSync as readFileSync31, readlinkSync as readlinkSync3, writeFileSync as writeFileSync13 } from "fs";
40846
+ import { dirname as dirname10, join as join33, relative as relative4, resolve as resolve13 } from "path";
39651
40847
  function ok3(msg) {
39652
- console.log(` ${green14("\u2713")} ${msg}`);
40848
+ console.log(` ${green15("\u2713")} ${msg}`);
39653
40849
  }
39654
40850
  function warn3(msg) {
39655
40851
  console.log(` ${yellow12("\u25CB")} ${msg}`);
39656
40852
  }
39657
40853
  function fail4(msg) {
39658
- console.log(` ${red7("\u2717")} ${msg}`);
40854
+ console.log(` ${red8("\u2717")} ${msg}`);
39659
40855
  }
39660
40856
  function fix(msg) {
39661
- console.log(` ${dim13("\u2192 fix:")} ${yellow12(msg)}`);
40857
+ console.log(` ${dim14("\u2192 fix:")} ${yellow12(msg)}`);
39662
40858
  }
39663
40859
  function hint(msg) {
39664
- console.log(` ${dim13(msg)}`);
40860
+ console.log(` ${dim14(msg)}`);
39665
40861
  }
39666
40862
  function section3(label) {
39667
40863
  const line = "\u2500".repeat(Math.max(0, 38 - label.length));
@@ -39676,10 +40872,10 @@ function isInstalled3(bin) {
39676
40872
  return spawnSync22("which", [bin], { encoding: "utf8", timeout: 2000 }).status === 0;
39677
40873
  }
39678
40874
  function loadJson2(path) {
39679
- if (!existsSync29(path))
40875
+ if (!existsSync31(path))
39680
40876
  return null;
39681
40877
  try {
39682
- return JSON.parse(readFileSync30(path, "utf8"));
40878
+ return JSON.parse(readFileSync31(path, "utf8"));
39683
40879
  } catch {
39684
40880
  return null;
39685
40881
  }
@@ -39701,7 +40897,7 @@ function checkPi() {
39701
40897
  fix("pi config (add at least one API key)");
39702
40898
  return false;
39703
40899
  }
39704
- ok3(`pi ${vStr} \u2014 ${providers.size} provider${providers.size > 1 ? "s" : ""} active ${dim13(`(${[...providers].join(", ")})`)}`);
40900
+ ok3(`pi ${vStr} \u2014 ${providers.size} provider${providers.size > 1 ? "s" : ""} active ${dim14(`(${[...providers].join(", ")})`)}`);
39705
40901
  return true;
39706
40902
  }
39707
40903
  function checkSpAlias() {
@@ -39721,8 +40917,8 @@ function checkBd() {
39721
40917
  fix("install beads (bd) first");
39722
40918
  return false;
39723
40919
  }
39724
- ok3(`bd installed ${dim13(sp("bd", ["--version"]).stdout || "")}`);
39725
- if (existsSync29(join32(CWD, ".beads")))
40920
+ ok3(`bd installed ${dim14(sp("bd", ["--version"]).stdout || "")}`);
40921
+ if (existsSync31(join33(CWD, ".beads")))
39726
40922
  ok3(".beads/ present in project");
39727
40923
  else
39728
40924
  warn3(".beads/ not found in project");
@@ -39735,22 +40931,22 @@ function checkXt() {
39735
40931
  fix("install xtrm-tools first");
39736
40932
  return false;
39737
40933
  }
39738
- ok3(`xt installed ${dim13(sp("xt", ["--version"]).stdout || "")}`);
40934
+ ok3(`xt installed ${dim14(sp("xt", ["--version"]).stdout || "")}`);
39739
40935
  return true;
39740
40936
  }
39741
40937
  function checkHooks() {
39742
40938
  section3("Claude Code hooks (2 expected)");
39743
40939
  let allPresent = true;
39744
40940
  for (const name of HOOK_NAMES) {
39745
- const canonicalPath = join32(HOOKS_DIR, name);
39746
- if (!existsSync29(canonicalPath)) {
39747
- fail4(`${relative4(CWD, canonicalPath)} ${red7("missing")}`);
40941
+ const canonicalPath = join33(HOOKS_DIR, name);
40942
+ if (!existsSync31(canonicalPath)) {
40943
+ fail4(`${relative4(CWD, canonicalPath)} ${red8("missing")}`);
39748
40944
  fix("specialists init");
39749
40945
  allPresent = false;
39750
40946
  } else {
39751
40947
  ok3(relative4(CWD, canonicalPath));
39752
40948
  }
39753
- const claudeHookPath = join32(CLAUDE_HOOKS_DIR, name);
40949
+ const claudeHookPath = join33(CLAUDE_HOOKS_DIR, name);
39754
40950
  const symlinkState = isSymlinkTo(claudeHookPath, canonicalPath);
39755
40951
  if (symlinkState.ok) {
39756
40952
  ok3(`${relative4(CWD, claudeHookPath)} -> ${relative4(dirname10(claudeHookPath), canonicalPath)}`);
@@ -39825,14 +41021,14 @@ function checkVersion() {
39825
41021
  }
39826
41022
  function hashFile(path) {
39827
41023
  const hash = createHash5("sha256");
39828
- hash.update(readFileSync30(path));
41024
+ hash.update(readFileSync31(path));
39829
41025
  return hash.digest("hex");
39830
41026
  }
39831
41027
  function collectFileHashes(rootDir) {
39832
41028
  const hashes = new Map;
39833
41029
  const visit2 = (dir) => {
39834
- for (const entry of readdirSync14(dir, { withFileTypes: true })) {
39835
- const fullPath = join32(dir, entry.name);
41030
+ for (const entry of readdirSync16(dir, { withFileTypes: true })) {
41031
+ const fullPath = join33(dir, entry.name);
39836
41032
  if (entry.isDirectory()) {
39837
41033
  visit2(fullPath);
39838
41034
  continue;
@@ -39843,12 +41039,12 @@ function collectFileHashes(rootDir) {
39843
41039
  hashes.set(relPath2, hashFile(fullPath));
39844
41040
  }
39845
41041
  };
39846
- if (existsSync29(rootDir))
41042
+ if (existsSync31(rootDir))
39847
41043
  visit2(rootDir);
39848
41044
  return hashes;
39849
41045
  }
39850
41046
  function isSymlinkTo(linkPath, expectedTargetPath) {
39851
- if (!existsSync29(linkPath))
41047
+ if (!existsSync31(linkPath))
39852
41048
  return { ok: false, reason: "missing" };
39853
41049
  let stats;
39854
41050
  try {
@@ -39859,7 +41055,7 @@ function isSymlinkTo(linkPath, expectedTargetPath) {
39859
41055
  if (!stats.isSymbolicLink())
39860
41056
  return { ok: false, reason: "not-symlink" };
39861
41057
  try {
39862
- const rawTarget = readlinkSync2(linkPath);
41058
+ const rawTarget = readlinkSync3(linkPath);
39863
41059
  const resolvedTarget = resolve13(dirname10(linkPath), rawTarget);
39864
41060
  const resolvedExpected = resolve13(expectedTargetPath);
39865
41061
  if (resolvedTarget !== resolvedExpected) {
@@ -39870,19 +41066,23 @@ function isSymlinkTo(linkPath, expectedTargetPath) {
39870
41066
  return { ok: false, reason: "broken" };
39871
41067
  }
39872
41068
  }
41069
+ function resolvePackageAssetDir(relativePath) {
41070
+ return resolveCanonicalAssetDir(relativePath) ?? (existsSync31(join33(CWD, "config", relativePath)) ? join33(CWD, "config", relativePath) : null);
41071
+ }
39873
41072
  function checkSkillDrift() {
39874
- section3("Skill drift (.xtrm skill sync)");
39875
- if (!existsSync29(CONFIG_SKILLS_DIR)) {
39876
- fail4("config/skills/ missing");
39877
- fix("restore config/skills/ from git");
41073
+ section3("Category A package-live skill sync");
41074
+ const canonicalSkillsDir = resolvePackageAssetDir("skills");
41075
+ if (!canonicalSkillsDir) {
41076
+ fail4("package canonical skills source missing");
41077
+ fix("restore config/skills/ or install package assets");
39878
41078
  return false;
39879
41079
  }
39880
- if (!existsSync29(XTRM_DEFAULT_SKILLS_DIR)) {
41080
+ if (!existsSync31(XTRM_DEFAULT_SKILLS_DIR)) {
39881
41081
  fail4(".xtrm/skills/default/ missing");
39882
41082
  fix("specialists init --sync-skills");
39883
41083
  return false;
39884
41084
  }
39885
- const canonicalHashes = collectFileHashes(CONFIG_SKILLS_DIR);
41085
+ const canonicalHashes = collectFileHashes(canonicalSkillsDir);
39886
41086
  const defaultHashes = collectFileHashes(XTRM_DEFAULT_SKILLS_DIR);
39887
41087
  const drifted = [];
39888
41088
  const missingInDefault = [];
@@ -39901,10 +41101,10 @@ function checkSkillDrift() {
39901
41101
  extraInDefault.push(relPath2);
39902
41102
  }
39903
41103
  if (drifted.length === 0 && missingInDefault.length === 0 && extraInDefault.length === 0) {
39904
- ok3("config/skills/ and .xtrm/skills/default/ are in sync");
41104
+ ok3(`${relative4(CWD, canonicalSkillsDir)} and .xtrm/skills/default/ are in sync`);
39905
41105
  } else {
39906
41106
  if (drifted.length > 0) {
39907
- fail4(`${drifted.length} drifted file${drifted.length === 1 ? "" : "s"} between config/skills and .xtrm/skills/default`);
41107
+ fail4(`${drifted.length} drifted file${drifted.length === 1 ? "" : "s"} between ${relative4(CWD, canonicalSkillsDir)} and .xtrm/skills/default`);
39908
41108
  hint(`example: ${drifted.slice(0, 3).join(", ")}${drifted.length > 3 ? ", ..." : ""}`);
39909
41109
  }
39910
41110
  if (missingInDefault.length > 0) {
@@ -39917,39 +41117,47 @@ function checkSkillDrift() {
39917
41117
  }
39918
41118
  fix("specialists init --sync-skills");
39919
41119
  }
41120
+ const defaultSkills = readdirSync16(XTRM_DEFAULT_SKILLS_DIR, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
39920
41121
  let linksOk = true;
39921
- for (const scope of ["claude", "pi"]) {
39922
- const activeRoot = join32(XTRM_ACTIVE_SKILLS_DIR, scope);
39923
- if (!existsSync29(activeRoot)) {
39924
- fail4(`${relative4(CWD, activeRoot)}/ missing`);
39925
- fix("specialists init --sync-skills");
39926
- linksOk = false;
41122
+ for (const skillName of defaultSkills) {
41123
+ const activeLinkPath = join33(XTRM_ACTIVE_SKILLS_DIR, skillName);
41124
+ const expectedTarget = join33(XTRM_DEFAULT_SKILLS_DIR, skillName);
41125
+ const state = isSymlinkTo(activeLinkPath, expectedTarget);
41126
+ if (state.ok)
39927
41127
  continue;
41128
+ linksOk = false;
41129
+ const relLink = relative4(CWD, activeLinkPath);
41130
+ if (state.reason === "missing") {
41131
+ fail4(`${relLink} missing`);
41132
+ } else if (state.reason === "not-symlink") {
41133
+ fail4(`${relLink} is not a symlink`);
41134
+ } else if (state.reason === "wrong-target") {
41135
+ fail4(`${relLink} points to ${state.target ?? "unknown target"}`);
41136
+ } else {
41137
+ fail4(`${relLink} is broken`);
39928
41138
  }
39929
- const defaultSkills = readdirSync14(XTRM_DEFAULT_SKILLS_DIR, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
39930
- for (const skillName of defaultSkills) {
39931
- const activeLinkPath = join32(activeRoot, skillName);
39932
- const expectedTarget = join32(XTRM_DEFAULT_SKILLS_DIR, skillName);
39933
- const state = isSymlinkTo(activeLinkPath, expectedTarget);
39934
- if (state.ok)
39935
- continue;
39936
- linksOk = false;
39937
- const relLink = relative4(CWD, activeLinkPath);
39938
- if (state.reason === "missing") {
39939
- fail4(`${relLink} missing`);
39940
- } else if (state.reason === "not-symlink") {
39941
- fail4(`${relLink} is not a symlink`);
39942
- } else if (state.reason === "wrong-target") {
39943
- fail4(`${relLink} points to ${state.target ?? "unknown target"}`);
39944
- } else {
39945
- fail4(`${relLink} is broken`);
39946
- }
39947
- fix("specialists init --sync-skills");
41139
+ fix("specialists init --sync-skills");
41140
+ }
41141
+ const legacyActiveRoots = [
41142
+ { scope: "claude", root: join33(XTRM_ACTIVE_SKILLS_DIR, "claude") },
41143
+ { scope: "pi", root: join33(XTRM_ACTIVE_SKILLS_DIR, "pi") }
41144
+ ];
41145
+ for (const { root } of legacyActiveRoots) {
41146
+ if (!existsSync31(root))
41147
+ continue;
41148
+ if (isSymlinkTo(root, XTRM_ACTIVE_SKILLS_DIR).ok)
41149
+ continue;
41150
+ const relRoot = relative4(CWD, root);
41151
+ if (lstatSync2(root).isDirectory()) {
41152
+ warn3(`${relRoot}/ legacy scoped layout found`);
41153
+ } else {
41154
+ warn3(`${relRoot} legacy scoped layout found`);
39948
41155
  }
41156
+ fix("specialists init --sync-skills");
39949
41157
  }
39950
41158
  const skillRootChecks = [
39951
- { root: join32(CLAUDE_DIR, "skills"), expected: ACTIVE_CLAUDE_SKILLS_DIR },
39952
- { root: join32(PI_DIR, "skills"), expected: ACTIVE_PI_SKILLS_DIR }
41159
+ { root: join33(CLAUDE_DIR, "skills"), expected: XTRM_ACTIVE_SKILLS_DIR },
41160
+ { root: join33(PI_DIR, "skills"), expected: XTRM_ACTIVE_SKILLS_DIR }
39953
41161
  ];
39954
41162
  let rootLinksOk = true;
39955
41163
  for (const check2 of skillRootChecks) {
@@ -39973,13 +41181,15 @@ function checkSkillDrift() {
39973
41181
  }
39974
41182
  return drifted.length === 0 && missingInDefault.length === 0 && linksOk && rootLinksOk;
39975
41183
  }
39976
- function checkManagedMirror(label, sourceDir, mirrorDir, fixHint) {
39977
- if (!existsSync29(sourceDir)) {
39978
- warn3(`${label} source missing: ${relative4(CWD, sourceDir)}`);
41184
+ function checkManagedMirror(label, canonicalRelativePath, mirrorDir, fixHint) {
41185
+ const sourceDir = resolvePackageAssetDir(canonicalRelativePath);
41186
+ const sourceLabel = sourceDir ? relative4(CWD, sourceDir) : `package canonical ${canonicalRelativePath}`;
41187
+ if (!sourceDir) {
41188
+ warn3(`${label} source missing: package canonical ${canonicalRelativePath}`);
39979
41189
  fix(fixHint);
39980
41190
  return false;
39981
41191
  }
39982
- if (!existsSync29(mirrorDir)) {
41192
+ if (!existsSync31(mirrorDir)) {
39983
41193
  fail4(`${label} mirror missing: ${relative4(CWD, mirrorDir)}`);
39984
41194
  fix(fixHint);
39985
41195
  return false;
@@ -39990,7 +41200,7 @@ function checkManagedMirror(label, sourceDir, mirrorDir, fixHint) {
39990
41200
  const missing = [...sourceHashes.keys()].filter((relPath2) => !mirrorHashes.has(relPath2));
39991
41201
  const extra = [...mirrorHashes.keys()].filter((relPath2) => !sourceHashes.has(relPath2));
39992
41202
  if (drifted.length === 0 && missing.length === 0 && extra.length === 0) {
39993
- ok3(`${label} mirror in sync`);
41203
+ ok3(`${label} mirror in sync against ${sourceLabel}`);
39994
41204
  return true;
39995
41205
  }
39996
41206
  if (drifted.length > 0) {
@@ -40009,33 +41219,33 @@ function checkManagedMirror(label, sourceDir, mirrorDir, fixHint) {
40009
41219
  return false;
40010
41220
  }
40011
41221
  function checkManagedAssetMirrors() {
40012
- section3("Managed mirrors (specialists / mandatory-rules / nodes)");
40013
- const specialistsOk = checkManagedMirror("specialists", CONFIG_SPECIALISTS_DIR, DEFAULT_SPECIALISTS_DIR, "specialists init --sync-defaults");
40014
- const rulesOk = checkManagedMirror("mandatory-rules", CONFIG_MANDATORY_RULES_DIR, join32(DEFAULT_SPECIALISTS_DIR, "mandatory-rules"), "specialists init --sync-defaults");
40015
- const nodesOk = checkManagedMirror("nodes", CONFIG_NODES_DIR, join32(DEFAULT_SPECIALISTS_DIR, "nodes"), "specialists init --sync-defaults");
41222
+ section3("Category B xtrm-managed asset mirrors");
41223
+ const specialistsOk = checkManagedMirror("specialists", "specialists", DEFAULT_SPECIALISTS_DIR, "specialists init --sync-defaults");
41224
+ const rulesOk = checkManagedMirror("mandatory-rules", "mandatory-rules", join33(DEFAULT_SPECIALISTS_DIR, "mandatory-rules"), "specialists init --sync-defaults");
41225
+ const nodesOk = checkManagedMirror("nodes", "nodes", join33(DEFAULT_SPECIALISTS_DIR, "nodes"), "specialists init --sync-defaults");
40016
41226
  return specialistsOk && rulesOk && nodesOk;
40017
41227
  }
40018
41228
  function checkUserOverlayDrift() {
40019
41229
  section3("User specialist overlays");
40020
- if (!existsSync29(USER_SPECIALISTS_DIR)) {
41230
+ if (!existsSync31(USER_SPECIALISTS_DIR)) {
40021
41231
  ok3("no user overlays present");
40022
41232
  return true;
40023
41233
  }
40024
- const overlays = readdirSync14(USER_SPECIALISTS_DIR).filter((name) => name.endsWith(".specialist.json"));
41234
+ const overlays = readdirSync16(USER_SPECIALISTS_DIR).filter((name) => name.endsWith(".specialist.json"));
40025
41235
  if (overlays.length === 0) {
40026
41236
  ok3("no user overlays present");
40027
41237
  return true;
40028
41238
  }
40029
41239
  let allOk = true;
40030
41240
  for (const name of overlays) {
40031
- const userPath = join32(USER_SPECIALISTS_DIR, name);
40032
- const defaultPath = join32(DEFAULT_SPECIALISTS_DIR, name);
41241
+ const userPath = join33(USER_SPECIALISTS_DIR, name);
41242
+ const defaultPath = join33(DEFAULT_SPECIALISTS_DIR, name);
40033
41243
  const userSpec = loadJson2(userPath);
40034
41244
  if (!userSpec) {
40035
41245
  warn3(`${name}: failed to parse \u2014 skipping drift check`);
40036
41246
  continue;
40037
41247
  }
40038
- if (!existsSync29(defaultPath)) {
41248
+ if (!existsSync31(defaultPath)) {
40039
41249
  ok3(`${name}: user-only overlay (no default to drift from)`);
40040
41250
  continue;
40041
41251
  }
@@ -40063,18 +41273,18 @@ function checkUserOverlayDrift() {
40063
41273
  }
40064
41274
  function checkRuntimeDirs() {
40065
41275
  section3(".specialists/ runtime directories");
40066
- const rootDir = join32(CWD, ".specialists");
40067
- const jobsDir = join32(rootDir, "jobs");
40068
- const readyDir = join32(rootDir, "ready");
41276
+ const rootDir = join33(CWD, ".specialists");
41277
+ const jobsDir = join33(rootDir, "jobs");
41278
+ const readyDir = join33(rootDir, "ready");
40069
41279
  let allOk = true;
40070
- if (!existsSync29(rootDir)) {
41280
+ if (!existsSync31(rootDir)) {
40071
41281
  warn3(".specialists/ not found in current project");
40072
41282
  fix("specialists init");
40073
41283
  allOk = false;
40074
41284
  } else {
40075
41285
  ok3(".specialists/ present");
40076
41286
  for (const [subDir, label] of [[jobsDir, "jobs"], [readyDir, "ready"]]) {
40077
- if (!existsSync29(subDir)) {
41287
+ if (!existsSync31(subDir)) {
40078
41288
  warn3(`.specialists/${label}/ missing \u2014 auto-creating`);
40079
41289
  mkdirSync11(subDir, { recursive: true });
40080
41290
  ok3(`.specialists/${label}/ created`);
@@ -40088,8 +41298,8 @@ function checkRuntimeDirs() {
40088
41298
  function checkClaudeMdFragments() {
40089
41299
  section3("CLAUDE.md fragments");
40090
41300
  const projectRoot = process.cwd();
40091
- const claudeMd = join32(projectRoot, "CLAUDE.md");
40092
- if (!existsSync29(claudeMd)) {
41301
+ const claudeMd = join33(projectRoot, "CLAUDE.md");
41302
+ if (!existsSync31(claudeMd)) {
40093
41303
  warn3("No CLAUDE.md in project root \u2014 skipping fragment check");
40094
41304
  return true;
40095
41305
  }
@@ -40215,10 +41425,10 @@ function compareVersions2(left, right) {
40215
41425
  }
40216
41426
  function setStatusError(statusPath) {
40217
41427
  try {
40218
- const raw = readFileSync30(statusPath, "utf8");
41428
+ const raw = readFileSync31(statusPath, "utf8");
40219
41429
  const status = JSON.parse(raw);
40220
41430
  status.status = "error";
40221
- writeFileSync12(statusPath, `${JSON.stringify(status, null, 2)}
41431
+ writeFileSync13(statusPath, `${JSON.stringify(status, null, 2)}
40222
41432
  `, "utf8");
40223
41433
  } catch {}
40224
41434
  }
@@ -40257,7 +41467,7 @@ function cleanupProcesses(jobsDir, dryRun) {
40257
41467
  }
40258
41468
  let entries;
40259
41469
  try {
40260
- entries = readdirSync14(jobsDir);
41470
+ entries = readdirSync16(jobsDir);
40261
41471
  } catch {
40262
41472
  entries = [];
40263
41473
  }
@@ -40269,11 +41479,11 @@ function cleanupProcesses(jobsDir, dryRun) {
40269
41479
  zombieJobIds: []
40270
41480
  };
40271
41481
  for (const jobId of entries) {
40272
- const statusPath = join32(jobsDir, jobId, "status.json");
40273
- if (!existsSync29(statusPath))
41482
+ const statusPath = join33(jobsDir, jobId, "status.json");
41483
+ if (!existsSync31(statusPath))
40274
41484
  continue;
40275
41485
  try {
40276
- const status = JSON.parse(readFileSync30(statusPath, "utf8"));
41486
+ const status = JSON.parse(readFileSync31(statusPath, "utf8"));
40277
41487
  result.total += 1;
40278
41488
  if (status.status !== "running" && status.status !== "starting")
40279
41489
  continue;
@@ -40358,8 +41568,8 @@ function resolveWatchdogMode() {
40358
41568
  function checkZombieJobs() {
40359
41569
  section3("Background jobs");
40360
41570
  hint(`watchdog mode: ${resolveWatchdogMode()}`);
40361
- const jobsDir = join32(CWD, ".specialists", "jobs");
40362
- if (!existsSync29(jobsDir)) {
41571
+ const jobsDir = join33(CWD, ".specialists", "jobs");
41572
+ if (!existsSync31(jobsDir)) {
40363
41573
  hint("No .specialists/jobs/ \u2014 skipping");
40364
41574
  return true;
40365
41575
  }
@@ -40369,7 +41579,7 @@ function checkZombieJobs() {
40369
41579
  return true;
40370
41580
  }
40371
41581
  for (const jobId of result.zombieJobIds) {
40372
- warn3(`${jobId} ${yellow12("ZOMBIE")} ${dim13("pid not found for running job")}`);
41582
+ warn3(`${jobId} ${yellow12("ZOMBIE")} ${dim14("pid not found for running job")}`);
40373
41583
  fix(`Edit .specialists/jobs/${jobId}/status.json \u2192 set "status": "error"`);
40374
41584
  }
40375
41585
  if (result.zombies === 0) {
@@ -40377,7 +41587,7 @@ function checkZombieJobs() {
40377
41587
  }
40378
41588
  return result.zombies === 0;
40379
41589
  }
40380
- async function run28(argv = process.argv.slice(3)) {
41590
+ async function run29(argv = process.argv.slice(3)) {
40381
41591
  const subcommand = argv[0];
40382
41592
  if (subcommand === "orphans") {
40383
41593
  runDoctorOrphans();
@@ -40411,37 +41621,32 @@ ${bold13("specialists doctor")}
40411
41621
  const allOk = piOk && spOk && bdOk && xtOk && hooksOk && mcpOk && versionOk && skillDriftOk && mirrorOk && userOverlayOk && dirsOk && jobsOk && fragmentsOk;
40412
41622
  console.log("");
40413
41623
  if (allOk) {
40414
- console.log(` ${green14("\u2713")} ${bold13("All checks passed")} \u2014 specialists is healthy`);
41624
+ console.log(` ${green15("\u2713")} ${bold13("All checks passed")} \u2014 specialists is healthy`);
40415
41625
  } else {
40416
41626
  console.log(` ${yellow12("\u25CB")} ${bold13("Some checks failed")} \u2014 follow the fix hints above`);
40417
- console.log(` ${dim13("specialists init fixes hook + MCP registration; specialists init --sync-skills fixes skill drift/symlink issues; specialists init --sync-defaults fixes managed mirrors.")}`);
41627
+ console.log(` ${dim14("specialists init fixes hook + MCP registration; specialists init --sync-skills fixes skill drift/symlink issues; specialists init --sync-defaults fixes managed mirrors.")}`);
40418
41628
  }
40419
41629
  console.log("");
40420
41630
  }
40421
- var bold13 = (s) => `\x1B[1m${s}\x1B[0m`, dim13 = (s) => `\x1B[2m${s}\x1B[0m`, green14 = (s) => `\x1B[32m${s}\x1B[0m`, yellow12 = (s) => `\x1B[33m${s}\x1B[0m`, red7 = (s) => `\x1B[31m${s}\x1B[0m`, CWD, CLAUDE_DIR, PI_DIR, XTRM_SKILLS_DIR, XTRM_DEFAULT_SKILLS_DIR, XTRM_ACTIVE_SKILLS_DIR, ACTIVE_CLAUDE_SKILLS_DIR, ACTIVE_PI_SKILLS_DIR, CONFIG_SKILLS_DIR, CONFIG_SPECIALISTS_DIR, CONFIG_MANDATORY_RULES_DIR, CONFIG_NODES_DIR, SPECIALISTS_DIR, DEFAULT_SPECIALISTS_DIR, USER_SPECIALISTS_DIR, HOOKS_DIR, CLAUDE_HOOKS_DIR, SETTINGS_FILE, MCP_FILE2, HOOK_NAMES;
41631
+ var bold13 = (s) => `\x1B[1m${s}\x1B[0m`, dim14 = (s) => `\x1B[2m${s}\x1B[0m`, green15 = (s) => `\x1B[32m${s}\x1B[0m`, yellow12 = (s) => `\x1B[33m${s}\x1B[0m`, red8 = (s) => `\x1B[31m${s}\x1B[0m`, CWD, CLAUDE_DIR, PI_DIR, XTRM_SKILLS_DIR, XTRM_DEFAULT_SKILLS_DIR, XTRM_ACTIVE_SKILLS_DIR, SPECIALISTS_DIR, DEFAULT_SPECIALISTS_DIR, USER_SPECIALISTS_DIR, HOOKS_DIR, CLAUDE_HOOKS_DIR, SETTINGS_FILE, MCP_FILE2, HOOK_NAMES;
40422
41632
  var init_doctor = __esm(() => {
40423
41633
  init_observability_sqlite();
41634
+ init_canonical_asset_resolver();
40424
41635
  init_drift_detector();
40425
41636
  init_version_check();
40426
41637
  CWD = process.cwd();
40427
- CLAUDE_DIR = join32(CWD, ".claude");
40428
- PI_DIR = join32(CWD, ".pi");
40429
- XTRM_SKILLS_DIR = join32(CWD, ".xtrm", "skills");
40430
- XTRM_DEFAULT_SKILLS_DIR = join32(XTRM_SKILLS_DIR, "default");
40431
- XTRM_ACTIVE_SKILLS_DIR = join32(XTRM_SKILLS_DIR, "active");
40432
- ACTIVE_CLAUDE_SKILLS_DIR = join32(XTRM_ACTIVE_SKILLS_DIR, "claude");
40433
- ACTIVE_PI_SKILLS_DIR = join32(XTRM_ACTIVE_SKILLS_DIR, "pi");
40434
- CONFIG_SKILLS_DIR = join32(CWD, "config", "skills");
40435
- CONFIG_SPECIALISTS_DIR = join32(CWD, "config", "specialists");
40436
- CONFIG_MANDATORY_RULES_DIR = join32(CWD, "config", "mandatory-rules");
40437
- CONFIG_NODES_DIR = join32(CWD, "config", "nodes");
40438
- SPECIALISTS_DIR = join32(CWD, ".specialists");
40439
- DEFAULT_SPECIALISTS_DIR = join32(SPECIALISTS_DIR, "default");
40440
- USER_SPECIALISTS_DIR = join32(SPECIALISTS_DIR, "user");
40441
- HOOKS_DIR = join32(CWD, ".xtrm", "hooks", "specialists");
40442
- CLAUDE_HOOKS_DIR = join32(CLAUDE_DIR, "hooks");
40443
- SETTINGS_FILE = join32(CLAUDE_DIR, "settings.json");
40444
- MCP_FILE2 = join32(CWD, ".mcp.json");
41638
+ CLAUDE_DIR = join33(CWD, ".claude");
41639
+ PI_DIR = join33(CWD, ".pi");
41640
+ XTRM_SKILLS_DIR = join33(CWD, ".xtrm", "skills");
41641
+ XTRM_DEFAULT_SKILLS_DIR = join33(XTRM_SKILLS_DIR, "default");
41642
+ XTRM_ACTIVE_SKILLS_DIR = join33(XTRM_SKILLS_DIR, "active");
41643
+ SPECIALISTS_DIR = join33(CWD, ".specialists");
41644
+ DEFAULT_SPECIALISTS_DIR = join33(SPECIALISTS_DIR, "default");
41645
+ USER_SPECIALISTS_DIR = join33(SPECIALISTS_DIR, "user");
41646
+ HOOKS_DIR = join33(CWD, ".xtrm", "hooks", "specialists");
41647
+ CLAUDE_HOOKS_DIR = join33(CLAUDE_DIR, "hooks");
41648
+ SETTINGS_FILE = join33(CLAUDE_DIR, "settings.json");
41649
+ MCP_FILE2 = join33(CWD, ".mcp.json");
40445
41650
  HOOK_NAMES = [
40446
41651
  "specialists-complete.mjs",
40447
41652
  "specialists-session-start.mjs"
@@ -40451,9 +41656,9 @@ var init_doctor = __esm(() => {
40451
41656
  // src/cli/setup.ts
40452
41657
  var exports_setup = {};
40453
41658
  __export(exports_setup, {
40454
- run: () => run29
41659
+ run: () => run30
40455
41660
  });
40456
- async function run29() {
41661
+ async function run30() {
40457
41662
  console.log("");
40458
41663
  console.log(yellow13("\u26A0 DEPRECATED: `specialists setup` is deprecated"));
40459
41664
  console.log("");
@@ -40468,26 +41673,26 @@ async function run29() {
40468
41673
  console.log(" Options:");
40469
41674
  console.log(" --force-workflow Overwrite existing workflow blocks");
40470
41675
  console.log("");
40471
- console.log(` ${dim14("Run: specialists init --help for full details")}`);
41676
+ console.log(` ${dim15("Run: specialists init --help for full details")}`);
40472
41677
  console.log("");
40473
41678
  }
40474
- var bold14 = (s) => `\x1B[1m${s}\x1B[0m`, yellow13 = (s) => `\x1B[33m${s}\x1B[0m`, dim14 = (s) => `\x1B[2m${s}\x1B[0m`;
41679
+ var bold14 = (s) => `\x1B[1m${s}\x1B[0m`, yellow13 = (s) => `\x1B[33m${s}\x1B[0m`, dim15 = (s) => `\x1B[2m${s}\x1B[0m`;
40475
41680
 
40476
41681
  // src/cli/serve-hot-reload.ts
40477
- import { existsSync as existsSync30, readdirSync as readdirSync15, statSync as statSync5, watch as fsWatch } from "fs";
40478
- import { join as join33 } from "path";
41682
+ import { existsSync as existsSync32, readdirSync as readdirSync17, statSync as statSync6, watch as fsWatch } from "fs";
41683
+ import { join as join34 } from "path";
40479
41684
  function specialistNameFromFile(file) {
40480
41685
  const match = file.match(/^(.+)\.specialist\.(json|yaml)$/);
40481
41686
  return match ? match[1] : null;
40482
41687
  }
40483
41688
  function snapshotMtimes(dir) {
40484
41689
  const out = new Map;
40485
- if (!existsSync30(dir))
41690
+ if (!existsSync32(dir))
40486
41691
  return out;
40487
- const entries = readdirSync15(dir).filter((name) => specialistNameFromFile(name) !== null);
41692
+ const entries = readdirSync17(dir).filter((name) => specialistNameFromFile(name) !== null);
40488
41693
  for (const name of entries) {
40489
41694
  try {
40490
- out.set(name, statSync5(join33(dir, name)).mtimeMs);
41695
+ out.set(name, statSync6(join34(dir, name)).mtimeMs);
40491
41696
  } catch {}
40492
41697
  }
40493
41698
  return out;
@@ -40544,7 +41749,7 @@ function createUserDirWatcher(opts) {
40544
41749
  for (const file of changed)
40545
41750
  queue(file);
40546
41751
  }, opts.pollMs);
40547
- } else if (existsSync30(opts.userDir)) {
41752
+ } else if (existsSync32(opts.userDir)) {
40548
41753
  try {
40549
41754
  watcher = fsWatch(opts.userDir, { persistent: false }, (_eventType, filename) => {
40550
41755
  queue(filename ? String(filename) : null);
@@ -40576,7 +41781,7 @@ var init_serve_hot_reload = () => {};
40576
41781
  var exports_serve = {};
40577
41782
  __export(exports_serve, {
40578
41783
  startServe: () => startServe,
40579
- run: () => run30,
41784
+ run: () => run31,
40580
41785
  recordAuditFailure: () => recordAuditFailure,
40581
41786
  evaluateReadiness: () => evaluateReadiness2,
40582
41787
  createReadinessState: () => createReadinessState,
@@ -40587,9 +41792,9 @@ import { randomUUID as randomUUID3 } from "crypto";
40587
41792
  import { once } from "events";
40588
41793
  import { spawnSync as spawnSync23 } from "child_process";
40589
41794
  import { access, readdir as readdir2, readFile as readFile5, constants } from "fs/promises";
40590
- import { existsSync as existsSync31 } from "fs";
41795
+ import { existsSync as existsSync33 } from "fs";
40591
41796
  import { homedir as homedir3 } from "os";
40592
- import { join as join34 } from "path";
41797
+ import { join as join35 } from "path";
40593
41798
  function createReadinessState() {
40594
41799
  return { shuttingDown: false, auditFailures: [], dbWriteFailuresTotal: 0 };
40595
41800
  }
@@ -40605,7 +41810,7 @@ function pruneAuditFailures(state, now = Date.now()) {
40605
41810
  }
40606
41811
  }
40607
41812
  async function checkUserDirSpecs(userDir) {
40608
- if (!existsSync31(userDir))
41813
+ if (!existsSync33(userDir))
40609
41814
  return "empty";
40610
41815
  const entries = await readdir2(userDir).catch(() => []);
40611
41816
  const specFiles = entries.filter((name) => name.endsWith(".specialist.json") || name.endsWith(".specialist.yaml"));
@@ -40614,7 +41819,7 @@ async function checkUserDirSpecs(userDir) {
40614
41819
  let validCount = 0;
40615
41820
  for (const file of specFiles) {
40616
41821
  try {
40617
- const content = await readFile5(join34(userDir, file), "utf-8");
41822
+ const content = await readFile5(join35(userDir, file), "utf-8");
40618
41823
  const json = file.endsWith(".json") ? content : null;
40619
41824
  if (!json)
40620
41825
  continue;
@@ -40632,7 +41837,7 @@ async function evaluateReadiness2(opts) {
40632
41837
  if (opts.state.auditFailures.length > opts.auditFailureThreshold) {
40633
41838
  return { ready: false, reason: "degraded:audit" };
40634
41839
  }
40635
- const piConfigPath = opts.piConfigPath ?? join34(homedir3(), ".pi", "agent", "auth.json");
41840
+ const piConfigPath = opts.piConfigPath ?? join35(homedir3(), ".pi", "agent", "auth.json");
40636
41841
  try {
40637
41842
  await access(piConfigPath, constants.R_OK);
40638
41843
  } catch {
@@ -40653,7 +41858,7 @@ async function evaluateReadiness2(opts) {
40653
41858
  warning = canaryFailure;
40654
41859
  }
40655
41860
  }
40656
- const userDir = join34(opts.projectDir, ".specialists", "user");
41861
+ const userDir = join35(opts.projectDir, ".specialists", "user");
40657
41862
  const userDirResult = await checkUserDirSpecs(userDir);
40658
41863
  if (userDirResult === "empty")
40659
41864
  return { ready: false, reason: "empty_user_dir" };
@@ -40774,7 +41979,7 @@ async function startServe(argv = process.argv.slice(3)) {
40774
41979
  return createObservabilitySqliteClient(args.projectDir);
40775
41980
  })();
40776
41981
  const readinessState = createReadinessState();
40777
- const userDir = join34(args.projectDir, ".specialists", "user");
41982
+ const userDir = join35(args.projectDir, ".specialists", "user");
40778
41983
  const hotReload = createUserDirWatcher({ loader, userDir, pollMs: args.reloadPollMs });
40779
41984
  let active = 0;
40780
41985
  const children = new Set;
@@ -40939,7 +42144,7 @@ async function startServe(argv = process.argv.slice(3)) {
40939
42144
  console.log(`sp serve listening on ${args.port}`);
40940
42145
  return { server, args, db, readinessState };
40941
42146
  }
40942
- async function run30(argv = process.argv.slice(3)) {
42147
+ async function run31(argv = process.argv.slice(3)) {
40943
42148
  await startServe(argv);
40944
42149
  }
40945
42150
  var AUDIT_WINDOW_MS = 60000, DEFAULT_REQUIRED_PI_FLAGS;
@@ -40957,7 +42162,7 @@ var init_serve = __esm(() => {
40957
42162
  var exports_script = {};
40958
42163
  __export(exports_script, {
40959
42164
  scriptCli: () => scriptCli,
40960
- run: () => run31,
42165
+ run: () => run32,
40961
42166
  parseArgs: () => parseArgs13,
40962
42167
  mapExitCode: () => mapExitCode
40963
42168
  });
@@ -41074,7 +42279,7 @@ function runUnderLock(lockPath, argv) {
41074
42279
  return 75;
41075
42280
  return flock.status ?? 1;
41076
42281
  }
41077
- async function run31(argv = process.argv.slice(3)) {
42282
+ async function run32(argv = process.argv.slice(3)) {
41078
42283
  const args = parseArgs13(argv);
41079
42284
  if (args.singleInstance && !process.env.SP_SCRIPT_NO_LOCK) {
41080
42285
  process.exit(runUnderLock(args.singleInstance, argv));
@@ -41098,13 +42303,13 @@ var init_script = __esm(() => {
41098
42303
  // src/cli/help.ts
41099
42304
  var exports_help = {};
41100
42305
  __export(exports_help, {
41101
- run: () => run32
42306
+ run: () => run33
41102
42307
  });
41103
42308
  function formatCommands(entries) {
41104
42309
  const width = Math.max(...entries.map(([cmd3]) => cmd3.length));
41105
42310
  return entries.map(([cmd3, desc]) => ` ${cmd3.padEnd(width)} ${desc}`);
41106
42311
  }
41107
- async function run32() {
42312
+ async function run33() {
41108
42313
  const lines = [
41109
42314
  "",
41110
42315
  "Specialists lets you run project-scoped specialist agents with a bead-first workflow.",
@@ -41113,7 +42318,7 @@ async function run32() {
41113
42318
  " specialists|sp [command]",
41114
42319
  " specialists|sp [command] --help",
41115
42320
  "",
41116
- dim15(" sp is a shorter alias \u2014 sp run, sp list, sp feed etc. all work identically."),
42321
+ dim16(" sp is a shorter alias \u2014 sp run, sp list, sp feed etc. all work identically."),
41117
42322
  "",
41118
42323
  bold15("Common flows:"),
41119
42324
  "",
@@ -41195,13 +42400,13 @@ async function run32() {
41195
42400
  " specialists init --help Bootstrap behavior and workflow injection",
41196
42401
  " specialists feed --help Job event streaming details",
41197
42402
  "",
41198
- dim15("Project model: specialists are project-only; user-scope discovery is deprecated."),
42403
+ dim16("Project model: specialists are project-only; user-scope discovery is deprecated."),
41199
42404
  ""
41200
42405
  ];
41201
42406
  console.log(lines.join(`
41202
42407
  `));
41203
42408
  }
41204
- var bold15 = (s) => `\x1B[1m${s}\x1B[0m`, dim15 = (s) => `\x1B[2m${s}\x1B[0m`, CORE_COMMANDS, EXTENDED_COMMANDS, WORKTREE_COMMANDS;
42409
+ var bold15 = (s) => `\x1B[1m${s}\x1B[0m`, dim16 = (s) => `\x1B[2m${s}\x1B[0m`, CORE_COMMANDS, EXTENDED_COMMANDS, WORKTREE_COMMANDS;
41205
42410
  var init_help = __esm(() => {
41206
42411
  CORE_COMMANDS = [
41207
42412
  ["init", "Bootstrap a project: dirs, workflow injection, project MCP registration"],
@@ -41218,7 +42423,7 @@ var init_help = __esm(() => {
41218
42423
  ["epic", "Epic lifecycle management: list/status/resolve wave-bound chain groups"],
41219
42424
  ["feed", "Tail job events; use -f to follow all jobs"],
41220
42425
  ["result", "Print final output of a completed job; --wait polls until done, --timeout <ms> sets a limit"],
41221
- ["clean", "Purge completed job directories (TTL, --all, --keep, --dry-run)"],
42426
+ ["clean", "Clean job dirs, dashboard history (--ps), and orphan processes"],
41222
42427
  ["merge", "Publish one standalone chain (refuses unresolved epic chains)"],
41223
42428
  ["end", "Session-close publish helper; chain-aware and epic-aware with optional --pr mode"],
41224
42429
  ["epic list", "Enumerate epics with lifecycle state and merge readiness summary"],
@@ -41231,7 +42436,7 @@ var init_help = __esm(() => {
41231
42436
  ["attach", "Attach terminal to a running background job tmux session"],
41232
42437
  ["report", "Generate/show/list/diff session reports in .xtrm/reports/"],
41233
42438
  ["status", "Show health, MCP state, and active jobs"],
41234
- ["ps", "Show urgency-sorted worktree view with ctx% and NEXT action; --json, --all, --follow, --running, --bead <id>, --since <dur>, --mine, --include-terminal"],
42439
+ ["ps", "Show actionable dashboard (active + unresolved terminal problems); --json, --all, --follow, --active, --health, --include-terminal, --include-cleaned"],
41235
42440
  ["doctor", "Diagnose installation/runtime problems; --check-drift reports stale .specialists/default/ snapshots"],
41236
42441
  ["prune-stale-defaults", "Prune redundant .specialists/default/ snapshots (byte-identical to package canonical); --dry-run, --root <path>"],
41237
42442
  ["quickstart", "Full getting-started guide"],
@@ -48756,12 +49961,21 @@ process.on("uncaughtException", (err) => {
48756
49961
  console.error("[specialists] [ERROR] Fatal error:", err);
48757
49962
  process.exit(1);
48758
49963
  });
49964
+ if (typeof globalThis.Bun === "undefined") {
49965
+ console.error([
49966
+ "[specialists] [ERROR] Bun runtime required (>=1.0.0).",
49967
+ "[specialists] Install Bun: https://bun.sh/install",
49968
+ "[specialists] Example: curl -fsSL https://bun.sh/install | bash"
49969
+ ].join(`
49970
+ `));
49971
+ process.exit(1);
49972
+ }
48759
49973
  var sub = process.argv[2];
48760
49974
  var next = process.argv[3];
48761
49975
  function wantsHelp() {
48762
49976
  return next === "--help" || next === "-h";
48763
49977
  }
48764
- async function run33() {
49978
+ async function run34() {
48765
49979
  if (sub === "install") {
48766
49980
  if (wantsHelp()) {
48767
49981
  console.log([
@@ -48893,6 +50107,12 @@ async function run33() {
48893
50107
  " \u2022 installs hooks to .claude/hooks/ and wires .claude/settings.json",
48894
50108
  " \u2022 syncs skills into .xtrm/skills/default/ and wires active symlinks",
48895
50109
  "",
50110
+ "Prereq:",
50111
+ " Bun >=1.0.0 required before xtrm-tools setup.",
50112
+ " Install order: Bun -> xtrm-tools -> xt install -> xt init ->",
50113
+ " @jaggerxtrm/specialists -> sp init.",
50114
+ " sp list, sp doctor --check-drift, sp prune-stale-defaults do not require xt.",
50115
+ "",
48896
50116
  "Options:",
48897
50117
  " --sync-defaults Also copy canonical specialists to .specialists/default/.",
48898
50118
  " Human-only: rewrites default specialist YAML files.",
@@ -49207,31 +50427,29 @@ async function run33() {
49207
50427
  if (wantsHelp()) {
49208
50428
  console.log([
49209
50429
  "",
49210
- "Usage: specialists epic <list|status|resolve|merge> [options]",
50430
+ "Usage: specialists epic <list|status|sync|abandon|merge> [options]",
49211
50431
  "",
49212
50432
  "Epic lifecycle management for wave-bound chain groups.",
49213
50433
  "",
49214
50434
  "Commands:",
49215
- " list [--unresolved] [--json] Enumerate epics with lifecycle state and readiness",
49216
- " status <epic-id> [--json] Show chains, blockers, and merge readiness",
49217
- " resolve <epic-id> [--dry-run] [--json] Transition epic from open -> resolving",
50435
+ " list [--unresolved] [--json] Enumerate epics with readiness",
50436
+ " status <epic-id> [--json] Show derived readiness and chain status",
50437
+ " sync <epic-id> [--apply] [--json] Reconcile epic drift (dry-run by default)",
50438
+ " abandon <epic-id> --reason <text> [--force] [--json] Transition epic to abandoned",
49218
50439
  " merge <epic-id> [--rebuild] [--pr] [--json] Publish epic chains (direct merge or PR mode)",
49219
50440
  "",
49220
50441
  "Options:",
49221
- " --unresolved Filter list to non-terminal (open, resolving, merge_ready) epics",
49222
- " --dry-run Preview transition without persisting",
50442
+ " --unresolved Filter list to open epics only",
49223
50443
  " --json Machine-readable JSON output",
49224
50444
  "",
49225
- "Lifecycle states:",
49226
- " open -> resolving -> merge_ready -> merged",
49227
- " (failed, abandoned are terminal)",
50445
+ "Readiness:",
50446
+ " status is derived from live chain readiness",
50447
+ " persisted lifecycle state is compatibility metadata only",
49228
50448
  "",
49229
50449
  "Examples:",
49230
50450
  " specialists epic list",
49231
50451
  " specialists epic list --unresolved",
49232
50452
  " specialists epic status unitAI-epic1",
49233
- " specialists epic resolve unitAI-epic1",
49234
- " specialists epic resolve unitAI-epic1 --dry-run",
49235
50453
  " specialists epic merge unitAI-epic1 --pr",
49236
50454
  ""
49237
50455
  ].join(`
@@ -49277,14 +50495,18 @@ async function run33() {
49277
50495
  "",
49278
50496
  "Usage: specialists ps [options]",
49279
50497
  "",
49280
- "Process dashboard \u2014 shows active specialist jobs grouped by worktree chain.",
49281
- "Dead jobs (PID gone) are filtered by default. Includes context%, bead title,",
49282
- "and next-action hints on every row.",
50498
+ "Process dashboard \u2014 shows active jobs plus unresolved terminal problems.",
50499
+ "Cleaned dashboard history and dead jobs are filtered by default. Includes",
50500
+ "context%, bead title, and next-action hints on every row.",
49283
50501
  "",
49284
50502
  "Options:",
49285
- " --json Structured JSON output with trees[].children[] schema",
49286
- " --all Include terminal (done/error) and dead jobs",
49287
- " --follow, -f Live-refresh view with spinner animation",
50503
+ " --json Structured JSON output with trees[].children[] schema",
50504
+ " --all Include every row, including cleaned/dead/terminal history",
50505
+ " --follow, -f Live-refresh view with spinner animation",
50506
+ " --health Show detailed process health tables (default is aggregate only)",
50507
+ " --active Show active jobs only; hide unresolved terminal problems",
50508
+ " --include-terminal Include terminal history that has not been cleaned",
50509
+ " --include-cleaned Include rows hidden by sp clean --ps",
49288
50510
  "",
49289
50511
  "Output columns:",
49290
50512
  " st Status icon: \u25C9 running, \u25D0 waiting/starting, \u25CB done/error",
@@ -49302,9 +50524,17 @@ async function run33() {
49302
50524
  "",
49303
50525
  "Node refs accept any unique prefix.",
49304
50526
  "",
50527
+ "Dashboard cleanup:",
50528
+ " sp clean --ps --dry-run previews terminal rows to hide from default ps.",
50529
+ " sp clean --ps hides terminal rows without deleting DB history or changing status.",
50530
+ " Use --include-cleaned or --all to audit cleaned rows later.",
50531
+ "",
49305
50532
  "Examples:",
49306
- " specialists ps Active jobs only (dead filtered out)",
49307
- " specialists ps --all All jobs including dead and terminal",
50533
+ " specialists ps Active + unresolved terminal problems",
50534
+ " specialists ps --active Active jobs only",
50535
+ " specialists ps --include-terminal Include uncleaned terminal history",
50536
+ " specialists ps --include-cleaned Show rows hidden by sp clean --ps",
50537
+ " specialists ps --all Full audit view including cleaned/dead/history",
49308
50538
  " specialists ps --json Machine-readable tree output",
49309
50539
  " specialists ps --node research Filter dashboard to one node run",
49310
50540
  " specialists ps --follow Live dashboard with auto-refresh",
@@ -49465,24 +50695,52 @@ async function run33() {
49465
50695
  console.log([
49466
50696
  "",
49467
50697
  "Usage: specialists clean [--all] [--keep <n>] [--dry-run]",
50698
+ " specialists clean --ps [--dry-run]",
50699
+ " specialists clean --reap-orphans [--dry-run]",
50700
+ " specialists clean --observability --before <iso|duration> [--include-epics] [--dry-run]",
49468
50701
  "",
49469
- "Purge completed job directories from .specialists/jobs/.",
50702
+ "Clean specialist runtime artifacts and dashboard visibility.",
49470
50703
  "",
49471
50704
  "Default behavior:",
49472
- " - removes done/error jobs older than SPECIALISTS_JOB_TTL_DAYS",
50705
+ " - removes done/error job directories older than SPECIALISTS_JOB_TTL_DAYS",
49473
50706
  " - TTL defaults to 7 days if env is unset",
49474
50707
  " - never removes SQLite artifacts (*.db, *.db-wal, *.db-shm)",
50708
+ " - never prunes observability.db rows unless --observability is explicit",
50709
+ "",
50710
+ "Dashboard cleanup:",
50711
+ " - --ps soft-hides terminal rows from default sp ps",
50712
+ " - --ps does not delete SQLite rows or change job status",
50713
+ " - sp ps --include-cleaned / --all restore audit visibility",
50714
+ "",
50715
+ "Observability cleanup:",
50716
+ " - --observability prunes terminal SQLite rows via the DB prune path",
50717
+ " - requires --before <iso|duration>; examples: 30d, 2026-01-01T00:00:00Z",
50718
+ " - preserves active/waiting/running jobs and memories_* tables",
49475
50719
  "",
49476
50720
  "Options:",
49477
- " --all Remove all done/error jobs regardless of age",
49478
- " --keep <n> Keep only the N most recent done/error jobs",
49479
- " --dry-run Show what would be removed without deleting",
50721
+ " --all Remove all done/error job directories regardless of age",
50722
+ " --keep <n> Keep only the N most recent done/error job directories",
50723
+ " --dry-run Preview filesystem, dashboard, or process cleanup",
50724
+ " --ps Hide terminal rows from default ps without deleting DB history",
50725
+ " --observability Prune terminal observability.db rows (requires --before)",
50726
+ " --before <value> Cutoff for --observability (ISO date or duration like 30d)",
50727
+ " --include-epics Also prune eligible terminal epic_runs with --observability",
50728
+ " --reap-orphans Reap orphan/stale leaked tool processes; detects",
50729
+ " dead-pid (PID gone), orphaned-keep-alive (PID alive,",
50730
+ " ppid=1, status=waiting), dead-toolchain (PID alive but",
50731
+ " no tool/think events in last 30min); all require 30min",
50732
+ " min-age threshold",
49480
50733
  "",
49481
50734
  "Examples:",
49482
50735
  " specialists clean",
49483
50736
  " specialists clean --all",
49484
50737
  " specialists clean --keep 20",
49485
50738
  " specialists clean --dry-run",
50739
+ " specialists clean --ps --dry-run",
50740
+ " specialists clean --ps",
50741
+ " specialists clean --observability --before 30d --dry-run",
50742
+ " specialists clean --observability --before 30d",
50743
+ " specialists clean --reap-orphans --dry-run",
49486
50744
  ""
49487
50745
  ].join(`
49488
50746
  `));
@@ -49495,7 +50753,7 @@ async function run33() {
49495
50753
  if (wantsHelp()) {
49496
50754
  console.log([
49497
50755
  "",
49498
- "Usage: specialists merge <target-bead-id> [--rebuild]",
50756
+ "Usage: specialists merge <target-bead-id> [--target-branch <name>] [--rebuild]",
49499
50757
  "",
49500
50758
  "Publish a chain root bead branch. Epic publication belongs to `sp epic merge`.",
49501
50759
  "",
@@ -49504,6 +50762,9 @@ async function run33() {
49504
50762
  " - unresolved epic member: refuses and points to `sp epic merge <epic-id>`",
49505
50763
  " - runs `bunx tsc --noEmit` after each merge and stops on failure",
49506
50764
  " - stops on first merge conflict and reports conflicting files",
50765
+ " - --target-branch overrides origin/HEAD as rebase target; useful for",
50766
+ " chains forked from non-main branches",
50767
+ " - ignores dirty .beads/issues.jsonl and .xtrm/skills/active/**",
49507
50768
  " - NOTE: for epic publication with lifecycle management, use `sp epic merge`",
49508
50769
  "",
49509
50770
  "Options:",
@@ -49574,6 +50835,28 @@ async function run33() {
49574
50835
  const { run: handler } = await Promise.resolve().then(() => (init_stop(), exports_stop));
49575
50836
  return handler();
49576
50837
  }
50838
+ if (sub === "finalize") {
50839
+ if (wantsHelp()) {
50840
+ console.log([
50841
+ "",
50842
+ "Usage: specialists finalize <job-id>",
50843
+ "",
50844
+ "Finalize waiting keep-alive job after reviewer PASS.",
50845
+ "Reads SQLite-first compliance verdict from observability.db specialist_results.",
50846
+ "Falls back to result.txt when SPECIALISTS_JOB_FILE_OUTPUT=on.",
50847
+ "Accepts any chain member job-id and finalizes full keep-alive chain.",
50848
+ "Refuses non-waiting or non-PASS jobs.",
50849
+ "",
50850
+ "Examples:",
50851
+ " specialists finalize job_a1b2c3d4",
50852
+ ""
50853
+ ].join(`
50854
+ `));
50855
+ return;
50856
+ }
50857
+ const { run: handler } = await Promise.resolve().then(() => (init_finalize(), exports_finalize));
50858
+ return handler();
50859
+ }
49577
50860
  if (sub === "attach") {
49578
50861
  if (wantsHelp()) {
49579
50862
  process.stdout.write([
@@ -49635,7 +50918,10 @@ async function run33() {
49635
50918
  "",
49636
50919
  "Subcommands:",
49637
50920
  " orphans Read-only orphan scan: membership/jobs/epics/worktree pointers",
49638
- " --check-drift, --drift Report stale .specialists/default/ snapshots vs package canonical",
50921
+ " --check-drift, --drift Compare .specialists/default/ snapshots against package canonical",
50922
+ " Category A (specialists runtime) and Category B",
50923
+ " (filesystem skills/hooks) are distinct; doctor",
50924
+ " covers Category A only",
49639
50925
  "",
49640
50926
  "Examples:",
49641
50927
  " specialists doctor",
@@ -49667,7 +50953,7 @@ async function run33() {
49667
50953
  if (wantsHelp()) {
49668
50954
  console.log([
49669
50955
  "",
49670
- "Usage: specialists serve [--port <n>] [--concurrency <n>] [--shutdown-grace-ms <n>] [--project-dir <path>] [--db-path <observability.db>] [--readiness-canary off|warn|require] [--log-level off|info|debug]",
50956
+ "Usage: specialists serve [--port <n>] [--concurrency <n>] [--shutdown-grace-ms <n>] [--project-dir <path>]",
49671
50957
  "",
49672
50958
  "HTTP wrapper for script-class specialists.",
49673
50959
  "",
@@ -49723,7 +51009,7 @@ Run 'specialists help' to see available commands.`);
49723
51009
  const server = new SpecialistsServer;
49724
51010
  await server.start();
49725
51011
  }
49726
- run33().catch((error2) => {
51012
+ run34().catch((error2) => {
49727
51013
  logger.error(`Fatal error: ${error2}`);
49728
51014
  process.exit(1);
49729
51015
  });