@jaggerxtrm/specialists 3.6.10 → 3.6.12

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.
package/dist/index.js CHANGED
@@ -17907,6 +17907,49 @@ function findTokenUsage(payload) {
17907
17907
  }
17908
17908
  return normalizeTokenUsage(record3);
17909
17909
  }
17910
+ function findApiErrorMessage(payload) {
17911
+ if (!payload || typeof payload !== "object")
17912
+ return;
17913
+ const record3 = payload;
17914
+ const direct = [record3.errorMessage, record3.error_message, record3.error, record3.message].find((value) => typeof value === "string" && value.trim().length > 0);
17915
+ if (typeof direct === "string")
17916
+ return direct.trim();
17917
+ const nestedError = record3.error;
17918
+ if (nestedError && typeof nestedError === "object") {
17919
+ const nested = nestedError;
17920
+ const nestedMessage = [nested.message, nested.errorMessage, nested.error_message].find((value) => typeof value === "string" && value.trim().length > 0);
17921
+ if (typeof nestedMessage === "string")
17922
+ return nestedMessage.trim();
17923
+ }
17924
+ const message = record3.assistantMessageEvent;
17925
+ if (message && typeof message === "object") {
17926
+ const nested = message;
17927
+ const nestedMessage = [nested.errorMessage, nested.error_message, nested.error, nested.message].find((value) => typeof value === "string" && value.trim().length > 0);
17928
+ if (typeof nestedMessage === "string")
17929
+ return nestedMessage.trim();
17930
+ }
17931
+ return;
17932
+ }
17933
+ function extractApiErrorFromStderr(stderr) {
17934
+ const compact = stderr.trim();
17935
+ if (!compact)
17936
+ return;
17937
+ const patterns = [
17938
+ /You have hit your ChatGPT usage limit[^\n]*/i,
17939
+ /rate limit[^\n]*/i,
17940
+ /quota[^\n]*/i,
17941
+ /auth(?:entication)?[^\n]*/i,
17942
+ /unauthori[sz]ed[^\n]*/i,
17943
+ /forbidden[^\n]*/i,
17944
+ /overloaded[^\n]*/i
17945
+ ];
17946
+ for (const pattern of patterns) {
17947
+ const match = compact.match(pattern);
17948
+ if (match)
17949
+ return match[0].trim();
17950
+ }
17951
+ return;
17952
+ }
17910
17953
  function normalizeToolResultPart(contentPart) {
17911
17954
  if (!contentPart || typeof contentPart !== "object")
17912
17955
  return;
@@ -18049,6 +18092,7 @@ class PiAgentSession {
18049
18092
  _pendingRequests = new Map;
18050
18093
  _nextRequestId = 1;
18051
18094
  _stderrBuffer = "";
18095
+ _apiError;
18052
18096
  _stallTimer;
18053
18097
  _stallError;
18054
18098
  _testWindowToolCallIds = new Set;
@@ -18147,7 +18191,9 @@ class PiAgentSession {
18147
18191
  donePromise.catch(() => {});
18148
18192
  this._donePromise = donePromise;
18149
18193
  this.proc.stderr?.on("data", (chunk) => {
18150
- this._stderrBuffer += chunk.toString();
18194
+ const text = chunk.toString();
18195
+ this._stderrBuffer += text;
18196
+ this._apiError ??= extractApiErrorFromStderr(this._stderrBuffer) ?? extractApiErrorFromStderr(text);
18151
18197
  });
18152
18198
  this.proc.stdout?.on("data", (chunk) => {
18153
18199
  this._lineBuffer += chunk.toString();
@@ -18308,6 +18354,12 @@ class PiAgentSession {
18308
18354
  }
18309
18355
  this._updateTokenUsage(findTokenUsage(event), "agent_end");
18310
18356
  this._updateFinishReason(findFinishReason(event), "agent_end");
18357
+ const apiError = findApiErrorMessage(event) ?? this._apiError ?? extractApiErrorFromStderr(this._stderrBuffer);
18358
+ if (apiError) {
18359
+ this._apiError = apiError;
18360
+ this._metrics.api_error = apiError;
18361
+ this.options.onMetric?.({ type: "api_error", source: "stderr", errorMessage: apiError });
18362
+ }
18311
18363
  this._agentEndReceived = true;
18312
18364
  this._clearStallTimer();
18313
18365
  this.options.onEvent?.("agent_end");
@@ -18434,6 +18486,16 @@ class PiAgentSession {
18434
18486
  this.options.onEvent?.("message_done");
18435
18487
  break;
18436
18488
  }
18489
+ case "error": {
18490
+ const apiError = findApiErrorMessage(ae) ?? findApiErrorMessage(event);
18491
+ if (apiError) {
18492
+ this._apiError = apiError;
18493
+ this._metrics.api_error = apiError;
18494
+ this.options.onMetric?.({ type: "api_error", source: "rpc", errorMessage: apiError });
18495
+ }
18496
+ this.options.onEvent?.("message_error");
18497
+ break;
18498
+ }
18437
18499
  }
18438
18500
  }
18439
18501
  }
@@ -18966,7 +19028,7 @@ function resolveCurrentBranch(cwd = process.cwd()) {
18966
19028
  var init_job_root = () => {};
18967
19029
 
18968
19030
  // src/specialist/observability-sqlite.ts
18969
- import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
19031
+ import { existsSync as existsSync4, readFileSync as readFileSync2, statSync } from "fs";
18970
19032
  import { join as join5 } from "path";
18971
19033
  function loadBunDatabase() {
18972
19034
  if (_probed)
@@ -19508,7 +19570,9 @@ function migrateToV10(db) {
19508
19570
 
19509
19571
  class SqliteClient {
19510
19572
  db;
19573
+ dbPath;
19511
19574
  constructor(dbPath) {
19575
+ this.dbPath = dbPath;
19512
19576
  const Ctor = loadBunDatabase();
19513
19577
  this.db = new Ctor(dbPath);
19514
19578
  this.db.run(`PRAGMA busy_timeout=${BUSY_TIMEOUT_MS}`);
@@ -20081,6 +20145,19 @@ class SqliteClient {
20081
20145
  return this.db.query("SELECT chain_id, epic_id, chain_root_bead_id, chain_root_job_id, updated_at_ms FROM epic_chain_membership WHERE epic_id = ? ORDER BY updated_at_ms DESC").all(epicId);
20082
20146
  }, "listEpicChains");
20083
20147
  }
20148
+ deleteEpicChainMembership(epicId, chainIds) {
20149
+ if (chainIds.length === 0)
20150
+ return [];
20151
+ return withRetry(() => {
20152
+ const existing = new Set(this.db.query("SELECT chain_id FROM epic_chain_membership WHERE epic_id = ?").all(epicId).map((row) => row.chain_id));
20153
+ const removable = chainIds.filter((chainId) => existing.has(chainId));
20154
+ if (removable.length === 0)
20155
+ return [];
20156
+ const placeholders = removable.map(() => "?").join(", ");
20157
+ this.db.query(`DELETE FROM epic_chain_membership WHERE epic_id = ? AND chain_id IN (${placeholders})`).run(epicId, ...removable);
20158
+ return removable;
20159
+ }, "deleteEpicChainMembership");
20160
+ }
20084
20161
  listEpicChainsWithLatestJob(epicId) {
20085
20162
  return withRetry(() => {
20086
20163
  const rows = this.db.query(`
@@ -20331,6 +20408,242 @@ class SqliteClient {
20331
20408
  transaction();
20332
20409
  }, "invalidateMemoriesCache");
20333
20410
  }
20411
+ hasActiveJobs(statuses = ["running", "starting"]) {
20412
+ return this.listActiveJobs(statuses).length > 0;
20413
+ }
20414
+ listActiveJobs(statuses = ["running", "starting"]) {
20415
+ return withRetry(() => {
20416
+ if (statuses.length === 0)
20417
+ return [];
20418
+ const placeholders = statuses.map(() => "?").join(", ");
20419
+ return this.db.query(`
20420
+ SELECT job_id, specialist, status
20421
+ FROM specialist_jobs
20422
+ WHERE status IN (${placeholders})
20423
+ ORDER BY updated_at_ms DESC
20424
+ `).all(...statuses);
20425
+ }, "listActiveJobs");
20426
+ }
20427
+ getDatabaseSizeBytes() {
20428
+ try {
20429
+ return statSync(this.dbPath).size;
20430
+ } catch {
20431
+ return 0;
20432
+ }
20433
+ }
20434
+ vacuumDatabase() {
20435
+ return withRetry(() => {
20436
+ const beforeBytes = this.getDatabaseSizeBytes();
20437
+ this.db.run("VACUUM");
20438
+ const afterBytes = this.getDatabaseSizeBytes();
20439
+ return { beforeBytes, afterBytes };
20440
+ }, "vacuumDatabase");
20441
+ }
20442
+ pruneObservabilityData(options) {
20443
+ return withRetry(() => {
20444
+ const nowMs = options.nowMs ?? Date.now();
20445
+ const eventsRetentionMs = options.eventsRetentionMs ?? 30 * 24 * 60 * 60 * 1000;
20446
+ const eventsCutoffMs = nowMs - eventsRetentionMs;
20447
+ const terminalStatuses = ["done", "error", "stopped"];
20448
+ const activeStatuses = ["running", "starting", "waiting"];
20449
+ const skippedActiveChainJobs = this.db.query(`
20450
+ SELECT COUNT(*) AS count
20451
+ FROM specialist_jobs stale
20452
+ WHERE stale.updated_at_ms < ?
20453
+ AND stale.status IN (${terminalStatuses.map(() => "?").join(", ")})
20454
+ AND stale.chain_id IS NOT NULL
20455
+ AND EXISTS (
20456
+ SELECT 1
20457
+ FROM specialist_jobs active
20458
+ WHERE active.chain_id = stale.chain_id
20459
+ AND active.status IN (${activeStatuses.map(() => "?").join(", ")})
20460
+ )
20461
+ `).get(options.beforeMs, ...terminalStatuses, ...activeStatuses)?.count ?? 0;
20462
+ const resultCandidates = this.db.query(`
20463
+ SELECT COUNT(*) AS count
20464
+ FROM specialist_results results
20465
+ LEFT JOIN specialist_jobs jobs ON jobs.job_id = results.job_id
20466
+ WHERE results.updated_at_ms < ?
20467
+ AND (
20468
+ jobs.job_id IS NULL
20469
+ OR jobs.chain_id IS NULL
20470
+ OR NOT EXISTS (
20471
+ SELECT 1
20472
+ FROM specialist_jobs active
20473
+ WHERE active.chain_id = jobs.chain_id
20474
+ AND active.status IN (${activeStatuses.map(() => "?").join(", ")})
20475
+ )
20476
+ )
20477
+ `).get(options.beforeMs, ...activeStatuses)?.count ?? 0;
20478
+ const jobCandidates = this.db.query(`
20479
+ SELECT COUNT(*) AS count
20480
+ FROM specialist_jobs stale
20481
+ WHERE stale.updated_at_ms < ?
20482
+ AND stale.status IN (${terminalStatuses.map(() => "?").join(", ")})
20483
+ AND (
20484
+ stale.chain_id IS NULL
20485
+ OR NOT EXISTS (
20486
+ SELECT 1
20487
+ FROM specialist_jobs active
20488
+ WHERE active.chain_id = stale.chain_id
20489
+ AND active.status IN (${activeStatuses.map(() => "?").join(", ")})
20490
+ )
20491
+ )
20492
+ `).get(options.beforeMs, ...terminalStatuses, ...activeStatuses)?.count ?? 0;
20493
+ const eventsCandidates = this.db.query("SELECT COUNT(*) AS count FROM specialist_events WHERE t < ?").get(eventsCutoffMs)?.count ?? 0;
20494
+ const epicCandidates = options.includeEpics ? this.db.query(`
20495
+ SELECT COUNT(*) AS count
20496
+ FROM epic_runs epic
20497
+ WHERE epic.updated_at_ms < ?
20498
+ AND epic.status IN ('merged', 'failed', 'abandoned')
20499
+ AND NOT EXISTS (
20500
+ SELECT 1
20501
+ FROM epic_chain_membership membership
20502
+ WHERE membership.epic_id = epic.epic_id
20503
+ )
20504
+ `).get(options.beforeMs)?.count ?? 0 : 0;
20505
+ if (!options.apply) {
20506
+ return {
20507
+ dryRun: true,
20508
+ beforeMs: options.beforeMs,
20509
+ eventsCutoffMs,
20510
+ includeEpics: options.includeEpics,
20511
+ deletedEvents: eventsCandidates,
20512
+ deletedResults: resultCandidates,
20513
+ deletedJobs: jobCandidates,
20514
+ deletedEpicRuns: epicCandidates,
20515
+ skippedActiveChainJobs
20516
+ };
20517
+ }
20518
+ const deleteResults = this.db.query(`
20519
+ DELETE FROM specialist_results
20520
+ WHERE updated_at_ms < ?
20521
+ AND (
20522
+ job_id NOT IN (SELECT job_id FROM specialist_jobs WHERE chain_id IS NOT NULL)
20523
+ OR job_id IN (
20524
+ SELECT jobs.job_id
20525
+ FROM specialist_jobs jobs
20526
+ WHERE jobs.chain_id IS NULL
20527
+ OR NOT EXISTS (
20528
+ SELECT 1
20529
+ FROM specialist_jobs active
20530
+ WHERE active.chain_id = jobs.chain_id
20531
+ AND active.status IN (${activeStatuses.map(() => "?").join(", ")})
20532
+ )
20533
+ )
20534
+ )
20535
+ `);
20536
+ const deletedResults = deleteResults.run(options.beforeMs, ...activeStatuses).changes ?? 0;
20537
+ const deleteEvents = this.db.query("DELETE FROM specialist_events WHERE t < ?");
20538
+ const deletedEvents = deleteEvents.run(eventsCutoffMs).changes ?? 0;
20539
+ const deleteJobs = this.db.query(`
20540
+ DELETE FROM specialist_jobs
20541
+ WHERE updated_at_ms < ?
20542
+ AND status IN (${terminalStatuses.map(() => "?").join(", ")})
20543
+ AND (
20544
+ chain_id IS NULL
20545
+ OR NOT EXISTS (
20546
+ SELECT 1
20547
+ FROM specialist_jobs active
20548
+ WHERE active.chain_id = specialist_jobs.chain_id
20549
+ AND active.status IN (${activeStatuses.map(() => "?").join(", ")})
20550
+ )
20551
+ )
20552
+ `);
20553
+ const deletedJobs = deleteJobs.run(options.beforeMs, ...terminalStatuses, ...activeStatuses).changes ?? 0;
20554
+ let deletedEpicRuns = 0;
20555
+ if (options.includeEpics) {
20556
+ const deleteEpics = this.db.query(`
20557
+ DELETE FROM epic_runs
20558
+ WHERE updated_at_ms < ?
20559
+ AND status IN ('merged', 'failed', 'abandoned')
20560
+ AND NOT EXISTS (
20561
+ SELECT 1
20562
+ FROM epic_chain_membership membership
20563
+ WHERE membership.epic_id = epic_runs.epic_id
20564
+ )
20565
+ `);
20566
+ deletedEpicRuns = deleteEpics.run(options.beforeMs).changes ?? 0;
20567
+ }
20568
+ return {
20569
+ dryRun: false,
20570
+ beforeMs: options.beforeMs,
20571
+ eventsCutoffMs,
20572
+ includeEpics: options.includeEpics,
20573
+ deletedEvents,
20574
+ deletedResults,
20575
+ deletedJobs,
20576
+ deletedEpicRuns,
20577
+ skippedActiveChainJobs
20578
+ };
20579
+ }, "pruneObservabilityData");
20580
+ }
20581
+ scanOrphans() {
20582
+ return withRetry(() => {
20583
+ const findings = [];
20584
+ const chainMembershipWithoutJobs = this.db.query(`
20585
+ SELECT membership.chain_id, membership.epic_id
20586
+ FROM epic_chain_membership membership
20587
+ LEFT JOIN specialist_jobs jobs ON jobs.chain_id = membership.chain_id
20588
+ WHERE jobs.job_id IS NULL
20589
+ `).all();
20590
+ for (const row of chainMembershipWithoutJobs) {
20591
+ findings.push({
20592
+ kind: "orphan",
20593
+ code: "chain_membership_without_jobs",
20594
+ message: `chain ${row.chain_id} has epic membership but no jobs`,
20595
+ details: { chain_id: row.chain_id, epic_id: row.epic_id }
20596
+ });
20597
+ }
20598
+ const epicsWithoutChains = this.db.query(`
20599
+ SELECT epic.epic_id, epic.status
20600
+ FROM epic_runs epic
20601
+ LEFT JOIN epic_chain_membership membership ON membership.epic_id = epic.epic_id
20602
+ WHERE membership.chain_id IS NULL
20603
+ `).all();
20604
+ for (const row of epicsWithoutChains) {
20605
+ findings.push({
20606
+ kind: "orphan",
20607
+ code: "epic_without_chains",
20608
+ message: `epic ${row.epic_id} has no chain membership`,
20609
+ details: { epic_id: row.epic_id, status: row.status }
20610
+ });
20611
+ }
20612
+ const jobEpicWithoutMembership = this.db.query(`
20613
+ SELECT jobs.job_id, jobs.epic_id, jobs.chain_id
20614
+ FROM specialist_jobs jobs
20615
+ LEFT JOIN epic_chain_membership membership
20616
+ ON membership.chain_id = jobs.chain_id
20617
+ AND membership.epic_id = jobs.epic_id
20618
+ WHERE jobs.epic_id IS NOT NULL
20619
+ AND (jobs.chain_id IS NULL OR membership.chain_id IS NULL)
20620
+ `).all();
20621
+ for (const row of jobEpicWithoutMembership) {
20622
+ findings.push({
20623
+ kind: "integrity-violation",
20624
+ code: "job_epic_without_membership",
20625
+ message: `job ${row.job_id} references epic without chain membership link`,
20626
+ details: { job_id: row.job_id, epic_id: row.epic_id, chain_id: row.chain_id ?? null }
20627
+ });
20628
+ }
20629
+ const worktreeRows = this.db.query(`
20630
+ SELECT DISTINCT job_id, worktree_column
20631
+ FROM specialist_jobs
20632
+ WHERE worktree_column IS NOT NULL AND worktree_column != ''
20633
+ `).all();
20634
+ for (const row of worktreeRows) {
20635
+ if (existsSync4(row.worktree_column))
20636
+ continue;
20637
+ findings.push({
20638
+ kind: "stale-pointer",
20639
+ code: "worktree_missing_on_disk",
20640
+ message: `job ${row.job_id} points to missing worktree path`,
20641
+ details: { job_id: row.job_id, worktree_path: row.worktree_column }
20642
+ });
20643
+ }
20644
+ return findings;
20645
+ }, "scanOrphans");
20646
+ }
20334
20647
  close() {
20335
20648
  this.db.close();
20336
20649
  }
@@ -20757,6 +21070,9 @@ function resolveOutputContractSchema(responseFormat, outputType, outputSchema) {
20757
21070
  }
20758
21071
  return mergedSchema;
20759
21072
  }
21073
+ function shellQuote(value) {
21074
+ return `'${value.replace(/'/g, `'''`)}'`;
21075
+ }
20760
21076
  function buildOutputContractInstruction(responseFormat, outputType, outputSchema) {
20761
21077
  if (responseFormat === "text")
20762
21078
  return "";
@@ -20781,6 +21097,58 @@ function buildOutputContractInstruction(responseFormat, outputType, outputSchema
20781
21097
  ${lines.join(`
20782
21098
  `)}`;
20783
21099
  }
21100
+ function buildReviewerDiffContext(cwd, maxFiles = 20) {
21101
+ const stat2 = execSync2("git diff --stat", {
21102
+ cwd,
21103
+ encoding: "utf8",
21104
+ timeout: 1e4,
21105
+ stdio: ["ignore", "pipe", "pipe"]
21106
+ }).trim();
21107
+ const files = execSync2("git diff --name-only", {
21108
+ cwd,
21109
+ encoding: "utf8",
21110
+ timeout: 1e4,
21111
+ stdio: ["ignore", "pipe", "pipe"]
21112
+ }).split(`
21113
+ `).map((line) => line.trim()).filter(Boolean).slice(0, maxFiles);
21114
+ if (files.length === 0) {
21115
+ throw new Error("Reviewer startup blocked: git diff is empty. No patch context to review.");
21116
+ }
21117
+ const hunks = files.map((file) => {
21118
+ const diff = execSync2(`git diff -- ${shellQuote(file)}`, {
21119
+ cwd,
21120
+ encoding: "utf8",
21121
+ timeout: 1e4,
21122
+ stdio: ["ignore", "pipe", "pipe"]
21123
+ }).trim();
21124
+ return diff ? `### ${file}
21125
+ ${diff}` : `### ${file}
21126
+ (no hunks)`;
21127
+ }).join(`
21128
+
21129
+ `);
21130
+ return { stat: stat2, files, hunks };
21131
+ }
21132
+ function buildReviewerDiffInstruction(context) {
21133
+ return `
21134
+
21135
+ ---
21136
+ ## Reviewer Diff Context
21137
+ Review only patch below. Ignore unrelated files, repo-wide exploration, and filesystem hunting.
21138
+ If patch context is empty, stop and fail fast.
21139
+
21140
+ Diff stat:
21141
+ ${context.stat || "(no stat)"}
21142
+
21143
+ Changed files:
21144
+ ${context.files.map((file) => `- ${file}`).join(`
21145
+ `)}
21146
+
21147
+ Diff hunks:
21148
+ ${context.hunks}
21149
+ ---
21150
+ `;
21151
+ }
20784
21152
  function tryParseJson(input) {
20785
21153
  try {
20786
21154
  return { value: JSON.parse(input) };
@@ -20944,11 +21312,21 @@ class SpecialistRunner {
20944
21312
  const preScriptOutput = formatScriptOutput(preResults);
20945
21313
  const resolvedPrompt = this.resolvePromptWithBeadContext(options, beadsClient);
20946
21314
  const beadVariables = options.inputBeadId ? { bead_context: resolvedPrompt, bead_id: options.inputBeadId } : {};
20947
- const beadTemplateVariables = options.inputBeadId ? { bead_id: options.inputBeadId, prompt: resolvedPrompt } : {};
21315
+ const lineageVariables = {
21316
+ ...options.reusedFromJobId ? { reused_from_job_id: options.reusedFromJobId } : {},
21317
+ ...options.worktreeOwnerJobId ? { worktree_owner_job_id: options.worktreeOwnerJobId } : {}
21318
+ };
21319
+ const beadTemplateVariables = {
21320
+ prompt: resolvedPrompt,
21321
+ bead_id: options.inputBeadId ?? "",
21322
+ ...lineageVariables
21323
+ };
20948
21324
  const variables = {
20949
21325
  prompt: resolvedPrompt,
20950
21326
  cwd: runCwd,
20951
21327
  pre_script_output: preScriptOutput,
21328
+ bead_id: options.inputBeadId ?? "",
21329
+ ...lineageVariables,
20952
21330
  ...options.variables ?? {},
20953
21331
  ...beadVariables
20954
21332
  };
@@ -20961,7 +21339,7 @@ class SpecialistRunner {
20961
21339
  estimated_tokens: Math.ceil(renderedTask.length / 4),
20962
21340
  system_prompt_present: !!prompt.system
20963
21341
  });
20964
- let agentsMd = options.inputBeadId ? renderTemplate(prompt.system ?? "", beadTemplateVariables) : prompt.system ?? "";
21342
+ let agentsMd = renderTemplate(prompt.system ?? "", beadTemplateVariables);
20965
21343
  {
20966
21344
  const sanitizedBeadId = options.inputBeadId ? sanitizeBeadIdForPrompt(options.inputBeadId) : "";
20967
21345
  const beadInstructions = sanitizedBeadId ? `
@@ -21092,6 +21470,10 @@ ${summaries.join(`
21092
21470
  }
21093
21471
  })
21094
21472
  });
21473
+ if (metadata.name === "reviewer" && options.reusedFromJobId) {
21474
+ const reviewerDiffContext = buildReviewerDiffContext(runCwd);
21475
+ agentsMd += buildReviewerDiffInstruction(reviewerDiffContext);
21476
+ }
21095
21477
  const responseFormat = execution.response_format ?? "text";
21096
21478
  const outputType = execution.output_type ?? "custom";
21097
21479
  const specialistOutputSchema = prompt.output_schema;
@@ -21691,6 +22073,13 @@ function mapCallbackEventToTimelineEvent(callbackEvent, context) {
21691
22073
  ...context.extensionError?.extension ? { extension: context.extensionError.extension } : {},
21692
22074
  ...context.extensionError?.errorMessage ? { error_message: context.extensionError.errorMessage } : {}
21693
22075
  };
22076
+ case "api_error":
22077
+ return {
22078
+ t,
22079
+ type: TIMELINE_EVENT_TYPES.ERROR,
22080
+ source: context.apiError?.source ?? "rpc",
22081
+ error_message: context.apiError?.errorMessage ?? "Unknown API error"
22082
+ };
21694
22083
  case "memory_injection":
21695
22084
  return {
21696
22085
  t,
@@ -21713,12 +22102,13 @@ function mapCallbackEventToTimelineEvent(callbackEvent, context) {
21713
22102
  return null;
21714
22103
  }
21715
22104
  }
21716
- function createRunStartEvent(specialist, beadId) {
22105
+ function createRunStartEvent(specialist, beadId, startupSnapshot) {
21717
22106
  return {
21718
22107
  t: Date.now(),
21719
22108
  type: TIMELINE_EVENT_TYPES.RUN_START,
21720
22109
  specialist,
21721
- bead_id: beadId
22110
+ bead_id: beadId,
22111
+ ...startupSnapshot ? { startup_snapshot: startupSnapshot } : {}
21722
22112
  };
21723
22113
  }
21724
22114
  function createMetaEvent(model, backend) {
@@ -21875,6 +22265,7 @@ var init_timeline_events = __esm(() => {
21875
22265
  RETRY: "retry",
21876
22266
  MODEL_CHANGE: "model_change",
21877
22267
  EXTENSION_ERROR: "extension_error",
22268
+ ERROR: "error",
21878
22269
  AUTO_COMMIT_SUCCESS: "auto_commit_success",
21879
22270
  AUTO_COMMIT_SKIPPED: "auto_commit_skipped",
21880
22271
  AUTO_COMMIT_FAILED: "auto_commit_failed",
@@ -21938,6 +22329,27 @@ function evaluateEpicMergeReadiness(input) {
21938
22329
  summary: `Epic ${input.epicId} is merge-ready and all chains are terminal.`
21939
22330
  };
21940
22331
  }
22332
+ function appendEpicTransitionAudit(statusJson, entry) {
22333
+ const fallback = {
22334
+ transitions: []
22335
+ };
22336
+ let parsed = fallback;
22337
+ if (statusJson) {
22338
+ try {
22339
+ const candidate = JSON.parse(statusJson);
22340
+ if (candidate && typeof candidate === "object") {
22341
+ parsed = candidate;
22342
+ }
22343
+ } catch {
22344
+ parsed = fallback;
22345
+ }
22346
+ }
22347
+ const previous = Array.isArray(parsed.transitions) ? parsed.transitions.filter((item) => Boolean(item) && typeof item === "object") : [];
22348
+ return JSON.stringify({
22349
+ ...parsed,
22350
+ transitions: [...previous, entry]
22351
+ });
22352
+ }
21941
22353
  function summarizeEpicTransition(epicId, from, to) {
21942
22354
  return `Epic ${epicId}: ${from} -> ${to}`;
21943
22355
  }
@@ -21954,6 +22366,72 @@ var init_epic_lifecycle = __esm(() => {
21954
22366
  };
21955
22367
  });
21956
22368
 
22369
+ // src/specialist/process-liveness.ts
22370
+ import { readFileSync as readFileSync4 } from "fs";
22371
+ function isValidPid(pid) {
22372
+ return Number.isInteger(pid) && pid > 0;
22373
+ }
22374
+ function isAliveBySignal(pid) {
22375
+ try {
22376
+ process.kill(pid, 0);
22377
+ return true;
22378
+ } catch {
22379
+ return false;
22380
+ }
22381
+ }
22382
+ function parseBootTimeMs() {
22383
+ try {
22384
+ const procStat = readFileSync4("/proc/stat", "utf-8");
22385
+ const bootLine = procStat.split(`
22386
+ `).find((line) => line.startsWith("btime "));
22387
+ if (!bootLine)
22388
+ return;
22389
+ const bootSeconds = Number.parseInt(bootLine.split(/\s+/)[1] ?? "", 10);
22390
+ if (!Number.isFinite(bootSeconds) || bootSeconds <= 0)
22391
+ return;
22392
+ return bootSeconds * 1000;
22393
+ } catch {
22394
+ return;
22395
+ }
22396
+ }
22397
+ function parseProcessStartTimeMs(pid) {
22398
+ try {
22399
+ const statRaw = readFileSync4(`/proc/${pid}/stat`, "utf-8");
22400
+ const closeParenIndex = statRaw.lastIndexOf(")");
22401
+ if (closeParenIndex < 0)
22402
+ return;
22403
+ const suffix = statRaw.slice(closeParenIndex + 1).trim();
22404
+ const fields = suffix.split(/\s+/);
22405
+ const startTimeTicksText = fields[PROC_STAT_START_TIME_INDEX - 2];
22406
+ if (!startTimeTicksText)
22407
+ return;
22408
+ const startTimeTicks = Number.parseInt(startTimeTicksText, 10);
22409
+ if (!Number.isFinite(startTimeTicks) || startTimeTicks < 0)
22410
+ return;
22411
+ const bootTimeMs = parseBootTimeMs();
22412
+ if (bootTimeMs === undefined)
22413
+ return;
22414
+ const ticksPerSecond = 100;
22415
+ return bootTimeMs + Math.floor(startTimeTicks * 1000 / ticksPerSecond);
22416
+ } catch {
22417
+ return;
22418
+ }
22419
+ }
22420
+ function isProcessAlive(pid, startTimeMs) {
22421
+ if (!isValidPid(pid))
22422
+ return false;
22423
+ if (!isAliveBySignal(pid))
22424
+ return false;
22425
+ if (startTimeMs === undefined)
22426
+ return true;
22427
+ const actualStartTimeMs = parseProcessStartTimeMs(pid);
22428
+ if (actualStartTimeMs === undefined)
22429
+ return true;
22430
+ return Math.abs(actualStartTimeMs - startTimeMs) <= 2000;
22431
+ }
22432
+ var PROC_STAT_START_TIME_INDEX = 21;
22433
+ var init_process_liveness = () => {};
22434
+
21957
22435
  // src/specialist/epic-readiness.ts
21958
22436
  function parseReviewerVerdict(output) {
21959
22437
  if (!output)
@@ -21984,7 +22462,19 @@ function evaluateChainReadiness(chainId, jobs, chainRootBeadId) {
21984
22462
  }
21985
22463
  const orderedJobs = [...jobs].sort((a, b) => a.started_at_ms - b.started_at_ms);
21986
22464
  const activeJobs = orderedJobs.filter((job) => ACTIVE_JOB_STATUSES.has(job.status));
22465
+ const deadActiveJobs = activeJobs.filter((job) => job.pid !== undefined && !isProcessAlive(job.pid, job.started_at_ms));
21987
22466
  const hasActiveJobs = activeJobs.length > 0;
22467
+ if (deadActiveJobs.length > 0) {
22468
+ return {
22469
+ chain_id: chainId,
22470
+ chain_root_bead_id: chainRootBeadId,
22471
+ state: "failed",
22472
+ reviewer_verdict: "missing",
22473
+ blocking_reason: `Active chain jobs appear dead: ${deadActiveJobs.map((job) => job.id).join(", ")}`,
22474
+ has_active_jobs: false,
22475
+ job_ids: orderedJobs.map((job) => job.id)
22476
+ };
22477
+ }
21988
22478
  if (hasActiveJobs) {
21989
22479
  return {
21990
22480
  chain_id: chainId,
@@ -22151,6 +22641,7 @@ function loadEpicReadinessSummary(sqlite, epicId) {
22151
22641
  id: status.id,
22152
22642
  specialist: status.specialist,
22153
22643
  status: status.status,
22644
+ pid: status.pid,
22154
22645
  started_at_ms: status.started_at_ms,
22155
22646
  result_text: sqlite.readResult(status.id) ?? undefined
22156
22647
  }));
@@ -22193,6 +22684,7 @@ function syncEpicStateFromReadiness(sqlite, summary) {
22193
22684
  var ACTIVE_JOB_STATUSES, TERMINAL_JOB_STATUSES, REVIEWER_VERDICT_REGEX;
22194
22685
  var init_epic_readiness = __esm(() => {
22195
22686
  init_epic_lifecycle();
22687
+ init_process_liveness();
22196
22688
  ACTIVE_JOB_STATUSES = new Set(["starting", "running", "waiting"]);
22197
22689
  TERMINAL_JOB_STATUSES = new Set(["done", "error"]);
22198
22690
  REVIEWER_VERDICT_REGEX = /Verdict:\s*(PASS|PARTIAL|FAIL)/i;
@@ -22271,10 +22763,10 @@ import {
22271
22763
  mkdirSync as mkdirSync3,
22272
22764
  openSync,
22273
22765
  readdirSync,
22274
- readFileSync as readFileSync4,
22766
+ readFileSync as readFileSync5,
22275
22767
  renameSync,
22276
22768
  rmSync,
22277
- statSync,
22769
+ statSync as statSync2,
22278
22770
  writeFileSync as writeFileSync3,
22279
22771
  writeSync
22280
22772
  } from "fs";
@@ -22685,7 +23177,7 @@ class Supervisor {
22685
23177
  if (!existsSync7(path))
22686
23178
  return null;
22687
23179
  try {
22688
- const status = JSON.parse(readFileSync4(path, "utf-8"));
23180
+ const status = JSON.parse(readFileSync5(path, "utf-8"));
22689
23181
  return this.withComputedLiveness(status);
22690
23182
  } catch {
22691
23183
  return null;
@@ -22727,7 +23219,7 @@ class Supervisor {
22727
23219
  if (!existsSync7(path))
22728
23220
  continue;
22729
23221
  try {
22730
- const status = JSON.parse(readFileSync4(path, "utf-8"));
23222
+ const status = JSON.parse(readFileSync5(path, "utf-8"));
22731
23223
  jobs.push(this.withComputedLiveness(status));
22732
23224
  } catch {}
22733
23225
  }
@@ -22807,7 +23299,7 @@ class Supervisor {
22807
23299
  for (const entry of readdirSync(this.resolvedJobsDir)) {
22808
23300
  const dir = join8(this.resolvedJobsDir, entry);
22809
23301
  try {
22810
- const stat2 = statSync(dir);
23302
+ const stat2 = statSync2(dir);
22811
23303
  if (!stat2.isDirectory())
22812
23304
  continue;
22813
23305
  if (stat2.mtimeMs < cutoff)
@@ -22828,7 +23320,7 @@ class Supervisor {
22828
23320
  if (!existsSync7(statusPath))
22829
23321
  continue;
22830
23322
  try {
22831
- const s = JSON.parse(readFileSync4(statusPath, "utf-8"));
23323
+ const s = JSON.parse(readFileSync5(statusPath, "utf-8"));
22832
23324
  if (s.status === "running" || s.status === "starting") {
22833
23325
  if (!s.pid)
22834
23326
  continue;
@@ -22890,6 +23382,32 @@ class Supervisor {
22890
23382
  mkdirSync3(dir, { recursive: true });
22891
23383
  mkdirSync3(this.readyDir(), { recursive: true });
22892
23384
  const nodeId = runOptions.variables?.node_id ?? runOptions.variables?.SPECIALISTS_NODE_ID;
23385
+ const variablesKeys = Object.keys(runOptions.variables ?? {});
23386
+ const activatedSkills = (runOptions.variables?.activated_skills ?? runOptions.variables?.skills_activated ?? "").split(",").map((skill) => skill.trim()).filter((skill) => skill.length > 0);
23387
+ const startupContext = {
23388
+ job_id: id,
23389
+ specialist_name: runOptions.name,
23390
+ ...runOptions.inputBeadId ? { bead_id: runOptions.inputBeadId } : {},
23391
+ ...runOptions.reusedFromJobId ? { reused_from_job_id: runOptions.reusedFromJobId } : {},
23392
+ ...runOptions.worktreeOwnerJobId ? { worktree_owner_job_id: runOptions.worktreeOwnerJobId } : {},
23393
+ ...runOptions.worktreeOwnerJobId || runOptions.workingDirectory ? {
23394
+ chain_id: runOptions.worktreeOwnerJobId ?? id,
23395
+ chain_root_job_id: runOptions.worktreeOwnerJobId ?? id
23396
+ } : {},
23397
+ ...runOptions.variables?.chain_root_bead_id ? { chain_root_bead_id: runOptions.variables.chain_root_bead_id } : {},
23398
+ ...runOptions.workingDirectory ? { worktree_path: runOptions.workingDirectory } : {},
23399
+ ...runOptions.workingDirectory ? { branch: resolveCurrentBranch(runOptions.workingDirectory) } : { branch: resolveCurrentBranch() },
23400
+ variables_keys: variablesKeys,
23401
+ reviewed_job_id_present: variablesKeys.includes("reviewed_job_id"),
23402
+ reused_worktree_awareness_present: variablesKeys.includes("reused_worktree_awareness"),
23403
+ bead_context_present: variablesKeys.includes("bead_context"),
23404
+ ...activatedSkills.length > 0 ? {
23405
+ skills: {
23406
+ count: activatedSkills.length,
23407
+ activated: activatedSkills
23408
+ }
23409
+ } : {}
23410
+ };
22893
23411
  const initialStatus = {
22894
23412
  id,
22895
23413
  specialist: runOptions.name,
@@ -22908,7 +23426,8 @@ class Supervisor {
22908
23426
  chain_root_job_id: runOptions.worktreeOwnerJobId ?? id
22909
23427
  } : { chain_kind: "prep" },
22910
23428
  ...runOptions.epicId ? { epic_id: runOptions.epicId } : {},
22911
- ...runOptions.workingDirectory ? { branch: resolveCurrentBranch(runOptions.workingDirectory) } : { branch: resolveCurrentBranch() }
23429
+ ...runOptions.workingDirectory ? { branch: resolveCurrentBranch(runOptions.workingDirectory) } : { branch: resolveCurrentBranch() },
23430
+ startup_context: startupContext
22912
23431
  };
22913
23432
  this.writeStatusFileOnly(id, initialStatus);
22914
23433
  const statusWatchdogPid = startDetachedStatusWatchdog(this.statusPath(id), process.pid);
@@ -22978,7 +23497,7 @@ class Supervisor {
22978
23497
  appendTimelineEvent(createStatusChangeEvent("waiting", previousStatus));
22979
23498
  }
22980
23499
  };
22981
- const runStartEvent = appendTimelineEventFileOnly(createRunStartEvent(runOptions.name, runOptions.inputBeadId));
23500
+ const runStartEvent = appendTimelineEventFileOnly(createRunStartEvent(runOptions.name, runOptions.inputBeadId, statusSnapshot.startup_context));
22982
23501
  try {
22983
23502
  this.withSqliteOperation("upsertStatusWithEvent:run_start", (client) => client.upsertStatusWithEvent(statusSnapshot, runStartEvent));
22984
23503
  } catch (error2) {
@@ -23283,6 +23802,14 @@ ${appendError}
23283
23802
  return;
23284
23803
  }
23285
23804
  })();
23805
+ if (eventType === "memory_injection" && memoryInjection) {
23806
+ setStatus({
23807
+ startup_context: {
23808
+ ...statusSnapshot.startup_context ?? {},
23809
+ memory_injection: memoryInjection
23810
+ }
23811
+ });
23812
+ }
23286
23813
  const timelineEvent = mapCallbackEventToTimelineEvent(eventType, {
23287
23814
  tool: toolState?.tool,
23288
23815
  toolCallId,
@@ -23340,6 +23867,16 @@ ${appendError}
23340
23867
  appendTimelineEvent(createFinishReasonEvent(metricEvent.finish_reason, metricEvent.source));
23341
23868
  return;
23342
23869
  }
23870
+ if (metricEvent.type === "api_error") {
23871
+ mergeRunMetrics({ api_error: metricEvent.errorMessage });
23872
+ appendTimelineEvent({
23873
+ t: Date.now(),
23874
+ type: TIMELINE_EVENT_TYPES.ERROR,
23875
+ source: metricEvent.source,
23876
+ error_message: metricEvent.errorMessage
23877
+ });
23878
+ return;
23879
+ }
23343
23880
  if (metricEvent.type === "turn_summary") {
23344
23881
  mergeRunMetrics({
23345
23882
  turns: metricEvent.turn_index,
@@ -23731,7 +24268,7 @@ __export(exports_list, {
23731
24268
  ArgParseError: () => ArgParseError
23732
24269
  });
23733
24270
  import { spawnSync as spawnSync7 } from "child_process";
23734
- import { existsSync as existsSync8, readdirSync as readdirSync2, readFileSync as readFileSync5 } from "fs";
24271
+ import { existsSync as existsSync8, readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
23735
24272
  import { join as join9 } from "path";
23736
24273
  import readline from "readline";
23737
24274
  function permissionBadge(permission) {
@@ -23764,7 +24301,7 @@ function toLiveJob(status) {
23764
24301
  }
23765
24302
  function readJobStatus(statusPath) {
23766
24303
  try {
23767
- return JSON.parse(readFileSync5(statusPath, "utf-8"));
24304
+ return JSON.parse(readFileSync6(statusPath, "utf-8"));
23768
24305
  } catch {
23769
24306
  return null;
23770
24307
  }
@@ -24333,7 +24870,7 @@ var exports_init = {};
24333
24870
  __export(exports_init, {
24334
24871
  run: () => run6
24335
24872
  });
24336
- import { copyFileSync, cpSync, existsSync as existsSync9, lstatSync, mkdirSync as mkdirSync4, readdirSync as readdirSync3, readFileSync as readFileSync6, readlinkSync, renameSync as renameSync2, symlinkSync, writeFileSync as writeFileSync4 } from "fs";
24873
+ import { copyFileSync, cpSync, existsSync as existsSync9, lstatSync, mkdirSync as mkdirSync4, readdirSync as readdirSync3, readFileSync as readFileSync7, readlinkSync, renameSync as renameSync2, symlinkSync, writeFileSync as writeFileSync4 } from "fs";
24337
24874
  import { spawnSync as spawnSync9 } from "child_process";
24338
24875
  import { basename as basename3, dirname as dirname5, join as join10, relative, resolve as resolve4 } from "path";
24339
24876
  import { fileURLToPath as fileURLToPath2 } from "url";
@@ -24375,7 +24912,7 @@ function loadJson(path, fallback) {
24375
24912
  if (!existsSync9(path))
24376
24913
  return structuredClone(fallback);
24377
24914
  try {
24378
- return JSON.parse(readFileSync6(path, "utf-8"));
24915
+ return JSON.parse(readFileSync7(path, "utf-8"));
24379
24916
  } catch {
24380
24917
  return structuredClone(fallback);
24381
24918
  }
@@ -24718,7 +25255,7 @@ function ensureProjectMcp(cwd) {
24718
25255
  }
24719
25256
  function ensureGitignore(cwd) {
24720
25257
  const gitignorePath = join10(cwd, ".gitignore");
24721
- const existing = existsSync9(gitignorePath) ? readFileSync6(gitignorePath, "utf-8") : "";
25258
+ const existing = existsSync9(gitignorePath) ? readFileSync7(gitignorePath, "utf-8") : "";
24722
25259
  let added = 0;
24723
25260
  const lines = existing.split(`
24724
25261
  `);
@@ -24765,7 +25302,7 @@ function ensureObservabilityDb(cwd) {
24765
25302
  function ensureAgentsMd(cwd) {
24766
25303
  const agentsPath = join10(cwd, "AGENTS.md");
24767
25304
  if (existsSync9(agentsPath)) {
24768
- const existing = readFileSync6(agentsPath, "utf-8");
25305
+ const existing = readFileSync7(agentsPath, "utf-8");
24769
25306
  if (existing.includes(AGENTS_MARKER)) {
24770
25307
  skip("AGENTS.md already has Specialists section");
24771
25308
  } else {
@@ -24783,7 +25320,7 @@ function readJsonObject(path) {
24783
25320
  if (!existsSync9(path))
24784
25321
  return {};
24785
25322
  try {
24786
- return JSON.parse(readFileSync6(path, "utf-8"));
25323
+ return JSON.parse(readFileSync7(path, "utf-8"));
24787
25324
  } catch {
24788
25325
  return {};
24789
25326
  }
@@ -25056,35 +25593,77 @@ var exports_db = {};
25056
25593
  __export(exports_db, {
25057
25594
  run: () => run8
25058
25595
  });
25059
- import { existsSync as existsSync10, readdirSync as readdirSync4, readFileSync as readFileSync7 } from "fs";
25060
- import { join as join11 } from "path";
25596
+ import { existsSync as existsSync10, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
25597
+ import { dirname as dirname6, join as join11, resolve as resolve5 } from "path";
25598
+ function formatBytes(bytes) {
25599
+ if (bytes < 1024)
25600
+ return `${bytes} B`;
25601
+ const units = ["KB", "MB", "GB", "TB"];
25602
+ let value = bytes;
25603
+ let unitIndex = -1;
25604
+ while (value >= 1024 && unitIndex < units.length - 1) {
25605
+ value /= 1024;
25606
+ unitIndex += 1;
25607
+ }
25608
+ return `${value.toFixed(2)} ${units[unitIndex]}`;
25609
+ }
25610
+ function parseIsoDate(input2) {
25611
+ const parsed = Date.parse(input2);
25612
+ return Number.isFinite(parsed) ? parsed : null;
25613
+ }
25614
+ function parseDuration(input2) {
25615
+ const match = input2.trim().toLowerCase().match(/^(\d+)([smhdw])$/);
25616
+ if (!match)
25617
+ return null;
25618
+ const amount = Number(match[1]);
25619
+ const unit = match[2];
25620
+ if (!Number.isFinite(amount) || amount <= 0)
25621
+ return null;
25622
+ const multipliers = {
25623
+ s: 1000,
25624
+ m: 60 * 1000,
25625
+ h: 60 * 60 * 1000,
25626
+ d: DAY_MS,
25627
+ w: 7 * DAY_MS
25628
+ };
25629
+ return amount * multipliers[unit];
25630
+ }
25631
+ function parseBeforeArgument(raw) {
25632
+ const durationMs = parseDuration(raw);
25633
+ if (durationMs !== null)
25634
+ return Date.now() - durationMs;
25635
+ const isoMs = parseIsoDate(raw);
25636
+ if (isoMs !== null)
25637
+ return isoMs;
25638
+ throw new Error(`Invalid --before value '${raw}'. Use ISO date or duration like 7d.`);
25639
+ }
25061
25640
  function printDbHelp() {
25062
25641
  console.log([
25063
25642
  "",
25064
- "Usage: specialists db <setup|backfill>",
25643
+ "Usage: specialists db <setup|backfill|vacuum|prune>",
25065
25644
  "",
25066
- "Human-only commands for the shared observability SQLite database.",
25645
+ "Human-only commands for shared observability SQLite database.",
25067
25646
  "",
25068
25647
  "Commands:",
25069
- " setup Provision database file + schema + .gitignore entries",
25070
- " init Alias for setup",
25071
- " backfill Import historical .specialists/jobs/*/status.json rows",
25072
- " --events Also replay events.jsonl into specialist_events",
25648
+ " setup Provision database file + schema + .gitignore entries",
25649
+ " init Alias for setup",
25650
+ " backfill [--events] Import historical .specialists/jobs/*/status.json rows",
25651
+ " vacuum Run SQLite VACUUM (refuses when running/starting jobs exist)",
25652
+ " prune --before <iso|duration> Prune old rows (default dry-run)",
25653
+ " [--dry-run] [--apply] [--include-epics]",
25073
25654
  "",
25074
25655
  "Behavior:",
25075
- " - resolves storage at git-root (.specialists/db/observability.db),",
25076
- " or $XDG_DATA_HOME/specialists/observability.db when XDG_DATA_HOME is set",
25077
- " - creates the DB file once (no auto-create from runtime paths)",
25078
- " - enforces chmod 644 on the database file",
25079
- " - ensures .gitignore excludes .db, .db-wal, and .db-shm files under .specialists/db/",
25080
- " - backfill skips jobs already present in SQLite by job_id (idempotent)",
25656
+ " - prune keeps specialist_events last 30 days always",
25657
+ " - prune removes specialist_results and terminal specialist_jobs older than --before",
25658
+ " - prune never touches active-chain jobs",
25659
+ " - prune never touches epic_runs unless --include-epics",
25081
25660
  "",
25082
25661
  "Examples:",
25083
25662
  " specialists db setup",
25084
- " specialists db backfill",
25085
25663
  " specialists db backfill --events",
25086
- " sp db setup",
25087
- " sp db backfill",
25664
+ " specialists db vacuum",
25665
+ " specialists db prune --before 30d --dry-run",
25666
+ " specialists db prune --before 2026-01-01T00:00:00Z --apply --include-epics",
25088
25667
  ""
25089
25668
  ].join(`
25090
25669
  `));
@@ -25094,7 +25673,7 @@ function assertHumanInteractiveTerminal(commandName) {
25094
25673
  const inAgentSession = !forceSetup && (!process.stdin.isTTY || !!process.env.SPECIALISTS_TMUX_SESSION || !!process.env.SPECIALISTS_JOB_ID || !!process.env.PI_SESSION_ID || !!process.env.PI_RPC_SOCKET);
25095
25674
  if (!inAgentSession)
25096
25675
  return;
25097
- console.error(`specialists db ${commandName} requires an interactive terminal. This is a user-only setup command \u2014 do not invoke from scripts or agent sessions.`);
25676
+ console.error(`specialists db ${commandName} requires interactive terminal. user-only setup command.`);
25098
25677
  process.exit(1);
25099
25678
  }
25100
25679
  function printSetupResult(created, gitignoreUpdated, location) {
@@ -25123,9 +25702,48 @@ function parseBackfillOptions(argv) {
25123
25702
  }
25124
25703
  return { importEvents };
25125
25704
  }
25705
+ function parsePruneOptions(argv) {
25706
+ let beforeValue = null;
25707
+ let apply = false;
25708
+ let dryRun = true;
25709
+ let includeEpics = false;
25710
+ for (let index = 0;index < argv.length; index += 1) {
25711
+ const argument = argv[index];
25712
+ if (argument === "--before") {
25713
+ const value = argv[index + 1];
25714
+ if (!value)
25715
+ throw new Error("Missing value for --before");
25716
+ beforeValue = value;
25717
+ index += 1;
25718
+ continue;
25719
+ }
25720
+ if (argument === "--apply") {
25721
+ apply = true;
25722
+ dryRun = false;
25723
+ continue;
25724
+ }
25725
+ if (argument === "--dry-run") {
25726
+ dryRun = true;
25727
+ apply = false;
25728
+ continue;
25729
+ }
25730
+ if (argument === "--include-epics") {
25731
+ includeEpics = true;
25732
+ continue;
25733
+ }
25734
+ throw new Error(`Unknown option for db prune: '${argument}'`);
25735
+ }
25736
+ if (!beforeValue)
25737
+ throw new Error("Missing required --before for db prune");
25738
+ return {
25739
+ beforeMs: parseBeforeArgument(beforeValue),
25740
+ apply: apply && !dryRun,
25741
+ includeEpics
25742
+ };
25743
+ }
25126
25744
  function parseStatusFile(jobDirectoryPath, fallbackJobId) {
25127
25745
  const statusPath = join11(jobDirectoryPath, "status.json");
25128
- const statusRaw = readFileSync7(statusPath, "utf-8");
25746
+ const statusRaw = readFileSync8(statusPath, "utf-8");
25129
25747
  const parsed = JSON.parse(statusRaw);
25130
25748
  const jobId = typeof parsed.id === "string" && parsed.id.length > 0 ? parsed.id : fallbackJobId;
25131
25749
  const specialist = typeof parsed.specialist === "string" && parsed.specialist.length > 0 ? parsed.specialist : "unknown";
@@ -25142,7 +25760,7 @@ function parseStatusFile(jobDirectoryPath, fallbackJobId) {
25142
25760
  function replayEvents(eventsPath, sqliteClient, status) {
25143
25761
  if (!existsSync10(eventsPath))
25144
25762
  return 0;
25145
- const rawContent = readFileSync7(eventsPath, "utf-8");
25763
+ const rawContent = readFileSync8(eventsPath, "utf-8");
25146
25764
  const lines = rawContent.split(`
25147
25765
  `).map((line) => line.trim()).filter((line) => line.length > 0);
25148
25766
  let importedEvents = 0;
@@ -25239,6 +25857,231 @@ ${bold7("specialists db backfill")}
25239
25857
  }
25240
25858
  console.log("");
25241
25859
  }
25860
+ function runVacuum() {
25861
+ const sqliteClient = createObservabilitySqliteClient();
25862
+ if (!sqliteClient) {
25863
+ throw new Error("Failed to initialize observability SQLite schema. Run `specialists db setup` first and ensure sqlite3 is installed.");
25864
+ }
25865
+ try {
25866
+ const activeJobs = sqliteClient.listActiveJobs(["running", "starting"]);
25867
+ if (activeJobs.length > 0) {
25868
+ const listing = activeJobs.slice(0, 5).map((job) => `${job.job_id}:${job.status}`).join(", ");
25869
+ throw new Error(`Refusing vacuum while active jobs exist (${activeJobs.length}): ${listing}`);
25870
+ }
25871
+ const { beforeBytes, afterBytes } = sqliteClient.vacuumDatabase();
25872
+ const savedBytes = Math.max(0, beforeBytes - afterBytes);
25873
+ console.log(`
25874
+ ${bold7("specialists db vacuum")}
25875
+ `);
25876
+ console.log(` ${green5("\u2713")} before: ${formatBytes(beforeBytes)} (${beforeBytes} bytes)`);
25877
+ console.log(` ${green5("\u2713")} after: ${formatBytes(afterBytes)} (${afterBytes} bytes)`);
25878
+ console.log(` ${green5("\u2713")} saved: ${formatBytes(savedBytes)} (${savedBytes} bytes)`);
25879
+ console.log("");
25880
+ } finally {
25881
+ sqliteClient.close();
25882
+ }
25883
+ }
25884
+ function runPrune(options) {
25885
+ const sqliteClient = createObservabilitySqliteClient();
25886
+ if (!sqliteClient) {
25887
+ throw new Error("Failed to initialize observability SQLite schema. Run `specialists db setup` first and ensure sqlite3 is installed.");
25888
+ }
25889
+ try {
25890
+ const report = sqliteClient.pruneObservabilityData({
25891
+ beforeMs: options.beforeMs,
25892
+ includeEpics: options.includeEpics,
25893
+ apply: options.apply
25894
+ });
25895
+ console.log(`
25896
+ ${bold7("specialists db prune")}
25897
+ `);
25898
+ console.log(` ${report.dryRun ? yellow6("\u25CB dry-run") : green5("\u2713 applied")}`);
25899
+ console.log(` ${green5("\u2713")} before: ${new Date(report.beforeMs).toISOString()}`);
25900
+ console.log(` ${green5("\u2713")} events cutoff (fixed 30d): ${new Date(report.eventsCutoffMs).toISOString()}`);
25901
+ console.log(` ${green5("\u2713")} specialist_events: ${report.deletedEvents}`);
25902
+ console.log(` ${green5("\u2713")} specialist_results: ${report.deletedResults}`);
25903
+ console.log(` ${green5("\u2713")} specialist_jobs: ${report.deletedJobs}`);
25904
+ console.log(` ${report.includeEpics ? green5("\u2713") : yellow6("\u25CB")} epic_runs: ${report.deletedEpicRuns} ${report.includeEpics ? "" : "(skipped, use --include-epics)"}`);
25905
+ console.log(` ${yellow6("\u25CB")} skipped active-chain jobs: ${report.skippedActiveChainJobs}`);
25906
+ console.log("");
25907
+ } finally {
25908
+ sqliteClient.close();
25909
+ }
25910
+ }
25911
+ function parseBenchmarkExportOptions(argv) {
25912
+ const defaultOutput = resolve5(process.cwd(), ".specialists/benchmarks/executor-benchmark-rows.jsonl");
25913
+ let outputPath = defaultOutput;
25914
+ let epicId;
25915
+ let includePrepJobs = false;
25916
+ for (let i = 0;i < argv.length; i += 1) {
25917
+ const argument = argv[i];
25918
+ if (argument === "--output" && argv[i + 1]) {
25919
+ outputPath = resolve5(process.cwd(), argv[i + 1]);
25920
+ i += 1;
25921
+ continue;
25922
+ }
25923
+ if (argument === "--epic" && argv[i + 1]) {
25924
+ epicId = argv[i + 1];
25925
+ i += 1;
25926
+ continue;
25927
+ }
25928
+ if (argument === "--include-prep") {
25929
+ includePrepJobs = true;
25930
+ continue;
25931
+ }
25932
+ throw new Error(`Unknown option for db benchmark-export: '${argument}'`);
25933
+ }
25934
+ return { outputPath, epicId, includePrepJobs };
25935
+ }
25936
+ function parseReviewerVerdict2(output2) {
25937
+ if (!output2)
25938
+ return "MISSING";
25939
+ const match = output2.match(/Verdict:\s*(PASS|PARTIAL|FAIL)/i);
25940
+ if (!match?.[1])
25941
+ return "MISSING";
25942
+ return match[1].toUpperCase();
25943
+ }
25944
+ function parseReviewerScore(output2) {
25945
+ if (!output2)
25946
+ return null;
25947
+ const match = output2.match(/(?:Reviewer\s+)?Score(?:\s*\(0-100\))?\s*[:=]\s*(\d+(?:\.\d+)?)/i);
25948
+ return match?.[1] ? Number(match[1]) : null;
25949
+ }
25950
+ function parseGateResult(output2, key) {
25951
+ if (!output2)
25952
+ return null;
25953
+ const regex = key === "lint" ? /(?:lint_pass|lint)\s*[:=]\s*(true|false|pass|fail)/i : /(?:tsc_pass|tsc(?:\s*--noEmit)?)\s*[:=]\s*(true|false|pass|fail)/i;
25954
+ const match = output2.match(regex);
25955
+ if (!match?.[1])
25956
+ return null;
25957
+ const normalized = match[1].toLowerCase();
25958
+ return normalized === "true" || normalized === "pass";
25959
+ }
25960
+ function readLatestRunCompleteEvent(events) {
25961
+ for (let index = events.length - 1;index >= 0; index -= 1) {
25962
+ const event = events[index];
25963
+ if (event?.type === "run_complete") {
25964
+ return event;
25965
+ }
25966
+ }
25967
+ return null;
25968
+ }
25969
+ function inferFailureNotes(input2) {
25970
+ const notes = [];
25971
+ if (input2.runComplete?.status === "ERROR") {
25972
+ notes.push(`run_complete_status=ERROR${input2.runComplete.error ? `: ${input2.runComplete.error}` : ""}`);
25973
+ }
25974
+ if (input2.runComplete?.status === "CANCELLED") {
25975
+ notes.push("run_complete_status=CANCELLED");
25976
+ }
25977
+ if (input2.runComplete?.exit_reason) {
25978
+ notes.push(`exit_reason=${input2.runComplete.exit_reason}`);
25979
+ }
25980
+ if (input2.runComplete?.finish_reason) {
25981
+ notes.push(`finish_reason=${input2.runComplete.finish_reason}`);
25982
+ }
25983
+ if (input2.status.error) {
25984
+ notes.push(`status_error=${input2.status.error}`);
25985
+ }
25986
+ if (input2.reviewerVerdict !== "PASS" && input2.hasLaterExecutorInChain) {
25987
+ notes.push("fix_loop_rerun_detected_after_non_pass_review");
25988
+ }
25989
+ if (!input2.runComplete) {
25990
+ notes.push("missing_run_complete_event_fallback_to_status_metrics");
25991
+ }
25992
+ return notes;
25993
+ }
25994
+ function runBenchmarkExport(options) {
25995
+ const sqliteClient = createObservabilitySqliteClient();
25996
+ if (!sqliteClient) {
25997
+ throw new Error("Failed to initialize observability SQLite schema. Run `specialists db setup` first and ensure sqlite3 is installed.");
25998
+ }
25999
+ try {
26000
+ const statuses = sqliteClient.listStatuses().filter((status) => options.includePrepJobs || status.chain_kind === "chain").filter((status) => !options.epicId || status.epic_id === options.epicId);
26001
+ const byChain = new Map;
26002
+ for (const status of statuses) {
26003
+ const chainId = status.chain_id ?? `job:${status.id}`;
26004
+ const group = byChain.get(chainId) ?? [];
26005
+ group.push(status);
26006
+ byChain.set(chainId, group);
26007
+ }
26008
+ const rows = [];
26009
+ for (const chainStatuses of byChain.values()) {
26010
+ const ordered = [...chainStatuses].sort((a, b) => a.started_at_ms - b.started_at_ms);
26011
+ const executorStatuses = ordered.filter((status) => status.specialist === "executor");
26012
+ const reviewerStatuses = ordered.filter((status) => status.specialist === "reviewer");
26013
+ executorStatuses.forEach((executorStatus, executorIndex) => {
26014
+ const nextExecutor = executorStatuses[executorIndex + 1];
26015
+ const reviewer = reviewerStatuses.find((candidate) => {
26016
+ if (candidate.started_at_ms < executorStatus.started_at_ms)
26017
+ return false;
26018
+ if (!nextExecutor)
26019
+ return true;
26020
+ return candidate.started_at_ms < nextExecutor.started_at_ms;
26021
+ }) ?? null;
26022
+ const runComplete = readLatestRunCompleteEvent(sqliteClient.readEvents(executorStatus.id));
26023
+ const reviewerOutput = reviewer ? sqliteClient.readResult(reviewer.id) : null;
26024
+ const reviewerVerdict = parseReviewerVerdict2(reviewerOutput);
26025
+ const totalTokens = runComplete?.token_usage?.total_tokens ?? runComplete?.metrics?.token_usage?.total_tokens ?? executorStatus.metrics?.token_usage?.total_tokens ?? null;
26026
+ const costUsd = runComplete?.token_usage?.cost_usd ?? runComplete?.metrics?.token_usage?.cost_usd ?? executorStatus.metrics?.token_usage?.cost_usd ?? null;
26027
+ const elapsedMs = runComplete ? Math.round(runComplete.elapsed_s * 1000) : typeof executorStatus.elapsed_s === "number" ? Math.round(executorStatus.elapsed_s * 1000) : null;
26028
+ const hasLaterExecutorInChain = Boolean(nextExecutor);
26029
+ const failureNotes = inferFailureNotes({
26030
+ status: executorStatus,
26031
+ runComplete,
26032
+ reviewerVerdict,
26033
+ hasLaterExecutorInChain
26034
+ });
26035
+ rows.push({
26036
+ task_id: executorStatus.chain_root_bead_id ?? executorStatus.bead_id ?? "unknown_task",
26037
+ model_id: executorStatus.model ?? null,
26038
+ executor_job_id: executorStatus.id,
26039
+ reviewer_job_id: reviewer?.id ?? null,
26040
+ lint_pass: parseGateResult(reviewerOutput, "lint"),
26041
+ tsc_pass: parseGateResult(reviewerOutput, "tsc"),
26042
+ reviewer_verdict: reviewerVerdict,
26043
+ reviewer_score_if_present: parseReviewerScore(reviewerOutput),
26044
+ total_tokens: totalTokens,
26045
+ cost_usd: costUsd,
26046
+ elapsed_ms: elapsedMs,
26047
+ failure_notes: failureNotes,
26048
+ source_of_truth: {
26049
+ task_id: "specialist_jobs.chain_root_bead_id fallback bead_id",
26050
+ model_id: "specialist_jobs.status_json.model",
26051
+ executor_job_id: "specialist_jobs.job_id",
26052
+ reviewer_job_id: "specialist_jobs.job_id where specialist=reviewer in same chain window",
26053
+ lint_pass: "reviewer specialist_results.output regex parse; null when absent",
26054
+ tsc_pass: "reviewer specialist_results.output regex parse; null when absent",
26055
+ reviewer_verdict: "reviewer specialist_results.output Verdict: PASS|PARTIAL|FAIL",
26056
+ reviewer_score_if_present: "reviewer specialist_results.output score regex; null when absent",
26057
+ total_tokens: runComplete ? "specialist_events.type=run_complete.token_usage.total_tokens" : "status_json.metrics.token_usage.total_tokens fallback",
26058
+ cost_usd: runComplete ? "specialist_events.type=run_complete.token_usage.cost_usd" : "status_json.metrics.token_usage.cost_usd fallback",
26059
+ elapsed_ms: runComplete ? "specialist_events.type=run_complete.elapsed_s * 1000" : "status_json.elapsed_s * 1000 fallback",
26060
+ failure_notes: "run_complete.error/status + status_json.error + chain sequencing heuristics"
26061
+ }
26062
+ });
26063
+ });
26064
+ }
26065
+ rows.sort((a, b) => a.task_id.localeCompare(b.task_id) || a.executor_job_id.localeCompare(b.executor_job_id));
26066
+ const outputDirectory = dirname6(options.outputPath);
26067
+ mkdirSync5(outputDirectory, { recursive: true });
26068
+ const jsonl = rows.map((row) => JSON.stringify(row)).join(`
26069
+ `);
26070
+ writeFileSync5(options.outputPath, rows.length > 0 ? `${jsonl}
26071
+ ` : "", "utf-8");
26072
+ console.log(`
26073
+ ${bold7("specialists db benchmark-export")}
26074
+ `);
26075
+ console.log(` ${green5("\u2713")} rows exported: ${rows.length}`);
26076
+ console.log(` ${green5("\u2713")} output: ${options.outputPath}`);
26077
+ if (options.epicId) {
26078
+ console.log(` ${green5("\u2713")} epic filter: ${options.epicId}`);
26079
+ }
26080
+ console.log("");
26081
+ } finally {
26082
+ sqliteClient.close();
26083
+ }
26084
+ }
25242
26085
  function runSetup() {
25243
26086
  const location = resolveObservabilityDbLocation(process.cwd());
25244
26087
  if (isPathInsideJobsDirectory(location.dbPath, location.gitRoot)) {
@@ -25270,17 +26113,32 @@ async function run8(argv = process.argv.slice(3)) {
25270
26113
  runBackfill(options);
25271
26114
  return;
25272
26115
  }
26116
+ if (subcommand === "vacuum") {
26117
+ runVacuum();
26118
+ return;
26119
+ }
26120
+ if (subcommand === "prune") {
26121
+ const options = parsePruneOptions(argv.slice(1));
26122
+ runPrune(options);
26123
+ return;
26124
+ }
26125
+ if (subcommand === "benchmark-export") {
26126
+ const options = parseBenchmarkExportOptions(argv.slice(1));
26127
+ runBenchmarkExport(options);
26128
+ return;
26129
+ }
25273
26130
  console.error(`Unknown db subcommand: '${subcommand}'`);
25274
26131
  printDbHelp();
25275
26132
  process.exit(1);
25276
26133
  }
25277
- var bold7 = (s) => `\x1B[1m${s}\x1B[0m`, green5 = (s) => `\x1B[32m${s}\x1B[0m`, yellow6 = (s) => `\x1B[33m${s}\x1B[0m`;
26134
+ var DAY_MS, bold7 = (s) => `\x1B[1m${s}\x1B[0m`, green5 = (s) => `\x1B[32m${s}\x1B[0m`, yellow6 = (s) => `\x1B[33m${s}\x1B[0m`;
25278
26135
  var init_db = __esm(() => {
25279
26136
  init_observability_db();
25280
26137
  init_job_root();
25281
26138
  init_observability_sqlite();
25282
26139
  init_chain_identity();
25283
26140
  init_timeline_events();
26141
+ DAY_MS = 24 * 60 * 60 * 1000;
25284
26142
  });
25285
26143
 
25286
26144
  // src/cli/validate.ts
@@ -25410,7 +26268,7 @@ __export(exports_edit, {
25410
26268
  run: () => run10
25411
26269
  });
25412
26270
  import { spawnSync as spawnSync10 } from "child_process";
25413
- import { existsSync as existsSync12, readdirSync as readdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
26271
+ import { existsSync as existsSync12, readdirSync as readdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
25414
26272
  import { join as join13 } from "path";
25415
26273
  function loadPresets() {
25416
26274
  const paths = [
@@ -25420,7 +26278,7 @@ function loadPresets() {
25420
26278
  for (const p of paths) {
25421
26279
  if (existsSync12(p)) {
25422
26280
  try {
25423
- const data = JSON.parse(readFileSync8(p, "utf-8"));
26281
+ const data = JSON.parse(readFileSync9(p, "utf-8"));
25424
26282
  return data;
25425
26283
  } catch {
25426
26284
  return {};
@@ -25773,7 +26631,7 @@ function getRawValue(args, resolvedPath) {
25773
26631
  if (!MULTILINE_FILE_PATHS.has(resolvedPath.normalizedPath)) {
25774
26632
  fail(`Error: --file is only supported for: ${Array.from(MULTILINE_FILE_PATHS).join(", ")}`);
25775
26633
  }
25776
- return readFileSync8(args.filePath, "utf-8");
26634
+ return readFileSync9(args.filePath, "utf-8");
25777
26635
  }
25778
26636
  function getAtPath(root, segments) {
25779
26637
  let current = root;
@@ -25883,7 +26741,7 @@ async function run10() {
25883
26741
  }
25884
26742
  const targets2 = await resolveTargets(args);
25885
26743
  for (const target of targets2) {
25886
- const raw = readFileSync8(target.filePath, "utf-8");
26744
+ const raw = readFileSync9(target.filePath, "utf-8");
25887
26745
  const doc2 = JSON.parse(raw);
25888
26746
  for (const [fieldPath, fieldValue] of Object.entries(preset.fields)) {
25889
26747
  const resolved = resolvePath2(fieldPath);
@@ -25897,7 +26755,7 @@ async function run10() {
25897
26755
  printDryRun(target.filePath, raw, updated);
25898
26756
  continue;
25899
26757
  }
25900
- writeFileSync5(target.filePath, updated, "utf-8");
26758
+ writeFileSync6(target.filePath, updated, "utf-8");
25901
26759
  const fieldList = Object.keys(preset.fields).map((f) => yellow8(f)).join(", ");
25902
26760
  console.log(`${green7("\u2713")} ${bold9(target.name)}: applied preset ${bold9(args.preset)} (${fieldList})`);
25903
26761
  }
@@ -25909,7 +26767,7 @@ async function run10() {
25909
26767
  fail("Error: no specialists found");
25910
26768
  }
25911
26769
  for (const target of targets) {
25912
- const raw = readFileSync8(target.filePath, "utf-8");
26770
+ const raw = readFileSync9(target.filePath, "utf-8");
25913
26771
  let doc2;
25914
26772
  try {
25915
26773
  const parsed = JSON.parse(raw);
@@ -25933,7 +26791,7 @@ async function run10() {
25933
26791
  printDryRun(target.filePath, raw, updated);
25934
26792
  continue;
25935
26793
  }
25936
- writeFileSync5(target.filePath, updated, "utf-8");
26794
+ writeFileSync6(target.filePath, updated, "utf-8");
25937
26795
  console.log(`${green7("\u2713")} ${bold9(target.name)}: ${yellow8(resolvedPath.normalizedPath)} = ${formatOutputValue(nextValue)}` + dim7(` (${target.filePath})`));
25938
26796
  }
25939
26797
  }
@@ -26052,8 +26910,8 @@ var init_config = __esm(() => {
26052
26910
  });
26053
26911
 
26054
26912
  // src/specialist/worktree.ts
26055
- import { existsSync as existsSync13, symlinkSync as symlinkSync2, mkdirSync as mkdirSync5 } from "fs";
26056
- import { join as join14, resolve as resolve5 } from "path";
26913
+ import { existsSync as existsSync13, symlinkSync as symlinkSync2, mkdirSync as mkdirSync6 } from "fs";
26914
+ import { join as join14, resolve as resolve6 } from "path";
26057
26915
  import { spawnSync as spawnSync11, execFileSync as execFileSync2 } from "child_process";
26058
26916
  function deriveBranchName(beadId, specialistName) {
26059
26917
  return `feature/${beadId}-${slugify(specialistName)}`;
@@ -26086,11 +26944,11 @@ function provisionWorktree(options) {
26086
26944
  const branch = deriveBranchName(options.beadId, options.specialistName);
26087
26945
  const existingPath = findExistingWorktree(branch, cwd);
26088
26946
  if (existingPath) {
26089
- return { branch, worktreePath: resolve5(existingPath), reused: true };
26947
+ return { branch, worktreePath: resolve6(existingPath), reused: true };
26090
26948
  }
26091
26949
  const worktreeBase = options.worktreeBase ?? join14(commonRoot, ".worktrees", options.beadId);
26092
26950
  const worktreeName = deriveWorktreeName(options.beadId, options.specialistName);
26093
- const worktreePath = resolve5(join14(worktreeBase, worktreeName));
26951
+ const worktreePath = resolve6(join14(worktreeBase, worktreeName));
26094
26952
  createWorktreeViaBd(worktreePath, branch, commonRoot);
26095
26953
  symlinkPiNpmCache(commonRoot, worktreePath);
26096
26954
  return { branch, worktreePath, reused: false };
@@ -26101,7 +26959,7 @@ function symlinkPiNpmCache(commonRoot, worktreePath) {
26101
26959
  if (!existsSync13(source) || existsSync13(target))
26102
26960
  return;
26103
26961
  try {
26104
- mkdirSync5(join14(worktreePath, ".pi"), { recursive: true });
26962
+ mkdirSync6(join14(worktreePath, ".pi"), { recursive: true });
26105
26963
  symlinkSync2(source, target);
26106
26964
  } catch {}
26107
26965
  }
@@ -26161,9 +27019,10 @@ __export(exports_merge, {
26161
27019
  executePublicationPlan: () => executePublicationPlan,
26162
27020
  evaluateMergeWorthiness: () => evaluateMergeWorthiness,
26163
27021
  ensureTerminalJobs: () => ensureTerminalJobs,
26164
- checkEpicUnresolvedGuard: () => checkEpicUnresolvedGuard
27022
+ checkEpicUnresolvedGuard: () => checkEpicUnresolvedGuard,
27023
+ assertMainRepoCleanForMerge: () => assertMainRepoCleanForMerge
26165
27024
  });
26166
- import { existsSync as existsSync14, readdirSync as readdirSync6, readFileSync as readFileSync9 } from "fs";
27025
+ import { existsSync as existsSync14, readdirSync as readdirSync6, readFileSync as readFileSync10 } from "fs";
26167
27026
  import { spawnSync as spawnSync12 } from "child_process";
26168
27027
  import { join as join15 } from "path";
26169
27028
  function parseOptions(argv) {
@@ -26353,7 +27212,7 @@ function readAllJobStatuses() {
26353
27212
  const statusPath = join15(jobsDir, entry.name, "status.json");
26354
27213
  if (!existsSync14(statusPath))
26355
27214
  continue;
26356
- const parsed = readJson(readFileSync9(statusPath, "utf-8"));
27215
+ const parsed = readJson(readFileSync10(statusPath, "utf-8"));
26357
27216
  if (!parsed || typeof parsed !== "object")
26358
27217
  continue;
26359
27218
  statuses.push(parsed);
@@ -26480,6 +27339,29 @@ function readChangedFilesForLastMerge(cwd = process.cwd()) {
26480
27339
  return diff.stdout.split(`
26481
27340
  `).map((line) => line.trim()).filter(Boolean);
26482
27341
  }
27342
+ function isMergeDirtyIgnored(path) {
27343
+ return MERGE_DIRTY_IGNORE_PREFIXES.some((prefix) => path.startsWith(prefix));
27344
+ }
27345
+ function assertMainRepoCleanForMerge(cwd) {
27346
+ const status = runCommand("git", ["status", "--porcelain", "--untracked-files=no"], cwd);
27347
+ if (status.status !== 0) {
27348
+ throw new Error(`Unable to read git status in '${cwd}'.`);
27349
+ }
27350
+ const dirty = status.stdout.split(`
27351
+ `).map((line) => line.trim()).filter(Boolean).map((line) => {
27352
+ const match = /^..\s(.+?)(?:\s->\s(.+))?$/.exec(line);
27353
+ const path = match ? match[2] ?? match[1] ?? "" : line.slice(3).trim();
27354
+ return path.trim();
27355
+ }).filter((path) => path && !isMergeDirtyIgnored(path));
27356
+ if (dirty.length === 0)
27357
+ return;
27358
+ const list = dirty.map((path) => `- ${path}`).join(`
27359
+ `);
27360
+ throw new Error(`Refusing merge: main repo '${cwd}' has uncommitted changes that could cause spurious conflicts.
27361
+ ` + `Dirty files (tracked, non-dist):
27362
+ ${list}
27363
+ ` + `Resolve by committing, stashing, or reverting these changes, then retry merge.`);
27364
+ }
26483
27365
  function parseNameStatusLine(line) {
26484
27366
  const trimmed = line.trim();
26485
27367
  if (!trimmed)
@@ -26648,6 +27530,7 @@ function printUsageAndExit(message) {
26648
27530
  }
26649
27531
  function runMergePlan(targets, options) {
26650
27532
  const mainRepoRoot = resolveMainWorktreeRoot();
27533
+ assertMainRepoCleanForMerge(mainRepoRoot);
26651
27534
  const mergedSteps = [];
26652
27535
  for (const target of targets) {
26653
27536
  rebaseBranchOntoMaster(target.branch, target.worktreePath);
@@ -26740,13 +27623,17 @@ async function run12() {
26740
27623
  const mergedSteps = runMergePlan(targets, { rebuild: options.rebuild });
26741
27624
  printSummary(mergedSteps, options.rebuild);
26742
27625
  }
26743
- var TERMINAL_STATUSES, NOISE_PATH_PREFIXES;
27626
+ var TERMINAL_STATUSES, NOISE_PATH_PREFIXES, MERGE_DIRTY_IGNORE_PREFIXES;
26744
27627
  var init_merge = __esm(() => {
26745
27628
  init_job_root();
26746
27629
  init_observability_sqlite();
26747
27630
  init_epic_lifecycle();
26748
27631
  TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]);
26749
27632
  NOISE_PATH_PREFIXES = [".xtrm/reports/", ".wolf/", ".specialists/jobs/"];
27633
+ MERGE_DIRTY_IGNORE_PREFIXES = [
27634
+ ...NOISE_PATH_PREFIXES,
27635
+ "dist/"
27636
+ ];
26750
27637
  });
26751
27638
 
26752
27639
  // src/cli/format-helpers.ts
@@ -26844,6 +27731,9 @@ function formatEventLine(event, options) {
26844
27731
  detailParts.push(`backend=${event.backend}`);
26845
27732
  } else if (event.type === "tool") {
26846
27733
  detail = formatToolDetail(event);
27734
+ } else if (event.type === "error") {
27735
+ detailParts.push(`source=${event.source}`);
27736
+ detailParts.push(`error=${event.error_message}`);
26847
27737
  } else if (event.type === "run_complete") {
26848
27738
  detailParts.push(`status=${event.status}`);
26849
27739
  detailParts.push(`elapsed=${formatElapsed(event.elapsed_s)}`);
@@ -26933,6 +27823,8 @@ function formatEventInline(event) {
26933
27823
  }
26934
27824
  case "stale_warning":
26935
27825
  return yellow10(`[warning] ${event.reason}: ${Math.round(event.silence_ms / 1000)}s silent`);
27826
+ case "error":
27827
+ return red2(`[error] ${event.source}: ${event.error_message}`);
26936
27828
  default:
26937
27829
  return null;
26938
27830
  }
@@ -26966,7 +27858,7 @@ var init_format_helpers = __esm(() => {
26966
27858
  turn_summary: "TURN+",
26967
27859
  compaction: "CMPCT",
26968
27860
  retry: "RETRY",
26969
- error: "ERR"
27861
+ error: "ERROR"
26970
27862
  };
26971
27863
  });
26972
27864
 
@@ -26976,7 +27868,7 @@ __export(exports_run, {
26976
27868
  run: () => run13
26977
27869
  });
26978
27870
  import { join as join16 } from "path";
26979
- import { readFileSync as readFileSync10 } from "fs";
27871
+ import { readFileSync as readFileSync11 } from "fs";
26980
27872
  import { randomBytes } from "crypto";
26981
27873
  import { spawn as cpSpawn, execSync as execSync3 } from "child_process";
26982
27874
  async function parseArgs6(argv) {
@@ -27091,13 +27983,13 @@ async function parseArgs6(argv) {
27091
27983
  process.exit(1);
27092
27984
  }
27093
27985
  if (!prompt && !beadId && !process.stdin.isTTY) {
27094
- prompt = await new Promise((resolve6) => {
27986
+ prompt = await new Promise((resolve7) => {
27095
27987
  let buf = "";
27096
27988
  process.stdin.setEncoding("utf-8");
27097
27989
  process.stdin.on("data", (chunk) => {
27098
27990
  buf += chunk;
27099
27991
  });
27100
- process.stdin.on("end", () => resolve6(buf.trim()));
27992
+ process.stdin.on("end", () => resolve7(buf.trim()));
27101
27993
  });
27102
27994
  }
27103
27995
  if (!prompt && !beadId && !reuseJobId) {
@@ -27255,7 +28147,7 @@ function startEventTailer(jobId, jobsDir, mode, specialist, beadId) {
27255
28147
  const drain = () => {
27256
28148
  let content;
27257
28149
  try {
27258
- content = readFileSync10(eventsPath, "utf-8");
28150
+ content = readFileSync11(eventsPath, "utf-8");
27259
28151
  } catch {
27260
28152
  return;
27261
28153
  }
@@ -27311,9 +28203,26 @@ function formatFooterModel(backend, model) {
27311
28203
  return model;
27312
28204
  return model.startsWith(`${backend}/`) ? model : `${backend}/${model}`;
27313
28205
  }
27314
- function shellQuote(value) {
28206
+ function shellQuote2(value) {
27315
28207
  return `'${value.replace(/'/g, `'\\''`)}'`;
27316
28208
  }
28209
+ function extractReviewedJobIdOverride(prompt) {
28210
+ const match = prompt.match(/(?:^|\n)\s*reviewed_job_id\s*:\s*([^\n]+)/i);
28211
+ const candidate = match?.[1]?.trim();
28212
+ return candidate ? candidate : undefined;
28213
+ }
28214
+ function buildReusedWorktreeAwarenessBlock(options) {
28215
+ const owner = options.worktreeOwnerJobId ?? options.reusedFromJobId;
28216
+ return [
28217
+ "## Reused workspace awareness (from --job)",
28218
+ `You are entering an existing worktree reused from job: ${options.reusedFromJobId}.`,
28219
+ `Worktree chain owner job: ${owner}.`,
28220
+ "Workspace may contain uncommitted edits, staged changes, generated files, or partial fixes from prior handoff steps.",
28221
+ "Before edits, run and inspect: git status --short --branch, git diff --stat, git diff --cached --stat.",
28222
+ "Treat existing tree state as real input context \u2014 do not assume clean baseline."
28223
+ ].join(`
28224
+ `);
28225
+ }
27317
28226
  async function run13() {
27318
28227
  const args = await parseArgs6(process.argv.slice(3));
27319
28228
  const loader = new SpecialistLoader;
@@ -27339,14 +28248,14 @@ async function run13() {
27339
28248
  const latestPath = join16(jobsDir2, "latest");
27340
28249
  const oldLatest = (() => {
27341
28250
  try {
27342
- return readFileSync10(latestPath, "utf-8").trim();
28251
+ return readFileSync11(latestPath, "utf-8").trim();
27343
28252
  } catch {
27344
28253
  return "";
27345
28254
  }
27346
28255
  })();
27347
28256
  const cwd = process.cwd();
27348
28257
  const innerArgs = process.argv.slice(2).filter((a) => a !== "--background");
27349
- const cmd = `${process.execPath} ${process.argv[1]} ${innerArgs.map(shellQuote).join(" ")}`;
28258
+ const cmd = `${process.execPath} ${process.argv[1]} ${innerArgs.map(shellQuote2).join(" ")}`;
27350
28259
  let childPid;
27351
28260
  if (isTmuxAvailable()) {
27352
28261
  const suffix = randomBytes(3).toString("hex");
@@ -27367,7 +28276,7 @@ async function run13() {
27367
28276
  while (Date.now() < deadline) {
27368
28277
  await new Promise((r) => setTimeout(r, 100));
27369
28278
  try {
27370
- const current = readFileSync10(latestPath, "utf-8").trim();
28279
+ const current = readFileSync11(latestPath, "utf-8").trim();
27371
28280
  if (current && current !== oldLatest) {
27372
28281
  jobId2 = current;
27373
28282
  break;
@@ -27449,10 +28358,19 @@ async function run13() {
27449
28358
  } else if (args.epicId) {
27450
28359
  epicId = args.epicId;
27451
28360
  }
28361
+ variables = {
28362
+ ...variables ?? {},
28363
+ reused_worktree_awareness: ""
28364
+ };
27452
28365
  if (args.reuseJobId) {
28366
+ const reviewedJobId = extractReviewedJobIdOverride(prompt) ?? args.reuseJobId;
27453
28367
  variables = {
27454
28368
  ...variables ?? {},
27455
- reviewed_job_id: args.reuseJobId
28369
+ reviewed_job_id: reviewedJobId,
28370
+ reused_worktree_awareness: buildReusedWorktreeAwarenessBlock({
28371
+ reusedFromJobId: args.reuseJobId,
28372
+ worktreeOwnerJobId
28373
+ })
27456
28374
  };
27457
28375
  }
27458
28376
  if (!prompt && !effectiveBeadId) {
@@ -27613,7 +28531,7 @@ var init_node_resolve = __esm(() => {
27613
28531
  });
27614
28532
 
27615
28533
  // src/specialist/job-control.ts
27616
- import { existsSync as existsSync15, readFileSync as readFileSync11, writeFileSync as writeFileSync6 } from "fs";
28534
+ import { existsSync as existsSync15, readFileSync as readFileSync12, writeFileSync as writeFileSync7 } from "fs";
27617
28535
  import { join as join17 } from "path";
27618
28536
 
27619
28537
  class JobControl {
@@ -27645,8 +28563,8 @@ class JobControl {
27645
28563
  }
27646
28564
  };
27647
28565
  let resolveJobId;
27648
- const jobIdPromise = new Promise((resolve6) => {
27649
- resolveJobId = resolve6;
28566
+ const jobIdPromise = new Promise((resolve7) => {
28567
+ resolveJobId = resolve7;
27650
28568
  });
27651
28569
  this.supervisor = new Supervisor({
27652
28570
  runner: this.runner,
@@ -27692,7 +28610,7 @@ class JobControl {
27692
28610
  if (!existsSync15(resultPath))
27693
28611
  return null;
27694
28612
  try {
27695
- return readFileSync11(resultPath, "utf-8");
28613
+ return readFileSync12(resultPath, "utf-8");
27696
28614
  } catch {
27697
28615
  return null;
27698
28616
  }
@@ -27711,7 +28629,7 @@ class JobControl {
27711
28629
  if (deadline !== undefined && Date.now() >= deadline) {
27712
28630
  throw new Error(`Timed out waiting for terminal status for job ${jobId}`);
27713
28631
  }
27714
- await new Promise((resolve6) => setTimeout(resolve6, backoffMs));
28632
+ await new Promise((resolve7) => setTimeout(resolve7, backoffMs));
27715
28633
  backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
27716
28634
  }
27717
28635
  }
@@ -27725,7 +28643,7 @@ class JobControl {
27725
28643
  }
27726
28644
  const jsonLine = `${JSON.stringify(payload)}
27727
28645
  `;
27728
- writeFileSync6(status.fifo_path, jsonLine, { flag: "a" });
28646
+ writeFileSync7(status.fifo_path, jsonLine, { flag: "a" });
27729
28647
  }
27730
28648
  resultPath(jobId) {
27731
28649
  return join17(this.jobsDir, jobId, "result.txt");
@@ -27890,7 +28808,7 @@ function hashOutput(output2, salt) {
27890
28808
  return createHash3("sha256").update(value).digest("hex");
27891
28809
  }
27892
28810
  function sleep2(ms) {
27893
- return new Promise((resolve6) => setTimeout(resolve6, ms));
28811
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
27894
28812
  }
27895
28813
  function toContextHealth(contextPct) {
27896
28814
  if (contextPct === null)
@@ -29803,10 +30721,10 @@ var exports_node = {};
29803
30721
  __export(exports_node, {
29804
30722
  handleNodeCommand: () => handleNodeCommand
29805
30723
  });
29806
- import { existsSync as existsSync16, readFileSync as readFileSync12, readdirSync as readdirSync7 } from "fs";
30724
+ import { existsSync as existsSync16, readFileSync as readFileSync13, readdirSync as readdirSync7 } from "fs";
29807
30725
  import { randomUUID } from "crypto";
29808
30726
  import { spawnSync as spawnSync14 } from "child_process";
29809
- import { basename as basename4, join as join18, resolve as resolve6 } from "path";
30727
+ import { basename as basename4, join as join18, resolve as resolve7 } from "path";
29810
30728
  function parseNodeArgs(argv) {
29811
30729
  const command = argv[0];
29812
30730
  const supportedCommands = new Set(["run", "list", "promote", "members", "memory", "stop", "spawn-member", "create-bead", "complete", "wait-phase"]);
@@ -29990,7 +30908,7 @@ function toNodeName(filePath) {
29990
30908
  function discoverNodeConfigs(cwd) {
29991
30909
  const discoveredByName = new Map;
29992
30910
  for (const directory of NODE_DISCOVERY_DIRS) {
29993
- const absoluteDir = resolve6(cwd, directory.path);
30911
+ const absoluteDir = resolve7(cwd, directory.path);
29994
30912
  if (!existsSync16(absoluteDir))
29995
30913
  continue;
29996
30914
  const files = readdirSync7(absoluteDir).filter((fileName) => fileName.endsWith(NODE_CONFIG_SUFFIX));
@@ -30005,7 +30923,7 @@ function discoverNodeConfigs(cwd) {
30005
30923
  return [...discoveredByName.values()].sort((left, right) => left.name.localeCompare(right.name));
30006
30924
  }
30007
30925
  function resolveNodeConfigPath(cwd, input2) {
30008
- const explicitPath = resolve6(cwd, input2);
30926
+ const explicitPath = resolve7(cwd, input2);
30009
30927
  if (existsSync16(explicitPath)) {
30010
30928
  return explicitPath;
30011
30929
  }
@@ -30101,7 +31019,7 @@ async function handleNodeRun(args) {
30101
31019
  throw new Error("Observability SQLite DB is unavailable. Run: specialists db setup");
30102
31020
  }
30103
31021
  try {
30104
- const rawConfig = args.inlineJson ? args.inlineJson : readFileSync12(resolveNodeConfigPath(process.cwd(), args.nodeConfigInput), "utf-8");
31022
+ const rawConfig = args.inlineJson ? args.inlineJson : readFileSync13(resolveNodeConfigPath(process.cwd(), args.nodeConfigInput), "utf-8");
30105
31023
  const config2 = parseNodeConfig(rawConfig);
30106
31024
  const loader = new SpecialistLoader;
30107
31025
  const runner = new SpecialistRunner({
@@ -30550,7 +31468,7 @@ async function handleNodeAction(args) {
30550
31468
  if (deadline !== null && Date.now() >= deadline) {
30551
31469
  throw new Error(`Timed out waiting for phase '${args.phaseId}' members: ${memberKeys.join(", ")}`);
30552
31470
  }
30553
- await new Promise((resolve7) => setTimeout(resolve7, 500));
31471
+ await new Promise((resolve8) => setTimeout(resolve8, 500));
30554
31472
  }
30555
31473
  } finally {
30556
31474
  sqliteClient.close();
@@ -30608,42 +31526,255 @@ async function handleNodeCommand(argv) {
30608
31526
  await handleNodeMemory(parsed);
30609
31527
  return;
30610
31528
  }
30611
- if (parsed.command === "stop") {
30612
- await handleNodeStop(parsed);
30613
- return;
31529
+ if (parsed.command === "stop") {
31530
+ await handleNodeStop(parsed);
31531
+ return;
31532
+ }
31533
+ if (parsed.command === "spawn-member" || parsed.command === "create-bead" || parsed.command === "complete" || parsed.command === "wait-phase") {
31534
+ await handleNodeAction(parsed);
31535
+ return;
31536
+ }
31537
+ emitNodeCommandError(`Unsupported node command: ${parsed.command}`, parsed.jsonMode);
31538
+ }
31539
+ var NODE_CONFIG_SUFFIX = ".node.json", NODE_DISCOVERY_DIRS;
31540
+ var init_node = __esm(() => {
31541
+ init_loader();
31542
+ init_runner();
31543
+ init_circuitBreaker();
31544
+ init_hooks();
31545
+ init_observability_sqlite();
31546
+ init_beads();
31547
+ init_supervisor();
31548
+ init_job_root();
31549
+ init_node_resolve();
31550
+ init_node_supervisor();
31551
+ NODE_DISCOVERY_DIRS = [
31552
+ { path: ".specialists/default/nodes", source: "default" },
31553
+ { path: "config/nodes", source: "project" }
31554
+ ];
31555
+ });
31556
+
31557
+ // src/specialist/epic-reconciler.ts
31558
+ import { mkdirSync as mkdirSync7, openSync as openSync2, readFileSync as readFileSync14, rmSync as rmSync2, writeFileSync as writeFileSync8 } from "fs";
31559
+ import { join as join19 } from "path";
31560
+ function buildEpicLockPath(epicId) {
31561
+ const location = resolveObservabilityDbLocation();
31562
+ const lockDir = join19(location.dbDirectory, "locks");
31563
+ mkdirSync7(lockDir, { recursive: true });
31564
+ return join19(lockDir, `epic-${epicId}.lock`);
31565
+ }
31566
+ function withEpicAdvisoryLock(epicId, action) {
31567
+ const lockPath = buildEpicLockPath(epicId);
31568
+ let lockFd = null;
31569
+ try {
31570
+ lockFd = openSync2(lockPath, "wx");
31571
+ writeFileSync8(lockPath, JSON.stringify({ epic_id: epicId, pid: process.pid, created_at_ms: Date.now() }));
31572
+ } catch {
31573
+ let holder = "unknown";
31574
+ try {
31575
+ holder = readFileSync14(lockPath, "utf-8");
31576
+ } catch {
31577
+ holder = "unknown";
31578
+ }
31579
+ throw new Error(`Epic advisory lock busy for ${epicId}. Holder: ${holder}`);
31580
+ }
31581
+ try {
31582
+ return action();
31583
+ } finally {
31584
+ if (lockFd !== null) {
31585
+ try {
31586
+ rmSync2(lockPath, { force: true });
31587
+ } catch {}
31588
+ }
31589
+ }
31590
+ }
31591
+ function hasRedirectMarkers(statusJson) {
31592
+ if (!statusJson)
31593
+ return false;
31594
+ try {
31595
+ const parsed = JSON.parse(statusJson);
31596
+ return Object.keys(parsed).some((key) => key.toLowerCase().includes("redirect"));
31597
+ } catch {
31598
+ return false;
31599
+ }
31600
+ }
31601
+ function clearRedirectMarkers(statusJson) {
31602
+ if (!statusJson)
31603
+ return statusJson;
31604
+ try {
31605
+ const parsed = JSON.parse(statusJson);
31606
+ const cleanedEntries = Object.entries(parsed).filter(([key]) => !key.toLowerCase().includes("redirect"));
31607
+ return JSON.stringify(Object.fromEntries(cleanedEntries));
31608
+ } catch {
31609
+ return statusJson;
31610
+ }
31611
+ }
31612
+ function collectEpicJobs(sqlite, epicId) {
31613
+ const chainIds = new Set(sqlite.listEpicChains(epicId).map((chain) => chain.chain_id));
31614
+ return sqlite.listStatuses().filter((status) => status.epic_id === epicId || (status.chain_id ? chainIds.has(status.chain_id) : false));
31615
+ }
31616
+ function detectStaleChainRefs(sqlite, epicId) {
31617
+ return sqlite.listEpicChains(epicId).map((chain) => chain.chain_id).filter((chainId) => sqlite.listChainJobIds(chainId).length === 0);
31618
+ }
31619
+ function detectDeadBlockingJobs(jobs) {
31620
+ return jobs.filter((job) => ACTIVE_JOB_STATUSES2.has(job.status) && isJobDead(job)).map((job) => job.id);
31621
+ }
31622
+ function detectIntegrityFlags(sqlite, epicId, jobs) {
31623
+ const chainIds = new Set(sqlite.listEpicChains(epicId).map((chain) => chain.chain_id));
31624
+ const flags = [];
31625
+ for (const job of jobs) {
31626
+ if (job.chain_kind === "chain" && !job.chain_id) {
31627
+ flags.push(`job:${job.id}:chain_kind=chain missing chain_id`);
31628
+ }
31629
+ if (job.chain_id && !chainIds.has(job.chain_id) && job.epic_id === epicId) {
31630
+ flags.push(`job:${job.id}:references chain ${job.chain_id} missing from epic membership`);
31631
+ }
31632
+ if (job.chain_id && chainIds.has(job.chain_id) && job.epic_id && job.epic_id !== epicId) {
31633
+ flags.push(`job:${job.id}:chain ${job.chain_id} linked to epic ${job.epic_id}, expected ${epicId}`);
31634
+ }
31635
+ }
31636
+ return flags;
31637
+ }
31638
+ function markDeadJobsAsError(sqlite, jobs) {
31639
+ const deadBlockingIds = detectDeadBlockingJobs(jobs);
31640
+ const now = Date.now();
31641
+ for (const jobId of deadBlockingIds) {
31642
+ const current = jobs.find((job) => job.id === jobId);
31643
+ if (!current)
31644
+ continue;
31645
+ sqlite.upsertStatus({
31646
+ ...current,
31647
+ status: "error",
31648
+ error: current.error ?? "epic reconciler: detected dead pid/tmux for active job",
31649
+ last_event_at_ms: now
31650
+ });
31651
+ }
31652
+ return deadBlockingIds;
31653
+ }
31654
+ function syncEpicState(sqlite, epicId, apply) {
31655
+ const epicRun = sqlite.readEpicRun(epicId);
31656
+ const jobs = collectEpicJobs(sqlite, epicId);
31657
+ const readinessBefore = loadEpicReadinessSummary(sqlite, epicId);
31658
+ const drift = {
31659
+ stale_chain_refs: detectStaleChainRefs(sqlite, epicId),
31660
+ dead_jobs_blocking_readiness: detectDeadBlockingJobs(jobs),
31661
+ integrity_flags: detectIntegrityFlags(sqlite, epicId, jobs),
31662
+ stale_redirect_markers: epicRun && hasRedirectMarkers(epicRun.status_json) ? [epicId] : []
31663
+ };
31664
+ let deadJobsMarkedError = [];
31665
+ let staleChainRefsPruned = [];
31666
+ let readinessResynced = false;
31667
+ let redirectMarkersCleared = false;
31668
+ if (apply) {
31669
+ if (drift.dead_jobs_blocking_readiness.length > 0) {
31670
+ deadJobsMarkedError = markDeadJobsAsError(sqlite, jobs);
31671
+ }
31672
+ if (drift.stale_chain_refs.length > 0) {
31673
+ staleChainRefsPruned = sqlite.deleteEpicChainMembership(epicId, drift.stale_chain_refs);
31674
+ }
31675
+ const readinessNext = loadEpicReadinessSummary(sqlite, epicId);
31676
+ const synced = syncEpicStateFromReadiness(sqlite, readinessNext);
31677
+ readinessResynced = synced.status !== readinessNext.persisted_state;
31678
+ if (epicRun && drift.stale_redirect_markers.length > 0) {
31679
+ const cleaned = clearRedirectMarkers(epicRun.status_json);
31680
+ if (cleaned && cleaned !== epicRun.status_json) {
31681
+ sqlite.upsertEpicRun({
31682
+ ...epicRun,
31683
+ status_json: cleaned,
31684
+ updated_at_ms: Date.now()
31685
+ });
31686
+ redirectMarkersCleared = true;
31687
+ }
31688
+ }
31689
+ }
31690
+ const readinessAfter = loadEpicReadinessSummary(sqlite, epicId);
31691
+ return {
31692
+ epic_id: epicId,
31693
+ apply,
31694
+ drift,
31695
+ repairs: {
31696
+ dead_jobs_marked_error: deadJobsMarkedError,
31697
+ stale_chain_refs_pruned: staleChainRefsPruned,
31698
+ readiness_resynced: readinessResynced,
31699
+ redirect_markers_cleared: redirectMarkersCleared
31700
+ },
31701
+ readiness_before: readinessBefore,
31702
+ readiness_after: readinessAfter
31703
+ };
31704
+ }
31705
+ function listLiveMemberJobIds(sqlite, epicId) {
31706
+ return collectEpicJobs(sqlite, epicId).filter((job) => ACTIVE_JOB_STATUSES2.has(job.status) && !isJobDead(job)).map((job) => job.id);
31707
+ }
31708
+ function buildAbandonedStatusJson(epic, epicId, fromState, reason, forced) {
31709
+ const base = appendEpicTransitionAudit(epic?.status_json, {
31710
+ from: fromState,
31711
+ to: "abandoned",
31712
+ at_ms: Date.now(),
31713
+ reason,
31714
+ trigger: "sp epic abandon",
31715
+ forced
31716
+ });
31717
+ try {
31718
+ const parsed = JSON.parse(base);
31719
+ return JSON.stringify({
31720
+ ...parsed,
31721
+ epic_id: epicId,
31722
+ status: "abandoned",
31723
+ reason,
31724
+ forced
31725
+ });
31726
+ } catch {
31727
+ return base;
31728
+ }
31729
+ }
31730
+ function abandonEpic(sqlite, epicId, reason, force) {
31731
+ const epic = sqlite.readEpicRun(epicId);
31732
+ const fromState = epic?.status ?? "open";
31733
+ if (fromState === "merged") {
31734
+ throw new Error(`Epic ${epicId} already merged. Abandon blocked.`);
31735
+ }
31736
+ if (fromState === "failed" || fromState === "abandoned") {
31737
+ throw new Error(`Epic ${epicId} already terminal in state '${fromState}'.`);
30614
31738
  }
30615
- if (parsed.command === "spawn-member" || parsed.command === "create-bead" || parsed.command === "complete" || parsed.command === "wait-phase") {
30616
- await handleNodeAction(parsed);
30617
- return;
31739
+ const liveMemberJobIds = listLiveMemberJobIds(sqlite, epicId);
31740
+ if (!force && liveMemberJobIds.length > 0) {
31741
+ throw new Error(`Epic ${epicId} has live members: ${liveMemberJobIds.join(", ")}. Retry with --force to abandon anyway.`);
30618
31742
  }
30619
- emitNodeCommandError(`Unsupported node command: ${parsed.command}`, parsed.jsonMode);
31743
+ const statusJson = buildAbandonedStatusJson(epic, epicId, fromState, reason, force);
31744
+ sqlite.upsertEpicRun({
31745
+ epic_id: epicId,
31746
+ status: "abandoned",
31747
+ status_json: statusJson,
31748
+ updated_at_ms: Date.now()
31749
+ });
31750
+ return {
31751
+ epic_id: epicId,
31752
+ from_state: fromState,
31753
+ to_state: "abandoned",
31754
+ forced: force,
31755
+ reason,
31756
+ live_member_job_ids: liveMemberJobIds
31757
+ };
30620
31758
  }
30621
- var NODE_CONFIG_SUFFIX = ".node.json", NODE_DISCOVERY_DIRS;
30622
- var init_node = __esm(() => {
30623
- init_loader();
30624
- init_runner();
30625
- init_circuitBreaker();
30626
- init_hooks();
30627
- init_observability_sqlite();
30628
- init_beads();
31759
+ var ACTIVE_JOB_STATUSES2;
31760
+ var init_epic_reconciler = __esm(() => {
31761
+ init_epic_lifecycle();
31762
+ init_epic_readiness();
31763
+ init_observability_db();
30629
31764
  init_supervisor();
30630
- init_job_root();
30631
- init_node_resolve();
30632
- init_node_supervisor();
30633
- NODE_DISCOVERY_DIRS = [
30634
- { path: ".specialists/default/nodes", source: "default" },
30635
- { path: "config/nodes", source: "project" }
30636
- ];
31765
+ ACTIVE_JOB_STATUSES2 = new Set(["starting", "running", "waiting"]);
30637
31766
  });
30638
31767
 
30639
31768
  // src/cli/epic.ts
30640
31769
  var exports_epic = {};
30641
31770
  __export(exports_epic, {
31771
+ handleEpicSyncCommand: () => handleEpicSyncCommand,
30642
31772
  handleEpicStatusCommand: () => handleEpicStatusCommand,
30643
31773
  handleEpicResolveCommand: () => handleEpicResolveCommand,
30644
31774
  handleEpicMergeCommand: () => handleEpicMergeCommand,
30645
31775
  handleEpicListCommand: () => handleEpicListCommand,
30646
- handleEpicCommand: () => handleEpicCommand
31776
+ handleEpicCommand: () => handleEpicCommand,
31777
+ handleEpicAbandonCommand: () => handleEpicAbandonCommand
30647
31778
  });
30648
31779
  import { spawnSync as spawnSync15 } from "child_process";
30649
31780
  function runCommand2(command, args, cwd = process.cwd()) {
@@ -30743,6 +31874,65 @@ function parseResolveOptions(argv) {
30743
31874
  }
30744
31875
  return { epicId, dryRun, json };
30745
31876
  }
31877
+ function parseSyncOptions(argv) {
31878
+ const epicId = parseEpicId(argv);
31879
+ let apply = false;
31880
+ let json = false;
31881
+ for (const argument of argv) {
31882
+ if (argument === "--apply") {
31883
+ apply = true;
31884
+ continue;
31885
+ }
31886
+ if (argument === "--json") {
31887
+ json = true;
31888
+ continue;
31889
+ }
31890
+ if (argument.startsWith("-") && argument !== "--apply" && argument !== "--json") {
31891
+ throw new Error(`Unknown option: ${argument}`);
31892
+ }
31893
+ }
31894
+ return { epicId, apply, json };
31895
+ }
31896
+ function parseAbandonOptions(argv) {
31897
+ let epicId = "";
31898
+ let reason = "";
31899
+ let force = false;
31900
+ let json = false;
31901
+ for (let index = 0;index < argv.length; index += 1) {
31902
+ const argument = argv[index];
31903
+ if (argument === "--force") {
31904
+ force = true;
31905
+ continue;
31906
+ }
31907
+ if (argument === "--json") {
31908
+ json = true;
31909
+ continue;
31910
+ }
31911
+ if (argument === "--reason") {
31912
+ const value = argv[index + 1];
31913
+ if (!value || value.startsWith("-")) {
31914
+ throw new Error("Missing value for --reason");
31915
+ }
31916
+ reason = value.trim();
31917
+ index += 1;
31918
+ continue;
31919
+ }
31920
+ if (argument.startsWith("-")) {
31921
+ throw new Error(`Unknown option: ${argument}`);
31922
+ }
31923
+ if (epicId.length > 0) {
31924
+ throw new Error("Only one epic ID is supported");
31925
+ }
31926
+ epicId = argument;
31927
+ }
31928
+ if (!epicId) {
31929
+ throw new Error("Missing epic ID");
31930
+ }
31931
+ if (reason.length === 0) {
31932
+ throw new Error("Missing required --reason <text>");
31933
+ }
31934
+ return { epicId, reason, force, json };
31935
+ }
30746
31936
  function readEpicChildrenFromBeads(epicId) {
30747
31937
  const result = runCommand2("bd", ["children", epicId]);
30748
31938
  if (result.status !== 0) {
@@ -31106,6 +32296,94 @@ async function handleEpicMergeCommand(argv) {
31106
32296
  process.exit(1);
31107
32297
  }
31108
32298
  }
32299
+ async function handleEpicSyncCommand(argv) {
32300
+ let options;
32301
+ try {
32302
+ options = parseSyncOptions(argv);
32303
+ } catch (error2) {
32304
+ const message = error2 instanceof Error ? error2.message : String(error2);
32305
+ console.error(message);
32306
+ console.error("Usage: specialists epic sync <epic-id> [--apply] [--json]");
32307
+ process.exit(1);
32308
+ }
32309
+ const sqlite = createObservabilitySqliteClient();
32310
+ if (!sqlite) {
32311
+ const message = "Observability SQLite database not available. Run `sp db setup` first.";
32312
+ if (options.json) {
32313
+ console.log(JSON.stringify({ error: message }, null, 2));
32314
+ } else {
32315
+ console.error(message);
32316
+ }
32317
+ process.exit(1);
32318
+ }
32319
+ try {
32320
+ const result = withEpicAdvisoryLock(options.epicId, () => syncEpicState(sqlite, options.epicId, options.apply));
32321
+ if (options.json) {
32322
+ console.log(JSON.stringify(result, null, 2));
32323
+ return;
32324
+ }
32325
+ console.log("");
32326
+ console.log(`Epic ${result.epic_id} sync (${result.apply ? "apply" : "dry-run"})`);
32327
+ console.log(` stale_chain_refs: ${result.drift.stale_chain_refs.length}`);
32328
+ console.log(` dead_jobs_blocking_readiness: ${result.drift.dead_jobs_blocking_readiness.length}`);
32329
+ console.log(` integrity_flags: ${result.drift.integrity_flags.length}`);
32330
+ console.log(` stale_redirect_markers: ${result.drift.stale_redirect_markers.length}`);
32331
+ if (result.apply) {
32332
+ console.log(` repaired_dead_jobs: ${result.repairs.dead_jobs_marked_error.length}`);
32333
+ console.log(` stale_chain_refs_pruned: ${result.repairs.stale_chain_refs_pruned.length}`);
32334
+ console.log(` readiness_resynced: ${result.repairs.readiness_resynced}`);
32335
+ console.log(` redirect_markers_cleared: ${result.repairs.redirect_markers_cleared}`);
32336
+ }
32337
+ console.log(` readiness_before: ${result.readiness_before.readiness_state}`);
32338
+ console.log(` readiness_after: ${result.readiness_after.readiness_state}`);
32339
+ console.log("");
32340
+ } finally {
32341
+ sqlite.close();
32342
+ }
32343
+ }
32344
+ async function handleEpicAbandonCommand(argv) {
32345
+ let options;
32346
+ try {
32347
+ options = parseAbandonOptions(argv);
32348
+ } catch (error2) {
32349
+ const message = error2 instanceof Error ? error2.message : String(error2);
32350
+ console.error(message);
32351
+ console.error("Usage: specialists epic abandon <epic-id> --reason <text> [--force] [--json]");
32352
+ process.exit(1);
32353
+ }
32354
+ const sqlite = createObservabilitySqliteClient();
32355
+ if (!sqlite) {
32356
+ const message = "Observability SQLite database not available. Run `sp db setup` first.";
32357
+ if (options.json) {
32358
+ console.log(JSON.stringify({ error: message }, null, 2));
32359
+ } else {
32360
+ console.error(message);
32361
+ }
32362
+ process.exit(1);
32363
+ }
32364
+ try {
32365
+ const result = withEpicAdvisoryLock(options.epicId, () => abandonEpic(sqlite, options.epicId, options.reason, options.force));
32366
+ if (options.json) {
32367
+ console.log(JSON.stringify(result, null, 2));
32368
+ return;
32369
+ }
32370
+ console.log(`Epic ${result.epic_id}: ${result.from_state} -> ${result.to_state}`);
32371
+ console.log(`Reason: ${result.reason}`);
32372
+ if (result.forced) {
32373
+ console.log("Mode: forced");
32374
+ }
32375
+ } catch (error2) {
32376
+ const message = error2 instanceof Error ? error2.message : String(error2);
32377
+ if (options.json) {
32378
+ console.log(JSON.stringify({ epic_id: options.epicId, error: message }, null, 2));
32379
+ } else {
32380
+ console.error(message);
32381
+ }
32382
+ process.exit(1);
32383
+ } finally {
32384
+ sqlite.close();
32385
+ }
32386
+ }
31109
32387
  async function handleEpicStatusCommand(argv) {
31110
32388
  let options;
31111
32389
  try {
@@ -31190,12 +32468,14 @@ async function handleEpicCommand(argv) {
31190
32468
  if (!subcommand || subcommand === "--help" || subcommand === "-h") {
31191
32469
  console.log([
31192
32470
  "",
31193
- "Usage: specialists epic <list|status|resolve|merge> [options]",
32471
+ "Usage: specialists epic <list|status|resolve|sync|abandon|merge> [options]",
31194
32472
  "",
31195
32473
  "Commands:",
31196
32474
  " list [--unresolved] [--json] List epics with lifecycle and readiness summary",
31197
32475
  " status <epic-id> [--json] Show epic state, chain statuses, and merge readiness",
31198
32476
  " resolve <epic-id> [--dry-run] [--json] Transition epic from open to resolving",
32477
+ " sync <epic-id> [--apply] [--json] Reconcile epic drift (dry-run by default)",
32478
+ " abandon <epic-id> --reason <text> [--force] [--json] Transition epic to abandoned",
31199
32479
  " merge <epic-id> [--rebuild] [--pr] [--json] Publish epic-owned chains in dependency order",
31200
32480
  "",
31201
32481
  "Epic lifecycle states:",
@@ -31215,6 +32495,9 @@ async function handleEpicCommand(argv) {
31215
32495
  " specialists epic list --unresolved --json",
31216
32496
  " specialists epic resolve unitAI-3f7b",
31217
32497
  " specialists epic status unitAI-3f7b --json",
32498
+ " specialists epic sync unitAI-3f7b",
32499
+ " specialists epic sync unitAI-3f7b --apply",
32500
+ ' specialists epic abandon unitAI-3f7b --reason "scope changed"',
31218
32501
  " specialists epic merge unitAI-3f7b --rebuild",
31219
32502
  " specialists epic merge unitAI-3f7b --pr",
31220
32503
  ""
@@ -31230,6 +32513,14 @@ async function handleEpicCommand(argv) {
31230
32513
  await handleEpicResolveCommand(argv.slice(1));
31231
32514
  return;
31232
32515
  }
32516
+ if (subcommand === "sync") {
32517
+ await handleEpicSyncCommand(argv.slice(1));
32518
+ return;
32519
+ }
32520
+ if (subcommand === "abandon") {
32521
+ await handleEpicAbandonCommand(argv.slice(1));
32522
+ return;
32523
+ }
31233
32524
  if (subcommand === "merge") {
31234
32525
  await handleEpicMergeCommand(argv.slice(1));
31235
32526
  return;
@@ -31239,12 +32530,13 @@ async function handleEpicCommand(argv) {
31239
32530
  return;
31240
32531
  }
31241
32532
  console.error(`Unknown epic subcommand: ${subcommand}`);
31242
- console.error("Usage: specialists epic <list|status|resolve|merge>");
32533
+ console.error("Usage: specialists epic <list|status|resolve|sync|abandon|merge>");
31243
32534
  process.exit(1);
31244
32535
  }
31245
32536
  var RUNNING_STATUSES;
31246
32537
  var init_epic = __esm(() => {
31247
32538
  init_epic_lifecycle();
32539
+ init_epic_reconciler();
31248
32540
  init_observability_sqlite();
31249
32541
  init_merge();
31250
32542
  RUNNING_STATUSES = new Set(["starting", "running", "waiting", "degraded"]);
@@ -31256,8 +32548,8 @@ __export(exports_status, {
31256
32548
  run: () => run14
31257
32549
  });
31258
32550
  import { spawnSync as spawnSync16 } from "child_process";
31259
- import { existsSync as existsSync17, readFileSync as readFileSync13 } from "fs";
31260
- import { join as join19 } from "path";
32551
+ import { existsSync as existsSync17, readFileSync as readFileSync15 } from "fs";
32552
+ import { join as join20 } from "path";
31261
32553
  function ok2(msg) {
31262
32554
  console.log(` ${green8("\u2713")} ${msg}`);
31263
32555
  }
@@ -31348,10 +32640,10 @@ function countJobEvents(sqliteClient, jobsDir, jobId) {
31348
32640
  } catch (error2) {
31349
32641
  console.warn(`SQLite events read failed for job ${jobId}; falling back to events.jsonl`, error2);
31350
32642
  }
31351
- const eventsFile = join19(jobsDir, jobId, "events.jsonl");
32643
+ const eventsFile = join20(jobsDir, jobId, "events.jsonl");
31352
32644
  if (!existsSync17(eventsFile))
31353
32645
  return 0;
31354
- const raw = readFileSync13(eventsFile, "utf-8").trim();
32646
+ const raw = readFileSync15(eventsFile, "utf-8").trim();
31355
32647
  if (!raw)
31356
32648
  return 0;
31357
32649
  return raw.split(`
@@ -31382,10 +32674,10 @@ function getLatestContextSnapshot(sqliteClient, jobsDir, jobId) {
31382
32674
  } catch (error2) {
31383
32675
  console.warn(`SQLite events read failed for job ${jobId}; falling back to events.jsonl`, error2);
31384
32676
  }
31385
- const eventsFile = join19(jobsDir, jobId, "events.jsonl");
32677
+ const eventsFile = join20(jobsDir, jobId, "events.jsonl");
31386
32678
  if (!existsSync17(eventsFile))
31387
32679
  return null;
31388
- const lines = readFileSync13(eventsFile, "utf-8").split(`
32680
+ const lines = readFileSync15(eventsFile, "utf-8").split(`
31389
32681
  `);
31390
32682
  for (let index = lines.length - 1;index >= 0; index -= 1) {
31391
32683
  const line = lines[index].trim();
@@ -31490,7 +32782,7 @@ async function run14() {
31490
32782
  `).slice(1).map((line) => line.split(/\s+/)[0]).filter(Boolean)) : new Set;
31491
32783
  const bdInstalled = isInstalled2("bd");
31492
32784
  const bdVersion = bdInstalled ? cmd("bd", ["--version"]) : null;
31493
- const beadsPresent = existsSync17(join19(process.cwd(), ".beads"));
32785
+ const beadsPresent = existsSync17(join20(process.cwd(), ".beads"));
31494
32786
  const specialistsBin = cmd("which", ["specialists"]);
31495
32787
  const jobsDir = resolveJobsDir();
31496
32788
  let jobs = [];
@@ -31651,8 +32943,8 @@ __export(exports_ps, {
31651
32943
  run: () => run15
31652
32944
  });
31653
32945
  import { spawnSync as spawnSync17 } from "child_process";
31654
- import { existsSync as existsSync18, readdirSync as readdirSync8, readFileSync as readFileSync14 } from "fs";
31655
- import { join as join20 } from "path";
32946
+ import { existsSync as existsSync18, readdirSync as readdirSync8, readFileSync as readFileSync16 } from "fs";
32947
+ import { join as join21 } from "path";
31656
32948
  function parseArgs7(argv) {
31657
32949
  let nodeId;
31658
32950
  const positional = [];
@@ -31686,21 +32978,21 @@ function readStatusesFromFiles(jobsDir) {
31686
32978
  return [];
31687
32979
  const statuses = [];
31688
32980
  for (const entry of readdirSync8(jobsDir)) {
31689
- const statusPath = join20(jobsDir, entry, "status.json");
32981
+ const statusPath = join21(jobsDir, entry, "status.json");
31690
32982
  if (!existsSync18(statusPath))
31691
32983
  continue;
31692
32984
  try {
31693
- statuses.push(JSON.parse(readFileSync14(statusPath, "utf-8")));
32985
+ statuses.push(JSON.parse(readFileSync16(statusPath, "utf-8")));
31694
32986
  } catch {}
31695
32987
  }
31696
32988
  return statuses.sort((a, b) => b.started_at_ms - a.started_at_ms);
31697
32989
  }
31698
32990
  function readLastToolEventFromFile(jobsDir, jobId) {
31699
- const eventsPath = join20(jobsDir, jobId, "events.jsonl");
32991
+ const eventsPath = join21(jobsDir, jobId, "events.jsonl");
31700
32992
  if (!existsSync18(eventsPath))
31701
32993
  return;
31702
32994
  try {
31703
- const lines = readFileSync14(eventsPath, "utf-8").split(`
32995
+ const lines = readFileSync16(eventsPath, "utf-8").split(`
31704
32996
  `);
31705
32997
  for (let index = lines.length - 1;index >= 0; index -= 1) {
31706
32998
  const line = lines[index]?.trim();
@@ -32458,8 +33750,8 @@ var exports_result = {};
32458
33750
  __export(exports_result, {
32459
33751
  run: () => run16
32460
33752
  });
32461
- import { existsSync as existsSync19, readFileSync as readFileSync15 } from "fs";
32462
- import { join as join21 } from "path";
33753
+ import { existsSync as existsSync19, readFileSync as readFileSync17 } from "fs";
33754
+ import { join as join22 } from "path";
32463
33755
  function parseArgs8(argv) {
32464
33756
  let jobId;
32465
33757
  let nodeId;
@@ -32543,9 +33835,96 @@ function resolveJobIdFromNodeMember(sqliteClient, nodeId, memberKey) {
32543
33835
  }
32544
33836
  return member.job_id;
32545
33837
  }
33838
+ function readTimelineEventsForResult(sqliteClient, jobsDir, jobId) {
33839
+ if (sqliteClient) {
33840
+ try {
33841
+ return sqliteClient.readEvents(jobId);
33842
+ } catch {}
33843
+ }
33844
+ const eventsPath = join22(jobsDir, jobId, "events.jsonl");
33845
+ if (!existsSync19(eventsPath))
33846
+ return [];
33847
+ return readFileSync17(eventsPath, "utf-8").split(`
33848
+ `).map((line) => line.trim()).filter(Boolean).map((line) => parseTimelineEvent(line)).filter((event) => event !== null);
33849
+ }
33850
+ function deriveStartupSnapshot(status, events) {
33851
+ const runStartEvent = events.find((event) => event.type === "run_start");
33852
+ const startupFromEvent = runStartEvent?.type === "run_start" ? runStartEvent.startup_snapshot ?? null : null;
33853
+ const memoryMeta = events.find((event) => event.type === "meta" && !!event.memory_injection);
33854
+ const memoryInjection = memoryMeta?.type === "meta" ? memoryMeta.memory_injection : undefined;
33855
+ const merged = {
33856
+ ...startupFromEvent ?? {},
33857
+ ...status.startup_context ?? {},
33858
+ ...memoryInjection ? { memory_injection: memoryInjection } : {}
33859
+ };
33860
+ if (!merged.job_id)
33861
+ merged.job_id = status.id;
33862
+ if (!merged.specialist_name)
33863
+ merged.specialist_name = status.specialist;
33864
+ if (!merged.bead_id && status.bead_id)
33865
+ merged.bead_id = status.bead_id;
33866
+ if (!merged.reused_from_job_id && status.reused_from_job_id)
33867
+ merged.reused_from_job_id = status.reused_from_job_id;
33868
+ if (!merged.worktree_owner_job_id && status.worktree_owner_job_id)
33869
+ merged.worktree_owner_job_id = status.worktree_owner_job_id;
33870
+ if (!merged.chain_id && status.chain_id)
33871
+ merged.chain_id = status.chain_id;
33872
+ if (!merged.chain_root_job_id && status.chain_root_job_id)
33873
+ merged.chain_root_job_id = status.chain_root_job_id;
33874
+ if (!merged.chain_root_bead_id && status.chain_root_bead_id)
33875
+ merged.chain_root_bead_id = status.chain_root_bead_id;
33876
+ if (!merged.worktree_path && status.worktree_path)
33877
+ merged.worktree_path = status.worktree_path;
33878
+ if (!merged.branch && status.branch)
33879
+ merged.branch = status.branch;
33880
+ return Object.keys(merged).length > 0 ? merged : null;
33881
+ }
33882
+ function deriveApiError(events) {
33883
+ const errorEvent = [...events].reverse().find((event) => event.type === "error");
33884
+ return errorEvent?.error_message ?? null;
33885
+ }
33886
+ function formatStartupSnapshot(snapshot) {
33887
+ if (!snapshot)
33888
+ return null;
33889
+ const lines = [`
33890
+ --- startup context ---`];
33891
+ const push = (key, value) => {
33892
+ if (value === undefined || value === null)
33893
+ return;
33894
+ lines.push(`${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`);
33895
+ };
33896
+ push("job_id", snapshot.job_id);
33897
+ push("specialist_name", snapshot.specialist_name);
33898
+ push("bead_id", snapshot.bead_id);
33899
+ push("reused_from_job_id", snapshot.reused_from_job_id);
33900
+ push("worktree_owner_job_id", snapshot.worktree_owner_job_id);
33901
+ push("chain_id", snapshot.chain_id);
33902
+ push("chain_root_job_id", snapshot.chain_root_job_id);
33903
+ push("chain_root_bead_id", snapshot.chain_root_bead_id);
33904
+ push("worktree_path", snapshot.worktree_path);
33905
+ push("branch", snapshot.branch);
33906
+ push("variables_keys", snapshot.variables_keys);
33907
+ push("reviewed_job_id_present", snapshot.reviewed_job_id_present);
33908
+ push("reused_worktree_awareness_present", snapshot.reused_worktree_awareness_present);
33909
+ push("bead_context_present", snapshot.bead_context_present);
33910
+ if (snapshot.memory_injection) {
33911
+ push("memory.static_tokens", snapshot.memory_injection.static_tokens);
33912
+ push("memory.memory_tokens", snapshot.memory_injection.memory_tokens);
33913
+ push("memory.gitnexus_tokens", snapshot.memory_injection.gitnexus_tokens);
33914
+ push("memory.total_tokens", snapshot.memory_injection.total_tokens);
33915
+ }
33916
+ if (snapshot.skills) {
33917
+ push("skills.count", snapshot.skills.count);
33918
+ push("skills.activated", snapshot.skills.activated);
33919
+ }
33920
+ lines.push("---");
33921
+ return `${lines.join(`
33922
+ `)}
33923
+ `;
33924
+ }
32546
33925
  async function run16() {
32547
33926
  const args = parseArgs8(process.argv.slice(3));
32548
- const emitJson = (status, output2, error2) => {
33927
+ const emitJson = (status, output2, error2, startupContext = null) => {
32549
33928
  console.log(JSON.stringify({
32550
33929
  job: status ? {
32551
33930
  id: status.id,
@@ -32555,17 +33934,20 @@ async function run16() {
32555
33934
  backend: status.backend ?? null,
32556
33935
  bead_id: status.bead_id ?? null,
32557
33936
  metrics: status.metrics ?? null,
33937
+ startup_context: startupContext,
32558
33938
  error: status.error ?? null
32559
33939
  } : null,
32560
33940
  output: output2,
33941
+ startup_context: startupContext,
32561
33942
  error: error2
32562
33943
  }, null, 2));
32563
33944
  };
32564
- const jobsDir = join21(process.cwd(), ".specialists", "jobs");
33945
+ const jobsDir = join22(process.cwd(), ".specialists", "jobs");
32565
33946
  const supervisor = new Supervisor({ runner: null, runOptions: null, jobsDir });
32566
33947
  const sqliteClient = createObservabilitySqliteClient();
32567
- const emitHumanResult = (output2, status, trailingFooter) => {
32568
- process.stdout.write(output2);
33948
+ const emitHumanResult = (output2, status, startupContext, trailingFooter) => {
33949
+ const startupBlock = formatStartupSnapshot(startupContext);
33950
+ process.stdout.write(startupBlock ? `${startupBlock}${output2}` : output2);
32569
33951
  const tokenSummaryParts = formatTokenUsageSummary(status.metrics?.token_usage).filter((part) => !part.startsWith("cost="));
32570
33952
  const formattedCost = formatCostUsd(status.metrics?.token_usage?.cost_usd);
32571
33953
  if (tokenSummaryParts.length === 0 && !formattedCost) {
@@ -32594,7 +33976,7 @@ async function run16() {
32594
33976
  const resolvedNodeId = args.nodeId ? resolveNodeRefWithClient(args.nodeId, sqliteClient) : resolveSingleActiveNodeRef(sqliteClient);
32595
33977
  return resolveJobIdFromNodeMember(sqliteClient, resolvedNodeId, args.memberKey);
32596
33978
  })();
32597
- const resultPath = join21(jobsDir, jobId, "result.txt");
33979
+ const resultPath = join22(jobsDir, jobId, "result.txt");
32598
33980
  const readResultOutput = () => {
32599
33981
  try {
32600
33982
  const sqliteResult = sqliteClient?.readResult(jobId) ?? null;
@@ -32606,7 +33988,7 @@ async function run16() {
32606
33988
  if (!existsSync19(resultPath)) {
32607
33989
  return null;
32608
33990
  }
32609
- return readFileSync15(resultPath, "utf-8");
33991
+ return readFileSync17(resultPath, "utf-8");
32610
33992
  };
32611
33993
  if (args.wait) {
32612
33994
  const startMs = Date.now();
@@ -32621,26 +34003,33 @@ async function run16() {
32621
34003
  process.exit(1);
32622
34004
  }
32623
34005
  if (status2.status === "done") {
34006
+ const events2 = readTimelineEventsForResult(sqliteClient, jobsDir, jobId);
34007
+ const startupContext2 = deriveStartupSnapshot(status2, events2);
34008
+ const apiError2 = status2.error ?? deriveApiError(events2);
32624
34009
  const output3 = readResultOutput();
32625
34010
  if (!output3) {
34011
+ const message = apiError2 ? `Job ${jobId} failed: ${apiError2}` : `Result not found for job ${jobId}`;
32626
34012
  if (args.json) {
32627
- emitJson(status2, null, `Result not found for job ${jobId}`);
34013
+ emitJson(status2, null, message, startupContext2);
32628
34014
  } else {
32629
- console.error(`Result not found for job ${jobId}`);
34015
+ process.stderr.write(`${red3(message)}
34016
+ `);
32630
34017
  }
32631
34018
  process.exit(1);
32632
34019
  }
34020
+ const enrichedStatus2 = apiError2 && !status2.error ? { ...status2, error: apiError2 } : status2;
32633
34021
  if (args.json) {
32634
- emitJson(status2, output3, null);
34022
+ emitJson(enrichedStatus2, output3, null, startupContext2);
32635
34023
  } else {
32636
- emitHumanResult(output3, status2);
34024
+ emitHumanResult(output3, enrichedStatus2, startupContext2);
32637
34025
  }
32638
34026
  return;
32639
34027
  }
32640
34028
  if (status2.status === "error") {
34029
+ const startupContext2 = deriveStartupSnapshot(status2, readTimelineEventsForResult(sqliteClient, jobsDir, jobId));
32641
34030
  const message = `Job ${jobId} failed: ${status2.error ?? "unknown error"}`;
32642
34031
  if (args.json) {
32643
- emitJson(status2, null, message);
34032
+ emitJson(status2, null, message, startupContext2);
32644
34033
  } else {
32645
34034
  process.stderr.write(`${red3(`Job ${jobId} failed:`)} ${status2.error ?? "unknown error"}
32646
34035
  `);
@@ -32652,7 +34041,8 @@ async function run16() {
32652
34041
  if (elapsedSecs >= args.timeout) {
32653
34042
  const timeoutMessage = `Timeout: job ${jobId} did not complete within ${args.timeout}s`;
32654
34043
  if (args.json) {
32655
- emitJson(status2, null, timeoutMessage);
34044
+ const startupContext2 = deriveStartupSnapshot(status2, readTimelineEventsForResult(sqliteClient, jobsDir, jobId));
34045
+ emitJson(status2, null, timeoutMessage, startupContext2);
32656
34046
  } else {
32657
34047
  process.stderr.write(`${timeoutMessage}
32658
34048
  `);
@@ -32673,11 +34063,12 @@ async function run16() {
32673
34063
  process.exit(1);
32674
34064
  }
32675
34065
  if (status.status === "running" || status.status === "starting") {
34066
+ const startupContext2 = deriveStartupSnapshot(status, readTimelineEventsForResult(sqliteClient, jobsDir, jobId));
32676
34067
  const output3 = readResultOutput();
32677
34068
  if (!output3) {
32678
34069
  const message = `Job ${jobId} is still ${status.status}. Use 'specialists feed --job ${jobId}' to follow.`;
32679
34070
  if (args.json) {
32680
- emitJson(status, null, message);
34071
+ emitJson(status, null, message, startupContext2);
32681
34072
  } else {
32682
34073
  process.stderr.write(`${dim10(message)}
32683
34074
  `);
@@ -32685,20 +34076,21 @@ async function run16() {
32685
34076
  process.exit(1);
32686
34077
  }
32687
34078
  if (args.json) {
32688
- emitJson(status, output3, null);
34079
+ emitJson(status, output3, null, startupContext2);
32689
34080
  } else {
32690
34081
  process.stderr.write(`${dim10(`Job ${jobId} is currently ${status.status}. Showing last completed output while it continues.`)}
32691
34082
  `);
32692
- emitHumanResult(output3, status);
34083
+ emitHumanResult(output3, status, startupContext2);
32693
34084
  }
32694
34085
  return;
32695
34086
  }
32696
34087
  if (status.status === "waiting") {
34088
+ const startupContext2 = deriveStartupSnapshot(status, readTimelineEventsForResult(sqliteClient, jobsDir, jobId));
32697
34089
  const output3 = readResultOutput();
32698
34090
  if (!output3) {
32699
34091
  const message = `Job ${jobId} is waiting for input. Use: specialists resume ${jobId} "..."`;
32700
34092
  if (args.json) {
32701
- emitJson(status, null, message);
34093
+ emitJson(status, null, message, startupContext2);
32702
34094
  } else {
32703
34095
  process.stderr.write(`${dim10(message)}
32704
34096
  `);
@@ -32709,36 +34101,44 @@ async function run16() {
32709
34101
  --- Session is waiting for your input. Use: specialists resume ${jobId} "..." ---
32710
34102
  `;
32711
34103
  if (args.json) {
32712
- emitJson(status, `${output3}${waitingFooter}`, null);
34104
+ emitJson(status, `${output3}${waitingFooter}`, null, startupContext2);
32713
34105
  } else {
32714
- emitHumanResult(output3, status, waitingFooter);
34106
+ emitHumanResult(output3, status, startupContext2, waitingFooter);
32715
34107
  }
32716
34108
  return;
32717
34109
  }
32718
34110
  if (status.status === "error") {
32719
- const message = `Job ${jobId} failed: ${status.error ?? "unknown error"}`;
34111
+ const events2 = readTimelineEventsForResult(sqliteClient, jobsDir, jobId);
34112
+ const startupContext2 = deriveStartupSnapshot(status, events2);
34113
+ const message = `Job ${jobId} failed: ${status.error ?? deriveApiError(events2) ?? "unknown error"}`;
32720
34114
  if (args.json) {
32721
- emitJson(status, null, message);
34115
+ emitJson(status, null, message, startupContext2);
32722
34116
  } else {
32723
- process.stderr.write(`${red3(`Job ${jobId} failed:`)} ${status.error ?? "unknown error"}
34117
+ process.stderr.write(`${red3(`Job ${jobId} failed:`)} ${status.error ?? deriveApiError(events2) ?? "unknown error"}
32724
34118
  `);
32725
34119
  }
32726
34120
  process.exit(1);
32727
34121
  }
34122
+ const events = readTimelineEventsForResult(sqliteClient, jobsDir, jobId);
34123
+ const apiError = status.error ?? deriveApiError(events);
32728
34124
  const output2 = readResultOutput();
32729
34125
  if (!output2) {
34126
+ const message = apiError ? `Job ${jobId} failed: ${apiError}` : `Result not found for job ${jobId}`;
32730
34127
  if (args.json) {
32731
- emitJson(status, null, `Result not found for job ${jobId}`);
34128
+ emitJson(status, null, message);
32732
34129
  } else {
32733
- console.error(`Result not found for job ${jobId}`);
34130
+ process.stderr.write(`${red3(message)}
34131
+ `);
32734
34132
  }
32735
34133
  process.exit(1);
32736
34134
  }
34135
+ const startupContext = deriveStartupSnapshot(status, events);
34136
+ const enrichedStatus = apiError && !status.error ? { ...status, error: apiError } : status;
32737
34137
  if (args.json) {
32738
- emitJson(status, output2, null);
34138
+ emitJson(enrichedStatus, output2, null, startupContext);
32739
34139
  return;
32740
34140
  }
32741
- emitHumanResult(output2, status);
34141
+ emitHumanResult(output2, enrichedStatus, startupContext);
32742
34142
  } catch (error2) {
32743
34143
  const message = error2 instanceof Error ? error2.message : String(error2);
32744
34144
  if (args.json) {
@@ -32756,13 +34156,14 @@ var dim10 = (s) => `\x1B[2m${s}\x1B[0m`, red3 = (s) => `\x1B[31m${s}\x1B[0m`;
32756
34156
  var init_result = __esm(() => {
32757
34157
  init_supervisor();
32758
34158
  init_observability_sqlite();
34159
+ init_timeline_events();
32759
34160
  init_node_resolve();
32760
34161
  init_format_helpers();
32761
34162
  });
32762
34163
 
32763
34164
  // src/specialist/timeline-query.ts
32764
- import { existsSync as existsSync20, readdirSync as readdirSync9, readFileSync as readFileSync16 } from "fs";
32765
- import { basename as basename5, join as join22 } from "path";
34165
+ import { existsSync as existsSync20, readdirSync as readdirSync9, readFileSync as readFileSync18 } from "fs";
34166
+ import { basename as basename5, join as join23 } from "path";
32766
34167
  function readJobEvents(jobDir) {
32767
34168
  const jobId = basename5(jobDir);
32768
34169
  try {
@@ -32772,10 +34173,10 @@ function readJobEvents(jobDir) {
32772
34173
  return sqliteEvents;
32773
34174
  }
32774
34175
  } catch {}
32775
- const eventsPath = join22(jobDir, "events.jsonl");
34176
+ const eventsPath = join23(jobDir, "events.jsonl");
32776
34177
  if (!existsSync20(eventsPath))
32777
34178
  return [];
32778
- const content = readFileSync16(eventsPath, "utf-8");
34179
+ const content = readFileSync18(eventsPath, "utf-8");
32779
34180
  const lines = content.split(`
32780
34181
  `).filter(Boolean);
32781
34182
  const events = [];
@@ -32788,7 +34189,7 @@ function readJobEvents(jobDir) {
32788
34189
  return events;
32789
34190
  }
32790
34191
  function readJobEventsById(jobsDir, jobId) {
32791
- return readJobEvents(join22(jobsDir, jobId));
34192
+ return readJobEvents(join23(jobsDir, jobId));
32792
34193
  }
32793
34194
  function readAllJobEvents(jobsDir) {
32794
34195
  if (!existsSync20(jobsDir))
@@ -32796,7 +34197,7 @@ function readAllJobEvents(jobsDir) {
32796
34197
  const batches = [];
32797
34198
  const entries = readdirSync9(jobsDir);
32798
34199
  for (const entry of entries) {
32799
- const jobDir = join22(jobsDir, entry);
34200
+ const jobDir = join23(jobsDir, entry);
32800
34201
  try {
32801
34202
  const stat2 = __require("fs").statSync(jobDir);
32802
34203
  if (!stat2.isDirectory())
@@ -32805,12 +34206,12 @@ function readAllJobEvents(jobsDir) {
32805
34206
  continue;
32806
34207
  }
32807
34208
  const jobId = entry;
32808
- const statusPath = join22(jobDir, "status.json");
34209
+ const statusPath = join23(jobDir, "status.json");
32809
34210
  let specialist = "unknown";
32810
34211
  let beadId;
32811
34212
  if (existsSync20(statusPath)) {
32812
34213
  try {
32813
- const status = JSON.parse(readFileSync16(statusPath, "utf-8"));
34214
+ const status = JSON.parse(readFileSync18(statusPath, "utf-8"));
32814
34215
  specialist = status.specialist ?? "unknown";
32815
34216
  beadId = status.bead_id;
32816
34217
  } catch {}
@@ -32908,12 +34309,12 @@ __export(exports_feed, {
32908
34309
  import {
32909
34310
  closeSync as closeSync2,
32910
34311
  existsSync as existsSync21,
32911
- openSync as openSync2,
32912
- readFileSync as readFileSync17,
34312
+ openSync as openSync3,
34313
+ readFileSync as readFileSync19,
32913
34314
  readdirSync as readdirSync10,
32914
- statSync as statSync2
34315
+ statSync as statSync3
32915
34316
  } from "fs";
32916
- import { join as join23 } from "path";
34317
+ import { join as join24 } from "path";
32917
34318
  function getHumanEventKey(event) {
32918
34319
  switch (event.type) {
32919
34320
  case "meta":
@@ -32934,6 +34335,8 @@ function getHumanEventKey(event) {
32934
34335
  return `run_start:${event.specialist}:${event.bead_id ?? ""}`;
32935
34336
  case "run_complete":
32936
34337
  return `run_complete:${event.status}:${event.error ?? ""}`;
34338
+ case "error":
34339
+ return `error:${event.source}:${event.error_message}`;
32937
34340
  case "token_usage":
32938
34341
  return `token_usage:${event.token_usage.total_tokens ?? ""}:${event.source}`;
32939
34342
  case "finish_reason":
@@ -32981,6 +34384,50 @@ function formatWaitingBanner(jobId, specialist) {
32981
34384
  const prefix = magenta3(bold10("WAIT"));
32982
34385
  return `${prefix} ${specialist} (${jobId}) is waiting for input. Use: specialists resume ${jobId} "..."`;
32983
34386
  }
34387
+ function formatStartupContextLine(event) {
34388
+ if (event.type === "run_start") {
34389
+ const snapshot = event.startup_snapshot;
34390
+ if (!snapshot)
34391
+ return null;
34392
+ const parts = [];
34393
+ if (snapshot.job_id)
34394
+ parts.push(`job=${snapshot.job_id}`);
34395
+ if (snapshot.specialist_name)
34396
+ parts.push(`specialist=${snapshot.specialist_name}`);
34397
+ if (snapshot.bead_id)
34398
+ parts.push(`bead=${snapshot.bead_id}`);
34399
+ if (snapshot.reused_from_job_id)
34400
+ parts.push(`reused=${snapshot.reused_from_job_id}`);
34401
+ if (snapshot.worktree_owner_job_id)
34402
+ parts.push(`owner=${snapshot.worktree_owner_job_id}`);
34403
+ if (snapshot.chain_id)
34404
+ parts.push(`chain=${snapshot.chain_id}`);
34405
+ if (snapshot.chain_root_job_id)
34406
+ parts.push(`chain_root_job=${snapshot.chain_root_job_id}`);
34407
+ if (snapshot.chain_root_bead_id)
34408
+ parts.push(`chain_root_bead=${snapshot.chain_root_bead_id}`);
34409
+ if (snapshot.worktree_path)
34410
+ parts.push(`worktree=${snapshot.worktree_path}`);
34411
+ if (snapshot.branch)
34412
+ parts.push(`branch=${snapshot.branch}`);
34413
+ if (snapshot.variables_keys)
34414
+ parts.push(`vars=[${snapshot.variables_keys.join(",")}]`);
34415
+ if (snapshot.reviewed_job_id_present !== undefined)
34416
+ parts.push(`reviewed_present=${snapshot.reviewed_job_id_present}`);
34417
+ if (snapshot.reused_worktree_awareness_present !== undefined)
34418
+ parts.push(`reuse_awareness_present=${snapshot.reused_worktree_awareness_present}`);
34419
+ if (snapshot.bead_context_present !== undefined)
34420
+ parts.push(`bead_context_present=${snapshot.bead_context_present}`);
34421
+ if (snapshot.skills)
34422
+ parts.push(`skills=${snapshot.skills.count}`);
34423
+ return parts.length > 0 ? dim8(` \u21B3 startup ${parts.join(" ")}`) : null;
34424
+ }
34425
+ if (event.type === "meta" && event.memory_injection) {
34426
+ const mem = event.memory_injection;
34427
+ return dim8(` \u21B3 memory static=${mem.static_tokens} dynamic=${mem.memory_tokens} gitnexus=${mem.gitnexus_tokens} total=${mem.total_tokens}`);
34428
+ }
34429
+ return null;
34430
+ }
32984
34431
  function parseSince(value) {
32985
34432
  if (value.includes("T") || value.includes("-")) {
32986
34433
  return new Date(value).getTime();
@@ -33007,8 +34454,8 @@ function parseCursor(value, defaultJobId) {
33007
34454
  function readFileFresh(filePath) {
33008
34455
  let fd = null;
33009
34456
  try {
33010
- fd = openSync2(filePath, "r");
33011
- return readFileSync17(fd, "utf-8");
34457
+ fd = openSync3(filePath, "r");
34458
+ return readFileSync19(fd, "utf-8");
33012
34459
  } catch {
33013
34460
  return null;
33014
34461
  } finally {
@@ -33025,7 +34472,7 @@ function readStatusJson(sqliteClient, jobsDir, jobId) {
33025
34472
  } catch (error2) {
33026
34473
  console.warn(`SQLite status read failed for job ${jobId}; falling back to status.json`, error2);
33027
34474
  }
33028
- const statusPath = join23(jobsDir, jobId, "status.json");
34475
+ const statusPath = join24(jobsDir, jobId, "status.json");
33029
34476
  const raw = readFileFresh(statusPath);
33030
34477
  if (!raw)
33031
34478
  return null;
@@ -33195,6 +34642,9 @@ function printSnapshot(sqliteClient, merged, options, jobsDir) {
33195
34642
  contextPct: meta.contextPct,
33196
34643
  colorize
33197
34644
  }));
34645
+ const startupContextLine = formatStartupContextLine(event);
34646
+ if (startupContextLine)
34647
+ console.log(startupContextLine);
33198
34648
  }
33199
34649
  }
33200
34650
  function compareMergedEvents(a, b) {
@@ -33235,9 +34685,9 @@ function listMatchingJobIds(sqliteClient, jobsDir, options) {
33235
34685
  return [];
33236
34686
  const jobIds = [];
33237
34687
  for (const entry of readdirSync10(jobsDir)) {
33238
- const jobDir = join23(jobsDir, entry);
34688
+ const jobDir = join24(jobsDir, entry);
33239
34689
  try {
33240
- if (!statSync2(jobDir).isDirectory())
34690
+ if (!statSync3(jobDir).isDirectory())
33241
34691
  continue;
33242
34692
  } catch {
33243
34693
  continue;
@@ -33269,7 +34719,7 @@ function readJobEventsFresh(sqliteClient, jobsDir, jobId) {
33269
34719
  } catch (error2) {
33270
34720
  console.warn(`SQLite events read failed for job ${jobId}; falling back to events.jsonl`, error2);
33271
34721
  }
33272
- const eventsPath = join23(jobsDir, jobId, "events.jsonl");
34722
+ const eventsPath = join24(jobsDir, jobId, "events.jsonl");
33273
34723
  const content = readFileFresh(eventsPath);
33274
34724
  if (!content)
33275
34725
  return [];
@@ -33345,7 +34795,7 @@ async function followMerged(sqliteClient, jobsDir, options) {
33345
34795
  }
33346
34796
  const lastPrintedEventKey = new Map;
33347
34797
  const seenMetaKey = new Map;
33348
- await new Promise((resolve7) => {
34798
+ await new Promise((resolve8) => {
33349
34799
  const interval = setInterval(() => {
33350
34800
  const batches = filteredBatches();
33351
34801
  for (const jobId of listMatchingJobIds(sqliteClient, jobsDir, options)) {
@@ -33415,11 +34865,14 @@ async function followMerged(sqliteClient, jobsDir, options) {
33415
34865
  contextPct: meta.contextPct,
33416
34866
  colorize
33417
34867
  }));
34868
+ const startupContextLine = formatStartupContextLine(event);
34869
+ if (startupContextLine)
34870
+ console.log(startupContextLine);
33418
34871
  }
33419
34872
  }
33420
34873
  if (!options.forever && trackedJobs.size > 0 && completedJobs.size === trackedJobs.size) {
33421
34874
  clearInterval(interval);
33422
- resolve7();
34875
+ resolve8();
33423
34876
  }
33424
34877
  }, 500);
33425
34878
  });
@@ -33428,7 +34881,7 @@ async function run17() {
33428
34881
  const options = parseArgs9(process.argv.slice(3));
33429
34882
  const sqliteClient = createObservabilitySqliteClient();
33430
34883
  try {
33431
- const jobsDir = join23(process.cwd(), ".specialists", "jobs");
34884
+ const jobsDir = join24(process.cwd(), ".specialists", "jobs");
33432
34885
  if (!existsSync21(jobsDir)) {
33433
34886
  console.log(dim8("No jobs directory found."));
33434
34887
  return;
@@ -33468,8 +34921,8 @@ var exports_poll = {};
33468
34921
  __export(exports_poll, {
33469
34922
  run: () => run18
33470
34923
  });
33471
- import { existsSync as existsSync22, readFileSync as readFileSync18 } from "fs";
33472
- import { join as join24 } from "path";
34924
+ import { existsSync as existsSync22, readFileSync as readFileSync20 } from "fs";
34925
+ import { join as join25 } from "path";
33473
34926
  function parseArgs10(argv) {
33474
34927
  let jobId;
33475
34928
  let cursor = 0;
@@ -33506,19 +34959,19 @@ function parseArgs10(argv) {
33506
34959
  return { jobId, cursor, outputCursor };
33507
34960
  }
33508
34961
  function readJobState(jobsDir, jobId, cursor, outputCursor) {
33509
- const jobDir = join24(jobsDir, jobId);
33510
- const statusPath = join24(jobDir, "status.json");
34962
+ const jobDir = join25(jobsDir, jobId);
34963
+ const statusPath = join25(jobDir, "status.json");
33511
34964
  let status = null;
33512
34965
  if (existsSync22(statusPath)) {
33513
34966
  try {
33514
- status = JSON.parse(readFileSync18(statusPath, "utf-8"));
34967
+ status = JSON.parse(readFileSync20(statusPath, "utf-8"));
33515
34968
  } catch {}
33516
34969
  }
33517
- const resultPath = join24(jobDir, "result.txt");
34970
+ const resultPath = join25(jobDir, "result.txt");
33518
34971
  let fullOutput = "";
33519
34972
  if (existsSync22(resultPath)) {
33520
34973
  try {
33521
- fullOutput = readFileSync18(resultPath, "utf-8");
34974
+ fullOutput = readFileSync20(resultPath, "utf-8");
33522
34975
  } catch {}
33523
34976
  }
33524
34977
  const events = readJobEventsById(jobsDir, jobId);
@@ -33550,8 +35003,8 @@ function readJobState(jobsDir, jobId, cursor, outputCursor) {
33550
35003
  }
33551
35004
  async function run18() {
33552
35005
  const { jobId, cursor, outputCursor } = parseArgs10(process.argv.slice(3));
33553
- const jobsDir = join24(process.cwd(), ".specialists", "jobs");
33554
- const jobDir = join24(jobsDir, jobId);
35006
+ const jobsDir = join25(process.cwd(), ".specialists", "jobs");
35007
+ const jobDir = join25(jobsDir, jobId);
33555
35008
  if (!existsSync22(jobDir)) {
33556
35009
  const result2 = {
33557
35010
  job_id: jobId,
@@ -33583,7 +35036,7 @@ var exports_steer = {};
33583
35036
  __export(exports_steer, {
33584
35037
  run: () => run19
33585
35038
  });
33586
- import { writeFileSync as writeFileSync7 } from "fs";
35039
+ import { writeFileSync as writeFileSync9 } from "fs";
33587
35040
  async function run19() {
33588
35041
  const jobId = process.argv[3];
33589
35042
  const message = process.argv[4];
@@ -33614,7 +35067,7 @@ async function run19() {
33614
35067
  try {
33615
35068
  const payload = JSON.stringify({ type: "steer", message }) + `
33616
35069
  `;
33617
- writeFileSync7(status.fifo_path, payload, { flag: "a" });
35070
+ writeFileSync9(status.fifo_path, payload, { flag: "a" });
33618
35071
  process.stdout.write(`${green10("\u2713")} Steer message sent to job ${jobId}
33619
35072
  `);
33620
35073
  } catch (err) {
@@ -33637,7 +35090,7 @@ var exports_resume = {};
33637
35090
  __export(exports_resume, {
33638
35091
  run: () => run20
33639
35092
  });
33640
- import { writeFileSync as writeFileSync8 } from "fs";
35093
+ import { writeFileSync as writeFileSync10 } from "fs";
33641
35094
  async function run20() {
33642
35095
  const jobId = process.argv[3];
33643
35096
  const task = process.argv[4];
@@ -33668,7 +35121,7 @@ async function run20() {
33668
35121
  try {
33669
35122
  const payload = JSON.stringify({ type: "resume", task }) + `
33670
35123
  `;
33671
- writeFileSync8(status.fifo_path, payload, { flag: "a" });
35124
+ writeFileSync10(status.fifo_path, payload, { flag: "a" });
33672
35125
  process.stdout.write(`${green11("\u2713")} Resume sent to job ${jobId}
33673
35126
  `);
33674
35127
  process.stdout.write(` Use 'specialists feed ${jobId} --follow' to watch the response.
@@ -33700,15 +35153,15 @@ async function run21() {
33700
35153
  }
33701
35154
 
33702
35155
  // src/specialist/worktree-gc.ts
33703
- import { existsSync as existsSync23, readdirSync as readdirSync11, readFileSync as readFileSync19 } from "fs";
33704
- import { join as join25 } from "path";
35156
+ import { existsSync as existsSync23, readdirSync as readdirSync11, readFileSync as readFileSync21 } from "fs";
35157
+ import { join as join26 } from "path";
33705
35158
  import { spawnSync as spawnSync18 } from "child_process";
33706
35159
  function readJobStatus2(jobDir) {
33707
- const statusPath = join25(jobDir, "status.json");
35160
+ const statusPath = join26(jobDir, "status.json");
33708
35161
  if (!existsSync23(statusPath))
33709
35162
  return null;
33710
35163
  try {
33711
- return JSON.parse(readFileSync19(statusPath, "utf-8"));
35164
+ return JSON.parse(readFileSync21(statusPath, "utf-8"));
33712
35165
  } catch {
33713
35166
  return null;
33714
35167
  }
@@ -33726,7 +35179,7 @@ function collectWorktreeGcCandidates(jobsDir) {
33726
35179
  for (const entry of readdirSync11(jobsDir, { withFileTypes: true })) {
33727
35180
  if (!entry.isDirectory())
33728
35181
  continue;
33729
- const status = readJobStatus2(join25(jobsDir, entry.name));
35182
+ const status = readJobStatus2(join26(jobsDir, entry.name));
33730
35183
  if (!status)
33731
35184
  continue;
33732
35185
  if (isActive(status.status))
@@ -33784,11 +35237,11 @@ __export(exports_clean, {
33784
35237
  import {
33785
35238
  existsSync as existsSync24,
33786
35239
  readdirSync as readdirSync12,
33787
- readFileSync as readFileSync20,
33788
- rmSync as rmSync2,
33789
- statSync as statSync3
35240
+ readFileSync as readFileSync22,
35241
+ rmSync as rmSync3,
35242
+ statSync as statSync4
33790
35243
  } from "fs";
33791
- import { join as join26 } from "path";
35244
+ import { join as join27 } from "path";
33792
35245
  function parseTtlDaysFromEnvironment() {
33793
35246
  const rawValue = process.env.SPECIALISTS_JOB_TTL_DAYS ?? process.env.JOB_TTL_DAYS;
33794
35247
  if (!rawValue)
@@ -33844,8 +35297,8 @@ function readDirectorySizeBytes(directoryPath) {
33844
35297
  let totalBytes = 0;
33845
35298
  const entries = readdirSync12(directoryPath, { withFileTypes: true });
33846
35299
  for (const entry of entries) {
33847
- const entryPath = join26(directoryPath, entry.name);
33848
- const stats = statSync3(entryPath);
35300
+ const entryPath = join27(directoryPath, entry.name);
35301
+ const stats = statSync4(entryPath);
33849
35302
  if (stats.isDirectory()) {
33850
35303
  totalBytes += readDirectorySizeBytes(entryPath);
33851
35304
  continue;
@@ -33857,7 +35310,7 @@ function readDirectorySizeBytes(directoryPath) {
33857
35310
  function containsProtectedSqliteArtifact(directoryPath) {
33858
35311
  const entries = readdirSync12(directoryPath, { withFileTypes: true });
33859
35312
  for (const entry of entries) {
33860
- const entryPath = join26(directoryPath, entry.name);
35313
+ const entryPath = join27(directoryPath, entry.name);
33861
35314
  if (entry.isDirectory()) {
33862
35315
  if (containsProtectedSqliteArtifact(entryPath))
33863
35316
  return true;
@@ -33872,21 +35325,21 @@ function containsProtectedSqliteArtifact(directoryPath) {
33872
35325
  function readCompletedJobDirectory(baseDirectory, entry) {
33873
35326
  if (!entry.isDirectory())
33874
35327
  return null;
33875
- const directoryPath = join26(baseDirectory, entry.name);
35328
+ const directoryPath = join27(baseDirectory, entry.name);
33876
35329
  if (containsProtectedSqliteArtifact(directoryPath))
33877
35330
  return null;
33878
- const statusFilePath = join26(directoryPath, "status.json");
35331
+ const statusFilePath = join27(directoryPath, "status.json");
33879
35332
  if (!existsSync24(statusFilePath))
33880
35333
  return null;
33881
35334
  let statusData;
33882
35335
  try {
33883
- statusData = JSON.parse(readFileSync20(statusFilePath, "utf-8"));
35336
+ statusData = JSON.parse(readFileSync22(statusFilePath, "utf-8"));
33884
35337
  } catch {
33885
35338
  return null;
33886
35339
  }
33887
35340
  if (!COMPLETED_STATUSES.has(statusData.status))
33888
35341
  return null;
33889
- const directoryStats = statSync3(directoryPath);
35342
+ const directoryStats = statSync4(directoryPath);
33890
35343
  return {
33891
35344
  id: entry.name,
33892
35345
  directoryPath,
@@ -33923,7 +35376,7 @@ function selectJobsToRemove(completedJobs, options) {
33923
35376
  const cutoffMs = Date.now() - ttlDays * MS_PER_DAY;
33924
35377
  return jobsByNewest.filter((job) => job.modifiedAtMs < cutoffMs);
33925
35378
  }
33926
- function formatBytes(bytes) {
35379
+ function formatBytes2(bytes) {
33927
35380
  if (bytes < 1024)
33928
35381
  return `${bytes} B`;
33929
35382
  const units = ["KB", "MB", "GB", "TB"];
@@ -33938,7 +35391,7 @@ function formatBytes(bytes) {
33938
35391
  function renderSummary(removedCount, freedBytes, dryRun) {
33939
35392
  const action = dryRun ? "Would remove" : "Removed";
33940
35393
  const noun = removedCount === 1 ? "directory" : "directories";
33941
- return `${action} ${removedCount} job ${noun} (${formatBytes(freedBytes)} freed)`;
35394
+ return `${action} ${removedCount} job ${noun} (${formatBytes2(freedBytes)} freed)`;
33942
35395
  }
33943
35396
  function printDryRunPlan(jobs) {
33944
35397
  if (jobs.length === 0)
@@ -33992,7 +35445,7 @@ async function run22() {
33992
35445
  return;
33993
35446
  }
33994
35447
  for (const job of jobsToRemove) {
33995
- rmSync2(job.directoryPath, { recursive: true, force: true });
35448
+ rmSync3(job.directoryPath, { recursive: true, force: true });
33996
35449
  }
33997
35450
  console.log(renderSummary(jobsToRemove.length, freedBytes, false));
33998
35451
  if (worktreeCandidates.length > 0) {
@@ -34115,6 +35568,10 @@ async function run23() {
34115
35568
  const guard = checkEpicUnresolvedGuard(beadId);
34116
35569
  if (guard.blocked && guard.epicId && guard.epicStatus && isEpicUnresolvedState(guard.epicStatus)) {
34117
35570
  console.log(`Chain ${beadId} belongs to unresolved epic ${guard.epicId} (${guard.epicStatus}).`);
35571
+ if (guard.epicStatus === "open") {
35572
+ console.log(`Epic ${guard.epicId} still open. Run: sp epic resolve ${guard.epicId}`);
35573
+ process.exit(1);
35574
+ }
34118
35575
  console.log(`Redirecting session close publication to epic merge (${options.pr ? "PR mode" : "direct mode"}).`);
34119
35576
  const args = ["merge", guard.epicId, ...options.rebuild ? ["--rebuild"] : [], ...options.pr ? ["--pr"] : []];
34120
35577
  await handleEpicMergeCommand(args);
@@ -34137,10 +35594,51 @@ __export(exports_stop, {
34137
35594
  function resolveTerminalStatus(jobId) {
34138
35595
  return hasRunCompleteEvent(jobId) ? "done" : "cancelled";
34139
35596
  }
35597
+ function parseStopArgs(argv) {
35598
+ let jobId;
35599
+ let force = false;
35600
+ for (const token of argv) {
35601
+ if (token === "--force") {
35602
+ force = true;
35603
+ continue;
35604
+ }
35605
+ if (!token.startsWith("-") && !jobId) {
35606
+ jobId = token;
35607
+ continue;
35608
+ }
35609
+ throw new Error(`Unknown option: ${token}`);
35610
+ }
35611
+ return { jobId, force };
35612
+ }
35613
+ async function waitForProcessExit(pid, timeoutMs) {
35614
+ const deadline = Date.now() + timeoutMs;
35615
+ while (Date.now() < deadline) {
35616
+ if (!isProcessAlive(pid))
35617
+ return true;
35618
+ await new Promise((resolve8) => setTimeout(resolve8, 100));
35619
+ }
35620
+ return !isProcessAlive(pid);
35621
+ }
35622
+ function tryKillProcessGroup(pid) {
35623
+ try {
35624
+ process.kill(-pid, "SIGKILL");
35625
+ } catch (err) {
35626
+ if (err.code !== "ESRCH")
35627
+ throw err;
35628
+ }
35629
+ }
34140
35630
  async function run24() {
34141
- const jobId = process.argv[3];
35631
+ let parsed;
35632
+ try {
35633
+ parsed = parseStopArgs(process.argv.slice(3));
35634
+ } catch (err) {
35635
+ console.error(err.message);
35636
+ console.error("Usage: specialists|sp stop <job-id> [--force]");
35637
+ process.exit(1);
35638
+ }
35639
+ const { jobId, force } = parsed;
34142
35640
  if (!jobId) {
34143
- console.error("Usage: specialists|sp stop <job-id>");
35641
+ console.error("Usage: specialists|sp stop <job-id> [--force]");
34144
35642
  process.exit(1);
34145
35643
  }
34146
35644
  const jobsDir = resolveJobsDir(process.cwd());
@@ -34161,33 +35659,53 @@ async function run24() {
34161
35659
  `);
34162
35660
  process.exit(1);
34163
35661
  }
35662
+ const pid = status.pid;
34164
35663
  const tmuxSession = status.tmux_session;
34165
- const terminalStatus = resolveTerminalStatus(jobId);
34166
- supervisor.updateJobStatus(jobId, terminalStatus);
34167
- try {
34168
- process.kill(status.pid, "SIGTERM");
34169
- process.stdout.write(`${green12("\u2713")} Marked ${jobId} as ${terminalStatus} and sent SIGTERM to PID ${status.pid}
35664
+ const isAlreadyDead = !isProcessAlive(pid, status.started_at_ms);
35665
+ if (force && isAlreadyDead) {
35666
+ supervisor.updateJobStatus(jobId, "error");
35667
+ tryKillProcessGroup(pid);
35668
+ process.stdout.write(`${green12("\u2713")} Marked ${jobId} as error (PID ${pid} already dead)
34170
35669
  `);
34171
- if (tmuxSession) {
34172
- killTmuxSession(tmuxSession);
34173
- process.stdout.write(`${dim11(` tmux session ${tmuxSession} killed`)}
34174
- `);
34175
- }
34176
- } catch (err) {
34177
- if (err.code === "ESRCH") {
34178
- process.stderr.write(`${red6(`Process ${status.pid} not found.`)} Job may have already completed.
35670
+ } else {
35671
+ const terminalStatus = resolveTerminalStatus(jobId);
35672
+ supervisor.updateJobStatus(jobId, terminalStatus);
35673
+ try {
35674
+ process.kill(pid, "SIGTERM");
35675
+ process.stdout.write(`${green12("\u2713")} Marked ${jobId} as ${terminalStatus} and sent SIGTERM to PID ${pid}
34179
35676
  `);
34180
- if (tmuxSession) {
34181
- killTmuxSession(tmuxSession);
34182
- process.stdout.write(`${dim11(` tmux session ${tmuxSession} killed`)}
35677
+ if (force) {
35678
+ const exited = await waitForProcessExit(pid, 5000);
35679
+ if (!exited) {
35680
+ supervisor.updateJobStatus(jobId, "error");
35681
+ tryKillProcessGroup(pid);
35682
+ process.stderr.write(`${red6("Force stop:")} PID ${pid} ignored SIGTERM, marked ${jobId} as error and killed process group.
34183
35683
  `);
35684
+ }
34184
35685
  }
34185
- } else {
34186
- process.stderr.write(`${red6("Error:")} ${err.message}
35686
+ } catch (err) {
35687
+ if (err.code === "ESRCH") {
35688
+ if (force) {
35689
+ supervisor.updateJobStatus(jobId, "error");
35690
+ tryKillProcessGroup(pid);
35691
+ process.stdout.write(`${green12("\u2713")} Marked ${jobId} as error (PID ${pid} already gone)
34187
35692
  `);
34188
- process.exit(1);
35693
+ } else {
35694
+ process.stderr.write(`${red6(`Process ${pid} not found.`)} Job may have already completed.
35695
+ `);
35696
+ }
35697
+ } else {
35698
+ process.stderr.write(`${red6("Error:")} ${err.message}
35699
+ `);
35700
+ process.exit(1);
35701
+ }
34189
35702
  }
34190
35703
  }
35704
+ if (tmuxSession) {
35705
+ killTmuxSession(tmuxSession);
35706
+ process.stdout.write(`${dim11(` tmux session ${tmuxSession} killed`)}
35707
+ `);
35708
+ }
34191
35709
  } finally {
34192
35710
  await supervisor.dispose();
34193
35711
  }
@@ -34197,6 +35715,7 @@ var init_stop = __esm(() => {
34197
35715
  init_supervisor();
34198
35716
  init_job_root();
34199
35717
  init_observability_sqlite();
35718
+ init_process_liveness();
34200
35719
  init_tmux_utils();
34201
35720
  });
34202
35721
 
@@ -34206,15 +35725,15 @@ __export(exports_attach, {
34206
35725
  run: () => run25
34207
35726
  });
34208
35727
  import { execFileSync as execFileSync3, spawnSync as spawnSync20 } from "child_process";
34209
- import { readFileSync as readFileSync21 } from "fs";
34210
- import { join as join27 } from "path";
35728
+ import { readFileSync as readFileSync23 } from "fs";
35729
+ import { join as join28 } from "path";
34211
35730
  function exitWithError(message) {
34212
35731
  console.error(message);
34213
35732
  process.exit(1);
34214
35733
  }
34215
35734
  function readStatus(statusPath, jobId) {
34216
35735
  try {
34217
- return JSON.parse(readFileSync21(statusPath, "utf-8"));
35736
+ return JSON.parse(readFileSync23(statusPath, "utf-8"));
34218
35737
  } catch (error2) {
34219
35738
  if (error2 && typeof error2 === "object" && "code" in error2 && error2.code === "ENOENT") {
34220
35739
  exitWithError(`Job \`${jobId}\` not found. Run \`specialists status\` to see active jobs.`);
@@ -34228,8 +35747,8 @@ async function run25() {
34228
35747
  if (!jobId) {
34229
35748
  exitWithError("Usage: specialists attach <job-id>");
34230
35749
  }
34231
- const jobsDir = join27(process.cwd(), ".specialists", "jobs");
34232
- const statusPath = join27(jobsDir, jobId, "status.json");
35750
+ const jobsDir = join28(process.cwd(), ".specialists", "jobs");
35751
+ const statusPath = join28(jobsDir, jobId, "status.json");
34233
35752
  const status = readStatus(statusPath, jobId);
34234
35753
  if (status.status === "done" || status.status === "error") {
34235
35754
  exitWithError(`Job \`${jobId}\` has already completed (status: ${status.status}). Use \`specialists result ${jobId}\` to read output.`);
@@ -34490,8 +36009,8 @@ __export(exports_doctor, {
34490
36009
  });
34491
36010
  import { createHash as createHash4 } from "crypto";
34492
36011
  import { spawnSync as spawnSync21 } from "child_process";
34493
- import { existsSync as existsSync25, lstatSync as lstatSync2, mkdirSync as mkdirSync6, readdirSync as readdirSync13, readFileSync as readFileSync22, readlinkSync as readlinkSync2, writeFileSync as writeFileSync9 } from "fs";
34494
- import { dirname as dirname6, join as join28, relative as relative2, resolve as resolve7 } from "path";
36012
+ import { existsSync as existsSync25, lstatSync as lstatSync2, mkdirSync as mkdirSync8, readdirSync as readdirSync13, readFileSync as readFileSync24, readlinkSync as readlinkSync2, writeFileSync as writeFileSync11 } from "fs";
36013
+ import { dirname as dirname7, join as join29, relative as relative2, resolve as resolve8 } from "path";
34495
36014
  function ok3(msg) {
34496
36015
  console.log(` ${green14("\u2713")} ${msg}`);
34497
36016
  }
@@ -34523,7 +36042,7 @@ function loadJson2(path) {
34523
36042
  if (!existsSync25(path))
34524
36043
  return null;
34525
36044
  try {
34526
- return JSON.parse(readFileSync22(path, "utf8"));
36045
+ return JSON.parse(readFileSync24(path, "utf8"));
34527
36046
  } catch {
34528
36047
  return null;
34529
36048
  }
@@ -34566,7 +36085,7 @@ function checkBd() {
34566
36085
  return false;
34567
36086
  }
34568
36087
  ok3(`bd installed ${dim13(sp("bd", ["--version"]).stdout || "")}`);
34569
- if (existsSync25(join28(CWD, ".beads")))
36088
+ if (existsSync25(join29(CWD, ".beads")))
34570
36089
  ok3(".beads/ present in project");
34571
36090
  else
34572
36091
  warn3(".beads/ not found in project");
@@ -34586,7 +36105,7 @@ function checkHooks() {
34586
36105
  section3("Claude Code hooks (2 expected)");
34587
36106
  let allPresent = true;
34588
36107
  for (const name of HOOK_NAMES) {
34589
- const canonicalPath = join28(HOOKS_DIR, name);
36108
+ const canonicalPath = join29(HOOKS_DIR, name);
34590
36109
  if (!existsSync25(canonicalPath)) {
34591
36110
  fail4(`${relative2(CWD, canonicalPath)} ${red7("missing")}`);
34592
36111
  fix("specialists init");
@@ -34594,10 +36113,10 @@ function checkHooks() {
34594
36113
  } else {
34595
36114
  ok3(relative2(CWD, canonicalPath));
34596
36115
  }
34597
- const claudeHookPath = join28(CLAUDE_HOOKS_DIR, name);
36116
+ const claudeHookPath = join29(CLAUDE_HOOKS_DIR, name);
34598
36117
  const symlinkState = isSymlinkTo(claudeHookPath, canonicalPath);
34599
36118
  if (symlinkState.ok) {
34600
- ok3(`${relative2(CWD, claudeHookPath)} -> ${relative2(dirname6(claudeHookPath), canonicalPath)}`);
36119
+ ok3(`${relative2(CWD, claudeHookPath)} -> ${relative2(dirname7(claudeHookPath), canonicalPath)}`);
34601
36120
  continue;
34602
36121
  }
34603
36122
  allPresent = false;
@@ -34649,14 +36168,14 @@ function checkMCP() {
34649
36168
  }
34650
36169
  function hashFile(path) {
34651
36170
  const hash = createHash4("sha256");
34652
- hash.update(readFileSync22(path));
36171
+ hash.update(readFileSync24(path));
34653
36172
  return hash.digest("hex");
34654
36173
  }
34655
36174
  function collectFileHashes(rootDir) {
34656
36175
  const hashes = new Map;
34657
36176
  const visit2 = (dir) => {
34658
36177
  for (const entry of readdirSync13(dir, { withFileTypes: true })) {
34659
- const fullPath = join28(dir, entry.name);
36178
+ const fullPath = join29(dir, entry.name);
34660
36179
  if (entry.isDirectory()) {
34661
36180
  visit2(fullPath);
34662
36181
  continue;
@@ -34684,8 +36203,8 @@ function isSymlinkTo(linkPath, expectedTargetPath) {
34684
36203
  return { ok: false, reason: "not-symlink" };
34685
36204
  try {
34686
36205
  const rawTarget = readlinkSync2(linkPath);
34687
- const resolvedTarget = resolve7(dirname6(linkPath), rawTarget);
34688
- const resolvedExpected = resolve7(expectedTargetPath);
36206
+ const resolvedTarget = resolve8(dirname7(linkPath), rawTarget);
36207
+ const resolvedExpected = resolve8(expectedTargetPath);
34689
36208
  if (resolvedTarget !== resolvedExpected) {
34690
36209
  return { ok: false, reason: "wrong-target", target: rawTarget };
34691
36210
  }
@@ -34743,7 +36262,7 @@ function checkSkillDrift() {
34743
36262
  }
34744
36263
  let linksOk = true;
34745
36264
  for (const scope of ["claude", "pi"]) {
34746
- const activeRoot = join28(XTRM_ACTIVE_SKILLS_DIR, scope);
36265
+ const activeRoot = join29(XTRM_ACTIVE_SKILLS_DIR, scope);
34747
36266
  if (!existsSync25(activeRoot)) {
34748
36267
  fail4(`${relative2(CWD, activeRoot)}/ missing`);
34749
36268
  fix("specialists init --sync-skills");
@@ -34752,8 +36271,8 @@ function checkSkillDrift() {
34752
36271
  }
34753
36272
  const defaultSkills = readdirSync13(XTRM_DEFAULT_SKILLS_DIR, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
34754
36273
  for (const skillName of defaultSkills) {
34755
- const activeLinkPath = join28(activeRoot, skillName);
34756
- const expectedTarget = join28(XTRM_DEFAULT_SKILLS_DIR, skillName);
36274
+ const activeLinkPath = join29(activeRoot, skillName);
36275
+ const expectedTarget = join29(XTRM_DEFAULT_SKILLS_DIR, skillName);
34757
36276
  const state = isSymlinkTo(activeLinkPath, expectedTarget);
34758
36277
  if (state.ok)
34759
36278
  continue;
@@ -34772,14 +36291,14 @@ function checkSkillDrift() {
34772
36291
  }
34773
36292
  }
34774
36293
  const skillRootChecks = [
34775
- { root: join28(CLAUDE_DIR, "skills"), expected: ACTIVE_CLAUDE_SKILLS_DIR },
34776
- { root: join28(PI_DIR, "skills"), expected: ACTIVE_PI_SKILLS_DIR }
36294
+ { root: join29(CLAUDE_DIR, "skills"), expected: ACTIVE_CLAUDE_SKILLS_DIR },
36295
+ { root: join29(PI_DIR, "skills"), expected: ACTIVE_PI_SKILLS_DIR }
34777
36296
  ];
34778
36297
  let rootLinksOk = true;
34779
36298
  for (const check2 of skillRootChecks) {
34780
36299
  const state = isSymlinkTo(check2.root, check2.expected);
34781
36300
  if (state.ok) {
34782
- ok3(`${relative2(CWD, check2.root)} -> ${relative2(dirname6(check2.root), check2.expected)}`);
36301
+ ok3(`${relative2(CWD, check2.root)} -> ${relative2(dirname7(check2.root), check2.expected)}`);
34783
36302
  continue;
34784
36303
  }
34785
36304
  rootLinksOk = false;
@@ -34799,9 +36318,9 @@ function checkSkillDrift() {
34799
36318
  }
34800
36319
  function checkRuntimeDirs() {
34801
36320
  section3(".specialists/ runtime directories");
34802
- const rootDir = join28(CWD, ".specialists");
34803
- const jobsDir = join28(rootDir, "jobs");
34804
- const readyDir = join28(rootDir, "ready");
36321
+ const rootDir = join29(CWD, ".specialists");
36322
+ const jobsDir = join29(rootDir, "jobs");
36323
+ const readyDir = join29(rootDir, "ready");
34805
36324
  let allOk = true;
34806
36325
  if (!existsSync25(rootDir)) {
34807
36326
  warn3(".specialists/ not found in current project");
@@ -34812,7 +36331,7 @@ function checkRuntimeDirs() {
34812
36331
  for (const [subDir, label] of [[jobsDir, "jobs"], [readyDir, "ready"]]) {
34813
36332
  if (!existsSync25(subDir)) {
34814
36333
  warn3(`.specialists/${label}/ missing \u2014 auto-creating`);
34815
- mkdirSync6(subDir, { recursive: true });
36334
+ mkdirSync8(subDir, { recursive: true });
34816
36335
  ok3(`.specialists/${label}/ created`);
34817
36336
  } else {
34818
36337
  ok3(`.specialists/${label}/ present`);
@@ -34843,10 +36362,10 @@ function compareVersions(left, right) {
34843
36362
  }
34844
36363
  function setStatusError(statusPath) {
34845
36364
  try {
34846
- const raw = readFileSync22(statusPath, "utf8");
36365
+ const raw = readFileSync24(statusPath, "utf8");
34847
36366
  const status = JSON.parse(raw);
34848
36367
  status.status = "error";
34849
- writeFileSync9(statusPath, `${JSON.stringify(status, null, 2)}
36368
+ writeFileSync11(statusPath, `${JSON.stringify(status, null, 2)}
34850
36369
  `, "utf8");
34851
36370
  } catch {}
34852
36371
  }
@@ -34865,11 +36384,11 @@ function cleanupProcesses(jobsDir, dryRun) {
34865
36384
  zombieJobIds: []
34866
36385
  };
34867
36386
  for (const jobId of entries) {
34868
- const statusPath = join28(jobsDir, jobId, "status.json");
36387
+ const statusPath = join29(jobsDir, jobId, "status.json");
34869
36388
  if (!existsSync25(statusPath))
34870
36389
  continue;
34871
36390
  try {
34872
- const status = JSON.parse(readFileSync22(statusPath, "utf8"));
36391
+ const status = JSON.parse(readFileSync24(statusPath, "utf8"));
34873
36392
  result.total += 1;
34874
36393
  if (status.status !== "running" && status.status !== "starting")
34875
36394
  continue;
@@ -34900,9 +36419,52 @@ function renderProcessSummary(result, dryRun) {
34900
36419
  const action = dryRun ? "would be marked error" : "marked error";
34901
36420
  return `${result.zombies} zombie job${result.zombies === 1 ? "" : "s"} found (${result.updated} ${action})`;
34902
36421
  }
36422
+ function runDoctorOrphans() {
36423
+ const sqliteClient = createObservabilitySqliteClient();
36424
+ if (!sqliteClient) {
36425
+ console.log(`
36426
+ ${bold13("specialists doctor orphans")}
36427
+ `);
36428
+ fail4("observability SQLite not available");
36429
+ fix("specialists db setup");
36430
+ console.log("");
36431
+ process.exit(1);
36432
+ }
36433
+ try {
36434
+ const findings = sqliteClient.scanOrphans();
36435
+ const byKind = {
36436
+ orphan: findings.filter((item) => item.kind === "orphan"),
36437
+ stalePointer: findings.filter((item) => item.kind === "stale-pointer"),
36438
+ integrity: findings.filter((item) => item.kind === "integrity-violation")
36439
+ };
36440
+ console.log(`
36441
+ ${bold13("specialists doctor orphans")}
36442
+ `);
36443
+ if (findings.length === 0) {
36444
+ ok3("No orphan/stale/integrity findings");
36445
+ console.log("");
36446
+ return;
36447
+ }
36448
+ const renderGroup = (label, rows) => {
36449
+ if (rows.length === 0)
36450
+ return;
36451
+ console.log(` ${yellow12("\u25CB")} ${label}: ${rows.length}`);
36452
+ for (const row of rows) {
36453
+ console.log(` - [${row.code}] ${row.message}`);
36454
+ }
36455
+ };
36456
+ renderGroup("orphan", byKind.orphan);
36457
+ renderGroup("stale-pointer", byKind.stalePointer);
36458
+ renderGroup("integrity-violation", byKind.integrity);
36459
+ console.log("");
36460
+ process.exit(1);
36461
+ } finally {
36462
+ sqliteClient.close();
36463
+ }
36464
+ }
34903
36465
  function checkZombieJobs() {
34904
36466
  section3("Background jobs");
34905
- const jobsDir = join28(CWD, ".specialists", "jobs");
36467
+ const jobsDir = join29(CWD, ".specialists", "jobs");
34906
36468
  if (!existsSync25(jobsDir)) {
34907
36469
  hint("No .specialists/jobs/ \u2014 skipping");
34908
36470
  return true;
@@ -34921,7 +36483,16 @@ function checkZombieJobs() {
34921
36483
  }
34922
36484
  return result.zombies === 0;
34923
36485
  }
34924
- async function run27() {
36486
+ async function run27(argv = process.argv.slice(3)) {
36487
+ const subcommand = argv[0];
36488
+ if (subcommand === "orphans") {
36489
+ runDoctorOrphans();
36490
+ return;
36491
+ }
36492
+ if (subcommand && subcommand !== "--help" && subcommand !== "-h") {
36493
+ console.error(`Unknown doctor subcommand: '${subcommand}'`);
36494
+ process.exit(1);
36495
+ }
34925
36496
  console.log(`
34926
36497
  ${bold13("specialists doctor")}
34927
36498
  `);
@@ -34946,20 +36517,21 @@ ${bold13("specialists doctor")}
34946
36517
  }
34947
36518
  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, SPECIALISTS_DIR, HOOKS_DIR, CLAUDE_HOOKS_DIR, SETTINGS_FILE, MCP_FILE2, HOOK_NAMES;
34948
36519
  var init_doctor = __esm(() => {
36520
+ init_observability_sqlite();
34949
36521
  CWD = process.cwd();
34950
- CLAUDE_DIR = join28(CWD, ".claude");
34951
- PI_DIR = join28(CWD, ".pi");
34952
- XTRM_SKILLS_DIR = join28(CWD, ".xtrm", "skills");
34953
- XTRM_DEFAULT_SKILLS_DIR = join28(XTRM_SKILLS_DIR, "default");
34954
- XTRM_ACTIVE_SKILLS_DIR = join28(XTRM_SKILLS_DIR, "active");
34955
- ACTIVE_CLAUDE_SKILLS_DIR = join28(XTRM_ACTIVE_SKILLS_DIR, "claude");
34956
- ACTIVE_PI_SKILLS_DIR = join28(XTRM_ACTIVE_SKILLS_DIR, "pi");
34957
- CONFIG_SKILLS_DIR = join28(CWD, "config", "skills");
34958
- SPECIALISTS_DIR = join28(CWD, ".specialists");
34959
- HOOKS_DIR = join28(CWD, ".xtrm", "hooks", "specialists");
34960
- CLAUDE_HOOKS_DIR = join28(CLAUDE_DIR, "hooks");
34961
- SETTINGS_FILE = join28(CLAUDE_DIR, "settings.json");
34962
- MCP_FILE2 = join28(CWD, ".mcp.json");
36522
+ CLAUDE_DIR = join29(CWD, ".claude");
36523
+ PI_DIR = join29(CWD, ".pi");
36524
+ XTRM_SKILLS_DIR = join29(CWD, ".xtrm", "skills");
36525
+ XTRM_DEFAULT_SKILLS_DIR = join29(XTRM_SKILLS_DIR, "default");
36526
+ XTRM_ACTIVE_SKILLS_DIR = join29(XTRM_SKILLS_DIR, "active");
36527
+ ACTIVE_CLAUDE_SKILLS_DIR = join29(XTRM_ACTIVE_SKILLS_DIR, "claude");
36528
+ ACTIVE_PI_SKILLS_DIR = join29(XTRM_ACTIVE_SKILLS_DIR, "pi");
36529
+ CONFIG_SKILLS_DIR = join29(CWD, "config", "skills");
36530
+ SPECIALISTS_DIR = join29(CWD, ".specialists");
36531
+ HOOKS_DIR = join29(CWD, ".xtrm", "hooks", "specialists");
36532
+ CLAUDE_HOOKS_DIR = join29(CLAUDE_DIR, "hooks");
36533
+ SETTINGS_FILE = join29(CLAUDE_DIR, "settings.json");
36534
+ MCP_FILE2 = join29(CWD, ".mcp.json");
34963
36535
  HOOK_NAMES = [
34964
36536
  "specialists-complete.mjs",
34965
36537
  "specialists-session-start.mjs"
@@ -38920,7 +40492,7 @@ var AssertObjectSchema = custom2((v) => v !== null && (typeof v === "object" ||
38920
40492
  var ProgressTokenSchema = union([string2(), number2().int()]);
38921
40493
  var CursorSchema = string2();
38922
40494
  var TaskCreationParamsSchema = looseObject({
38923
- ttl: union([number2(), _null3()]).optional(),
40495
+ ttl: number2().optional(),
38924
40496
  pollInterval: number2().optional()
38925
40497
  });
38926
40498
  var TaskMetadataSchema = object2({
@@ -39074,7 +40646,8 @@ var ClientCapabilitiesSchema = object2({
39074
40646
  roots: object2({
39075
40647
  listChanged: boolean2().optional()
39076
40648
  }).optional(),
39077
- tasks: ClientTasksCapabilitySchema.optional()
40649
+ tasks: ClientTasksCapabilitySchema.optional(),
40650
+ extensions: record(string2(), AssertObjectSchema).optional()
39078
40651
  });
39079
40652
  var InitializeRequestParamsSchema = BaseRequestParamsSchema.extend({
39080
40653
  protocolVersion: string2(),
@@ -39099,7 +40672,8 @@ var ServerCapabilitiesSchema = object2({
39099
40672
  tools: object2({
39100
40673
  listChanged: boolean2().optional()
39101
40674
  }).optional(),
39102
- tasks: ServerTasksCapabilitySchema.optional()
40675
+ tasks: ServerTasksCapabilitySchema.optional(),
40676
+ extensions: record(string2(), AssertObjectSchema).optional()
39103
40677
  });
39104
40678
  var InitializeResultSchema = ResultSchema.extend({
39105
40679
  protocolVersion: string2(),
@@ -39214,6 +40788,7 @@ var ResourceSchema = object2({
39214
40788
  uri: string2(),
39215
40789
  description: optional(string2()),
39216
40790
  mimeType: optional(string2()),
40791
+ size: optional(number2()),
39217
40792
  annotations: AnnotationsSchema.optional(),
39218
40793
  _meta: optional(looseObject({}))
39219
40794
  });
@@ -41209,6 +42784,10 @@ class Protocol {
41209
42784
  this._progressHandlers.clear();
41210
42785
  this._taskProgressTokens.clear();
41211
42786
  this._pendingDebouncedNotifications.clear();
42787
+ for (const info of this._timeoutInfo.values()) {
42788
+ clearTimeout(info.timeoutId);
42789
+ }
42790
+ this._timeoutInfo.clear();
41212
42791
  for (const controller of this._requestHandlerAbortControllers.values()) {
41213
42792
  controller.abort();
41214
42793
  }
@@ -41339,7 +42918,9 @@ class Protocol {
41339
42918
  await capturedTransport?.send(errorResponse);
41340
42919
  }
41341
42920
  }).catch((error2) => this._onerror(new Error(`Failed to send response: ${error2}`))).finally(() => {
41342
- this._requestHandlerAbortControllers.delete(request.id);
42921
+ if (this._requestHandlerAbortControllers.get(request.id) === abortController) {
42922
+ this._requestHandlerAbortControllers.delete(request.id);
42923
+ }
41343
42924
  });
41344
42925
  }
41345
42926
  _onprogress(notification) {
@@ -42818,7 +44399,7 @@ async function run30() {
42818
44399
  if (wantsHelp()) {
42819
44400
  console.log([
42820
44401
  "",
42821
- "Usage: specialists db <setup|backfill>",
44402
+ "Usage: specialists db <setup|backfill|vacuum|prune>",
42822
44403
  "",
42823
44404
  "Provision the shared observability SQLite database (human-only).",
42824
44405
  "",
@@ -42827,6 +44408,9 @@ async function run30() {
42827
44408
  " init Alias for setup",
42828
44409
  " backfill Backfill specialist_jobs from .specialists/jobs/*/status.json",
42829
44410
  " Use --events to also replay events.jsonl",
44411
+ " vacuum Run SQLite VACUUM (refuses when active jobs running/starting)",
44412
+ " prune Prune old rows: requires --before <iso|duration>, dry-run by default",
44413
+ " Use --apply to execute; --include-epics to also prune epic_runs",
42830
44414
  "",
42831
44415
  "Notes:",
42832
44416
  " - TTY required (blocked in agent/non-interactive sessions)",
@@ -42837,6 +44421,9 @@ async function run30() {
42837
44421
  " specialists db setup",
42838
44422
  " specialists db backfill",
42839
44423
  " specialists db backfill --events",
44424
+ " specialists db vacuum",
44425
+ " specialists db prune --before 30d --dry-run",
44426
+ " specialists db prune --before 2026-01-01T00:00:00Z --apply --include-epics",
42840
44427
  " sp db setup",
42841
44428
  " sp db backfill",
42842
44429
  ""
@@ -43508,7 +45095,7 @@ async function run30() {
43508
45095
  if (wantsHelp()) {
43509
45096
  console.log([
43510
45097
  "",
43511
- "Usage: specialists doctor",
45098
+ "Usage: specialists doctor [orphans]",
43512
45099
  "",
43513
45100
  "Diagnose bootstrap and runtime problems.",
43514
45101
  "",
@@ -43525,15 +45112,19 @@ async function run30() {
43525
45112
  " - prints fix hints for failing checks",
43526
45113
  " - auto-creates missing runtime directories when possible",
43527
45114
  "",
45115
+ "Subcommands:",
45116
+ " orphans Read-only orphan scan: membership/jobs/epics/worktree pointers",
45117
+ "",
43528
45118
  "Examples:",
43529
45119
  " specialists doctor",
45120
+ " specialists doctor orphans",
43530
45121
  ""
43531
45122
  ].join(`
43532
45123
  `));
43533
45124
  return;
43534
45125
  }
43535
45126
  const { run: handler } = await Promise.resolve().then(() => (init_doctor(), exports_doctor));
43536
- return handler();
45127
+ return handler(process.argv.slice(3));
43537
45128
  }
43538
45129
  if (sub === "setup") {
43539
45130
  if (wantsHelp()) {