@jaggerxtrm/specialists 3.6.10 → 3.6.11

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
@@ -18966,7 +18966,7 @@ function resolveCurrentBranch(cwd = process.cwd()) {
18966
18966
  var init_job_root = () => {};
18967
18967
 
18968
18968
  // src/specialist/observability-sqlite.ts
18969
- import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
18969
+ import { existsSync as existsSync4, readFileSync as readFileSync2, statSync } from "fs";
18970
18970
  import { join as join5 } from "path";
18971
18971
  function loadBunDatabase() {
18972
18972
  if (_probed)
@@ -19508,7 +19508,9 @@ function migrateToV10(db) {
19508
19508
 
19509
19509
  class SqliteClient {
19510
19510
  db;
19511
+ dbPath;
19511
19512
  constructor(dbPath) {
19513
+ this.dbPath = dbPath;
19512
19514
  const Ctor = loadBunDatabase();
19513
19515
  this.db = new Ctor(dbPath);
19514
19516
  this.db.run(`PRAGMA busy_timeout=${BUSY_TIMEOUT_MS}`);
@@ -20331,6 +20333,242 @@ class SqliteClient {
20331
20333
  transaction();
20332
20334
  }, "invalidateMemoriesCache");
20333
20335
  }
20336
+ hasActiveJobs(statuses = ["running", "starting"]) {
20337
+ return this.listActiveJobs(statuses).length > 0;
20338
+ }
20339
+ listActiveJobs(statuses = ["running", "starting"]) {
20340
+ return withRetry(() => {
20341
+ if (statuses.length === 0)
20342
+ return [];
20343
+ const placeholders = statuses.map(() => "?").join(", ");
20344
+ return this.db.query(`
20345
+ SELECT job_id, specialist, status
20346
+ FROM specialist_jobs
20347
+ WHERE status IN (${placeholders})
20348
+ ORDER BY updated_at_ms DESC
20349
+ `).all(...statuses);
20350
+ }, "listActiveJobs");
20351
+ }
20352
+ getDatabaseSizeBytes() {
20353
+ try {
20354
+ return statSync(this.dbPath).size;
20355
+ } catch {
20356
+ return 0;
20357
+ }
20358
+ }
20359
+ vacuumDatabase() {
20360
+ return withRetry(() => {
20361
+ const beforeBytes = this.getDatabaseSizeBytes();
20362
+ this.db.run("VACUUM");
20363
+ const afterBytes = this.getDatabaseSizeBytes();
20364
+ return { beforeBytes, afterBytes };
20365
+ }, "vacuumDatabase");
20366
+ }
20367
+ pruneObservabilityData(options) {
20368
+ return withRetry(() => {
20369
+ const nowMs = options.nowMs ?? Date.now();
20370
+ const eventsRetentionMs = options.eventsRetentionMs ?? 30 * 24 * 60 * 60 * 1000;
20371
+ const eventsCutoffMs = nowMs - eventsRetentionMs;
20372
+ const terminalStatuses = ["done", "error", "stopped"];
20373
+ const activeStatuses = ["running", "starting", "waiting"];
20374
+ const skippedActiveChainJobs = this.db.query(`
20375
+ SELECT COUNT(*) AS count
20376
+ FROM specialist_jobs stale
20377
+ WHERE stale.updated_at_ms < ?
20378
+ AND stale.status IN (${terminalStatuses.map(() => "?").join(", ")})
20379
+ AND stale.chain_id IS NOT NULL
20380
+ AND EXISTS (
20381
+ SELECT 1
20382
+ FROM specialist_jobs active
20383
+ WHERE active.chain_id = stale.chain_id
20384
+ AND active.status IN (${activeStatuses.map(() => "?").join(", ")})
20385
+ )
20386
+ `).get(options.beforeMs, ...terminalStatuses, ...activeStatuses)?.count ?? 0;
20387
+ const resultCandidates = this.db.query(`
20388
+ SELECT COUNT(*) AS count
20389
+ FROM specialist_results results
20390
+ LEFT JOIN specialist_jobs jobs ON jobs.job_id = results.job_id
20391
+ WHERE results.updated_at_ms < ?
20392
+ AND (
20393
+ jobs.job_id IS NULL
20394
+ OR jobs.chain_id IS NULL
20395
+ OR NOT EXISTS (
20396
+ SELECT 1
20397
+ FROM specialist_jobs active
20398
+ WHERE active.chain_id = jobs.chain_id
20399
+ AND active.status IN (${activeStatuses.map(() => "?").join(", ")})
20400
+ )
20401
+ )
20402
+ `).get(options.beforeMs, ...activeStatuses)?.count ?? 0;
20403
+ const jobCandidates = this.db.query(`
20404
+ SELECT COUNT(*) AS count
20405
+ FROM specialist_jobs stale
20406
+ WHERE stale.updated_at_ms < ?
20407
+ AND stale.status IN (${terminalStatuses.map(() => "?").join(", ")})
20408
+ AND (
20409
+ stale.chain_id IS NULL
20410
+ OR NOT EXISTS (
20411
+ SELECT 1
20412
+ FROM specialist_jobs active
20413
+ WHERE active.chain_id = stale.chain_id
20414
+ AND active.status IN (${activeStatuses.map(() => "?").join(", ")})
20415
+ )
20416
+ )
20417
+ `).get(options.beforeMs, ...terminalStatuses, ...activeStatuses)?.count ?? 0;
20418
+ const eventsCandidates = this.db.query("SELECT COUNT(*) AS count FROM specialist_events WHERE t < ?").get(eventsCutoffMs)?.count ?? 0;
20419
+ const epicCandidates = options.includeEpics ? this.db.query(`
20420
+ SELECT COUNT(*) AS count
20421
+ FROM epic_runs epic
20422
+ WHERE epic.updated_at_ms < ?
20423
+ AND epic.status IN ('merged', 'failed', 'abandoned')
20424
+ AND NOT EXISTS (
20425
+ SELECT 1
20426
+ FROM epic_chain_membership membership
20427
+ WHERE membership.epic_id = epic.epic_id
20428
+ )
20429
+ `).get(options.beforeMs)?.count ?? 0 : 0;
20430
+ if (!options.apply) {
20431
+ return {
20432
+ dryRun: true,
20433
+ beforeMs: options.beforeMs,
20434
+ eventsCutoffMs,
20435
+ includeEpics: options.includeEpics,
20436
+ deletedEvents: eventsCandidates,
20437
+ deletedResults: resultCandidates,
20438
+ deletedJobs: jobCandidates,
20439
+ deletedEpicRuns: epicCandidates,
20440
+ skippedActiveChainJobs
20441
+ };
20442
+ }
20443
+ const deleteResults = this.db.query(`
20444
+ DELETE FROM specialist_results
20445
+ WHERE updated_at_ms < ?
20446
+ AND (
20447
+ job_id NOT IN (SELECT job_id FROM specialist_jobs WHERE chain_id IS NOT NULL)
20448
+ OR job_id IN (
20449
+ SELECT jobs.job_id
20450
+ FROM specialist_jobs jobs
20451
+ WHERE jobs.chain_id IS NULL
20452
+ OR NOT EXISTS (
20453
+ SELECT 1
20454
+ FROM specialist_jobs active
20455
+ WHERE active.chain_id = jobs.chain_id
20456
+ AND active.status IN (${activeStatuses.map(() => "?").join(", ")})
20457
+ )
20458
+ )
20459
+ )
20460
+ `);
20461
+ const deletedResults = deleteResults.run(options.beforeMs, ...activeStatuses).changes ?? 0;
20462
+ const deleteEvents = this.db.query("DELETE FROM specialist_events WHERE t < ?");
20463
+ const deletedEvents = deleteEvents.run(eventsCutoffMs).changes ?? 0;
20464
+ const deleteJobs = this.db.query(`
20465
+ DELETE FROM specialist_jobs
20466
+ WHERE updated_at_ms < ?
20467
+ AND status IN (${terminalStatuses.map(() => "?").join(", ")})
20468
+ AND (
20469
+ chain_id IS NULL
20470
+ OR NOT EXISTS (
20471
+ SELECT 1
20472
+ FROM specialist_jobs active
20473
+ WHERE active.chain_id = specialist_jobs.chain_id
20474
+ AND active.status IN (${activeStatuses.map(() => "?").join(", ")})
20475
+ )
20476
+ )
20477
+ `);
20478
+ const deletedJobs = deleteJobs.run(options.beforeMs, ...terminalStatuses, ...activeStatuses).changes ?? 0;
20479
+ let deletedEpicRuns = 0;
20480
+ if (options.includeEpics) {
20481
+ const deleteEpics = this.db.query(`
20482
+ DELETE FROM epic_runs
20483
+ WHERE updated_at_ms < ?
20484
+ AND status IN ('merged', 'failed', 'abandoned')
20485
+ AND NOT EXISTS (
20486
+ SELECT 1
20487
+ FROM epic_chain_membership membership
20488
+ WHERE membership.epic_id = epic_runs.epic_id
20489
+ )
20490
+ `);
20491
+ deletedEpicRuns = deleteEpics.run(options.beforeMs).changes ?? 0;
20492
+ }
20493
+ return {
20494
+ dryRun: false,
20495
+ beforeMs: options.beforeMs,
20496
+ eventsCutoffMs,
20497
+ includeEpics: options.includeEpics,
20498
+ deletedEvents,
20499
+ deletedResults,
20500
+ deletedJobs,
20501
+ deletedEpicRuns,
20502
+ skippedActiveChainJobs
20503
+ };
20504
+ }, "pruneObservabilityData");
20505
+ }
20506
+ scanOrphans() {
20507
+ return withRetry(() => {
20508
+ const findings = [];
20509
+ const chainMembershipWithoutJobs = this.db.query(`
20510
+ SELECT membership.chain_id, membership.epic_id
20511
+ FROM epic_chain_membership membership
20512
+ LEFT JOIN specialist_jobs jobs ON jobs.chain_id = membership.chain_id
20513
+ WHERE jobs.job_id IS NULL
20514
+ `).all();
20515
+ for (const row of chainMembershipWithoutJobs) {
20516
+ findings.push({
20517
+ kind: "orphan",
20518
+ code: "chain_membership_without_jobs",
20519
+ message: `chain ${row.chain_id} has epic membership but no jobs`,
20520
+ details: { chain_id: row.chain_id, epic_id: row.epic_id }
20521
+ });
20522
+ }
20523
+ const epicsWithoutChains = this.db.query(`
20524
+ SELECT epic.epic_id, epic.status
20525
+ FROM epic_runs epic
20526
+ LEFT JOIN epic_chain_membership membership ON membership.epic_id = epic.epic_id
20527
+ WHERE membership.chain_id IS NULL
20528
+ `).all();
20529
+ for (const row of epicsWithoutChains) {
20530
+ findings.push({
20531
+ kind: "orphan",
20532
+ code: "epic_without_chains",
20533
+ message: `epic ${row.epic_id} has no chain membership`,
20534
+ details: { epic_id: row.epic_id, status: row.status }
20535
+ });
20536
+ }
20537
+ const jobEpicWithoutMembership = this.db.query(`
20538
+ SELECT jobs.job_id, jobs.epic_id, jobs.chain_id
20539
+ FROM specialist_jobs jobs
20540
+ LEFT JOIN epic_chain_membership membership
20541
+ ON membership.chain_id = jobs.chain_id
20542
+ AND membership.epic_id = jobs.epic_id
20543
+ WHERE jobs.epic_id IS NOT NULL
20544
+ AND (jobs.chain_id IS NULL OR membership.chain_id IS NULL)
20545
+ `).all();
20546
+ for (const row of jobEpicWithoutMembership) {
20547
+ findings.push({
20548
+ kind: "integrity-violation",
20549
+ code: "job_epic_without_membership",
20550
+ message: `job ${row.job_id} references epic without chain membership link`,
20551
+ details: { job_id: row.job_id, epic_id: row.epic_id, chain_id: row.chain_id ?? null }
20552
+ });
20553
+ }
20554
+ const worktreeRows = this.db.query(`
20555
+ SELECT DISTINCT job_id, worktree_column
20556
+ FROM specialist_jobs
20557
+ WHERE worktree_column IS NOT NULL AND worktree_column != ''
20558
+ `).all();
20559
+ for (const row of worktreeRows) {
20560
+ if (existsSync4(row.worktree_column))
20561
+ continue;
20562
+ findings.push({
20563
+ kind: "stale-pointer",
20564
+ code: "worktree_missing_on_disk",
20565
+ message: `job ${row.job_id} points to missing worktree path`,
20566
+ details: { job_id: row.job_id, worktree_path: row.worktree_column }
20567
+ });
20568
+ }
20569
+ return findings;
20570
+ }, "scanOrphans");
20571
+ }
20334
20572
  close() {
20335
20573
  this.db.close();
20336
20574
  }
@@ -20944,11 +21182,21 @@ class SpecialistRunner {
20944
21182
  const preScriptOutput = formatScriptOutput(preResults);
20945
21183
  const resolvedPrompt = this.resolvePromptWithBeadContext(options, beadsClient);
20946
21184
  const beadVariables = options.inputBeadId ? { bead_context: resolvedPrompt, bead_id: options.inputBeadId } : {};
20947
- const beadTemplateVariables = options.inputBeadId ? { bead_id: options.inputBeadId, prompt: resolvedPrompt } : {};
21185
+ const lineageVariables = {
21186
+ ...options.reusedFromJobId ? { reused_from_job_id: options.reusedFromJobId } : {},
21187
+ ...options.worktreeOwnerJobId ? { worktree_owner_job_id: options.worktreeOwnerJobId } : {}
21188
+ };
21189
+ const beadTemplateVariables = {
21190
+ prompt: resolvedPrompt,
21191
+ bead_id: options.inputBeadId ?? "",
21192
+ ...lineageVariables
21193
+ };
20948
21194
  const variables = {
20949
21195
  prompt: resolvedPrompt,
20950
21196
  cwd: runCwd,
20951
21197
  pre_script_output: preScriptOutput,
21198
+ bead_id: options.inputBeadId ?? "",
21199
+ ...lineageVariables,
20952
21200
  ...options.variables ?? {},
20953
21201
  ...beadVariables
20954
21202
  };
@@ -20961,7 +21209,7 @@ class SpecialistRunner {
20961
21209
  estimated_tokens: Math.ceil(renderedTask.length / 4),
20962
21210
  system_prompt_present: !!prompt.system
20963
21211
  });
20964
- let agentsMd = options.inputBeadId ? renderTemplate(prompt.system ?? "", beadTemplateVariables) : prompt.system ?? "";
21212
+ let agentsMd = renderTemplate(prompt.system ?? "", beadTemplateVariables);
20965
21213
  {
20966
21214
  const sanitizedBeadId = options.inputBeadId ? sanitizeBeadIdForPrompt(options.inputBeadId) : "";
20967
21215
  const beadInstructions = sanitizedBeadId ? `
@@ -21713,12 +21961,13 @@ function mapCallbackEventToTimelineEvent(callbackEvent, context) {
21713
21961
  return null;
21714
21962
  }
21715
21963
  }
21716
- function createRunStartEvent(specialist, beadId) {
21964
+ function createRunStartEvent(specialist, beadId, startupSnapshot) {
21717
21965
  return {
21718
21966
  t: Date.now(),
21719
21967
  type: TIMELINE_EVENT_TYPES.RUN_START,
21720
21968
  specialist,
21721
- bead_id: beadId
21969
+ bead_id: beadId,
21970
+ ...startupSnapshot ? { startup_snapshot: startupSnapshot } : {}
21722
21971
  };
21723
21972
  }
21724
21973
  function createMetaEvent(model, backend) {
@@ -21938,6 +22187,27 @@ function evaluateEpicMergeReadiness(input) {
21938
22187
  summary: `Epic ${input.epicId} is merge-ready and all chains are terminal.`
21939
22188
  };
21940
22189
  }
22190
+ function appendEpicTransitionAudit(statusJson, entry) {
22191
+ const fallback = {
22192
+ transitions: []
22193
+ };
22194
+ let parsed = fallback;
22195
+ if (statusJson) {
22196
+ try {
22197
+ const candidate = JSON.parse(statusJson);
22198
+ if (candidate && typeof candidate === "object") {
22199
+ parsed = candidate;
22200
+ }
22201
+ } catch {
22202
+ parsed = fallback;
22203
+ }
22204
+ }
22205
+ const previous = Array.isArray(parsed.transitions) ? parsed.transitions.filter((item) => Boolean(item) && typeof item === "object") : [];
22206
+ return JSON.stringify({
22207
+ ...parsed,
22208
+ transitions: [...previous, entry]
22209
+ });
22210
+ }
21941
22211
  function summarizeEpicTransition(epicId, from, to) {
21942
22212
  return `Epic ${epicId}: ${from} -> ${to}`;
21943
22213
  }
@@ -21954,6 +22224,72 @@ var init_epic_lifecycle = __esm(() => {
21954
22224
  };
21955
22225
  });
21956
22226
 
22227
+ // src/specialist/process-liveness.ts
22228
+ import { readFileSync as readFileSync4 } from "fs";
22229
+ function isValidPid(pid) {
22230
+ return Number.isInteger(pid) && pid > 0;
22231
+ }
22232
+ function isAliveBySignal(pid) {
22233
+ try {
22234
+ process.kill(pid, 0);
22235
+ return true;
22236
+ } catch {
22237
+ return false;
22238
+ }
22239
+ }
22240
+ function parseBootTimeMs() {
22241
+ try {
22242
+ const procStat = readFileSync4("/proc/stat", "utf-8");
22243
+ const bootLine = procStat.split(`
22244
+ `).find((line) => line.startsWith("btime "));
22245
+ if (!bootLine)
22246
+ return;
22247
+ const bootSeconds = Number.parseInt(bootLine.split(/\s+/)[1] ?? "", 10);
22248
+ if (!Number.isFinite(bootSeconds) || bootSeconds <= 0)
22249
+ return;
22250
+ return bootSeconds * 1000;
22251
+ } catch {
22252
+ return;
22253
+ }
22254
+ }
22255
+ function parseProcessStartTimeMs(pid) {
22256
+ try {
22257
+ const statRaw = readFileSync4(`/proc/${pid}/stat`, "utf-8");
22258
+ const closeParenIndex = statRaw.lastIndexOf(")");
22259
+ if (closeParenIndex < 0)
22260
+ return;
22261
+ const suffix = statRaw.slice(closeParenIndex + 1).trim();
22262
+ const fields = suffix.split(/\s+/);
22263
+ const startTimeTicksText = fields[PROC_STAT_START_TIME_INDEX - 2];
22264
+ if (!startTimeTicksText)
22265
+ return;
22266
+ const startTimeTicks = Number.parseInt(startTimeTicksText, 10);
22267
+ if (!Number.isFinite(startTimeTicks) || startTimeTicks < 0)
22268
+ return;
22269
+ const bootTimeMs = parseBootTimeMs();
22270
+ if (bootTimeMs === undefined)
22271
+ return;
22272
+ const ticksPerSecond = 100;
22273
+ return bootTimeMs + Math.floor(startTimeTicks * 1000 / ticksPerSecond);
22274
+ } catch {
22275
+ return;
22276
+ }
22277
+ }
22278
+ function isProcessAlive(pid, startTimeMs) {
22279
+ if (!isValidPid(pid))
22280
+ return false;
22281
+ if (!isAliveBySignal(pid))
22282
+ return false;
22283
+ if (startTimeMs === undefined)
22284
+ return true;
22285
+ const actualStartTimeMs = parseProcessStartTimeMs(pid);
22286
+ if (actualStartTimeMs === undefined)
22287
+ return true;
22288
+ return Math.abs(actualStartTimeMs - startTimeMs) <= 2000;
22289
+ }
22290
+ var PROC_STAT_START_TIME_INDEX = 21;
22291
+ var init_process_liveness = () => {};
22292
+
21957
22293
  // src/specialist/epic-readiness.ts
21958
22294
  function parseReviewerVerdict(output) {
21959
22295
  if (!output)
@@ -21984,7 +22320,19 @@ function evaluateChainReadiness(chainId, jobs, chainRootBeadId) {
21984
22320
  }
21985
22321
  const orderedJobs = [...jobs].sort((a, b) => a.started_at_ms - b.started_at_ms);
21986
22322
  const activeJobs = orderedJobs.filter((job) => ACTIVE_JOB_STATUSES.has(job.status));
22323
+ const deadActiveJobs = activeJobs.filter((job) => job.pid !== undefined && !isProcessAlive(job.pid, job.started_at_ms));
21987
22324
  const hasActiveJobs = activeJobs.length > 0;
22325
+ if (deadActiveJobs.length > 0) {
22326
+ return {
22327
+ chain_id: chainId,
22328
+ chain_root_bead_id: chainRootBeadId,
22329
+ state: "failed",
22330
+ reviewer_verdict: "missing",
22331
+ blocking_reason: `Active chain jobs appear dead: ${deadActiveJobs.map((job) => job.id).join(", ")}`,
22332
+ has_active_jobs: false,
22333
+ job_ids: orderedJobs.map((job) => job.id)
22334
+ };
22335
+ }
21988
22336
  if (hasActiveJobs) {
21989
22337
  return {
21990
22338
  chain_id: chainId,
@@ -22151,6 +22499,7 @@ function loadEpicReadinessSummary(sqlite, epicId) {
22151
22499
  id: status.id,
22152
22500
  specialist: status.specialist,
22153
22501
  status: status.status,
22502
+ pid: status.pid,
22154
22503
  started_at_ms: status.started_at_ms,
22155
22504
  result_text: sqlite.readResult(status.id) ?? undefined
22156
22505
  }));
@@ -22193,6 +22542,7 @@ function syncEpicStateFromReadiness(sqlite, summary) {
22193
22542
  var ACTIVE_JOB_STATUSES, TERMINAL_JOB_STATUSES, REVIEWER_VERDICT_REGEX;
22194
22543
  var init_epic_readiness = __esm(() => {
22195
22544
  init_epic_lifecycle();
22545
+ init_process_liveness();
22196
22546
  ACTIVE_JOB_STATUSES = new Set(["starting", "running", "waiting"]);
22197
22547
  TERMINAL_JOB_STATUSES = new Set(["done", "error"]);
22198
22548
  REVIEWER_VERDICT_REGEX = /Verdict:\s*(PASS|PARTIAL|FAIL)/i;
@@ -22271,10 +22621,10 @@ import {
22271
22621
  mkdirSync as mkdirSync3,
22272
22622
  openSync,
22273
22623
  readdirSync,
22274
- readFileSync as readFileSync4,
22624
+ readFileSync as readFileSync5,
22275
22625
  renameSync,
22276
22626
  rmSync,
22277
- statSync,
22627
+ statSync as statSync2,
22278
22628
  writeFileSync as writeFileSync3,
22279
22629
  writeSync
22280
22630
  } from "fs";
@@ -22685,7 +23035,7 @@ class Supervisor {
22685
23035
  if (!existsSync7(path))
22686
23036
  return null;
22687
23037
  try {
22688
- const status = JSON.parse(readFileSync4(path, "utf-8"));
23038
+ const status = JSON.parse(readFileSync5(path, "utf-8"));
22689
23039
  return this.withComputedLiveness(status);
22690
23040
  } catch {
22691
23041
  return null;
@@ -22727,7 +23077,7 @@ class Supervisor {
22727
23077
  if (!existsSync7(path))
22728
23078
  continue;
22729
23079
  try {
22730
- const status = JSON.parse(readFileSync4(path, "utf-8"));
23080
+ const status = JSON.parse(readFileSync5(path, "utf-8"));
22731
23081
  jobs.push(this.withComputedLiveness(status));
22732
23082
  } catch {}
22733
23083
  }
@@ -22807,7 +23157,7 @@ class Supervisor {
22807
23157
  for (const entry of readdirSync(this.resolvedJobsDir)) {
22808
23158
  const dir = join8(this.resolvedJobsDir, entry);
22809
23159
  try {
22810
- const stat2 = statSync(dir);
23160
+ const stat2 = statSync2(dir);
22811
23161
  if (!stat2.isDirectory())
22812
23162
  continue;
22813
23163
  if (stat2.mtimeMs < cutoff)
@@ -22828,7 +23178,7 @@ class Supervisor {
22828
23178
  if (!existsSync7(statusPath))
22829
23179
  continue;
22830
23180
  try {
22831
- const s = JSON.parse(readFileSync4(statusPath, "utf-8"));
23181
+ const s = JSON.parse(readFileSync5(statusPath, "utf-8"));
22832
23182
  if (s.status === "running" || s.status === "starting") {
22833
23183
  if (!s.pid)
22834
23184
  continue;
@@ -22890,6 +23240,32 @@ class Supervisor {
22890
23240
  mkdirSync3(dir, { recursive: true });
22891
23241
  mkdirSync3(this.readyDir(), { recursive: true });
22892
23242
  const nodeId = runOptions.variables?.node_id ?? runOptions.variables?.SPECIALISTS_NODE_ID;
23243
+ const variablesKeys = Object.keys(runOptions.variables ?? {});
23244
+ const activatedSkills = (runOptions.variables?.activated_skills ?? runOptions.variables?.skills_activated ?? "").split(",").map((skill) => skill.trim()).filter((skill) => skill.length > 0);
23245
+ const startupContext = {
23246
+ job_id: id,
23247
+ specialist_name: runOptions.name,
23248
+ ...runOptions.inputBeadId ? { bead_id: runOptions.inputBeadId } : {},
23249
+ ...runOptions.reusedFromJobId ? { reused_from_job_id: runOptions.reusedFromJobId } : {},
23250
+ ...runOptions.worktreeOwnerJobId ? { worktree_owner_job_id: runOptions.worktreeOwnerJobId } : {},
23251
+ ...runOptions.worktreeOwnerJobId || runOptions.workingDirectory ? {
23252
+ chain_id: runOptions.worktreeOwnerJobId ?? id,
23253
+ chain_root_job_id: runOptions.worktreeOwnerJobId ?? id
23254
+ } : {},
23255
+ ...runOptions.variables?.chain_root_bead_id ? { chain_root_bead_id: runOptions.variables.chain_root_bead_id } : {},
23256
+ ...runOptions.workingDirectory ? { worktree_path: runOptions.workingDirectory } : {},
23257
+ ...runOptions.workingDirectory ? { branch: resolveCurrentBranch(runOptions.workingDirectory) } : { branch: resolveCurrentBranch() },
23258
+ variables_keys: variablesKeys,
23259
+ reviewed_job_id_present: variablesKeys.includes("reviewed_job_id"),
23260
+ reused_worktree_awareness_present: variablesKeys.includes("reused_worktree_awareness"),
23261
+ bead_context_present: variablesKeys.includes("bead_context"),
23262
+ ...activatedSkills.length > 0 ? {
23263
+ skills: {
23264
+ count: activatedSkills.length,
23265
+ activated: activatedSkills
23266
+ }
23267
+ } : {}
23268
+ };
22893
23269
  const initialStatus = {
22894
23270
  id,
22895
23271
  specialist: runOptions.name,
@@ -22908,7 +23284,8 @@ class Supervisor {
22908
23284
  chain_root_job_id: runOptions.worktreeOwnerJobId ?? id
22909
23285
  } : { chain_kind: "prep" },
22910
23286
  ...runOptions.epicId ? { epic_id: runOptions.epicId } : {},
22911
- ...runOptions.workingDirectory ? { branch: resolveCurrentBranch(runOptions.workingDirectory) } : { branch: resolveCurrentBranch() }
23287
+ ...runOptions.workingDirectory ? { branch: resolveCurrentBranch(runOptions.workingDirectory) } : { branch: resolveCurrentBranch() },
23288
+ startup_context: startupContext
22912
23289
  };
22913
23290
  this.writeStatusFileOnly(id, initialStatus);
22914
23291
  const statusWatchdogPid = startDetachedStatusWatchdog(this.statusPath(id), process.pid);
@@ -22978,7 +23355,7 @@ class Supervisor {
22978
23355
  appendTimelineEvent(createStatusChangeEvent("waiting", previousStatus));
22979
23356
  }
22980
23357
  };
22981
- const runStartEvent = appendTimelineEventFileOnly(createRunStartEvent(runOptions.name, runOptions.inputBeadId));
23358
+ const runStartEvent = appendTimelineEventFileOnly(createRunStartEvent(runOptions.name, runOptions.inputBeadId, statusSnapshot.startup_context));
22982
23359
  try {
22983
23360
  this.withSqliteOperation("upsertStatusWithEvent:run_start", (client) => client.upsertStatusWithEvent(statusSnapshot, runStartEvent));
22984
23361
  } catch (error2) {
@@ -23283,6 +23660,14 @@ ${appendError}
23283
23660
  return;
23284
23661
  }
23285
23662
  })();
23663
+ if (eventType === "memory_injection" && memoryInjection) {
23664
+ setStatus({
23665
+ startup_context: {
23666
+ ...statusSnapshot.startup_context ?? {},
23667
+ memory_injection: memoryInjection
23668
+ }
23669
+ });
23670
+ }
23286
23671
  const timelineEvent = mapCallbackEventToTimelineEvent(eventType, {
23287
23672
  tool: toolState?.tool,
23288
23673
  toolCallId,
@@ -23731,7 +24116,7 @@ __export(exports_list, {
23731
24116
  ArgParseError: () => ArgParseError
23732
24117
  });
23733
24118
  import { spawnSync as spawnSync7 } from "child_process";
23734
- import { existsSync as existsSync8, readdirSync as readdirSync2, readFileSync as readFileSync5 } from "fs";
24119
+ import { existsSync as existsSync8, readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
23735
24120
  import { join as join9 } from "path";
23736
24121
  import readline from "readline";
23737
24122
  function permissionBadge(permission) {
@@ -23764,7 +24149,7 @@ function toLiveJob(status) {
23764
24149
  }
23765
24150
  function readJobStatus(statusPath) {
23766
24151
  try {
23767
- return JSON.parse(readFileSync5(statusPath, "utf-8"));
24152
+ return JSON.parse(readFileSync6(statusPath, "utf-8"));
23768
24153
  } catch {
23769
24154
  return null;
23770
24155
  }
@@ -24333,7 +24718,7 @@ var exports_init = {};
24333
24718
  __export(exports_init, {
24334
24719
  run: () => run6
24335
24720
  });
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";
24721
+ 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
24722
  import { spawnSync as spawnSync9 } from "child_process";
24338
24723
  import { basename as basename3, dirname as dirname5, join as join10, relative, resolve as resolve4 } from "path";
24339
24724
  import { fileURLToPath as fileURLToPath2 } from "url";
@@ -24375,7 +24760,7 @@ function loadJson(path, fallback) {
24375
24760
  if (!existsSync9(path))
24376
24761
  return structuredClone(fallback);
24377
24762
  try {
24378
- return JSON.parse(readFileSync6(path, "utf-8"));
24763
+ return JSON.parse(readFileSync7(path, "utf-8"));
24379
24764
  } catch {
24380
24765
  return structuredClone(fallback);
24381
24766
  }
@@ -24718,7 +25103,7 @@ function ensureProjectMcp(cwd) {
24718
25103
  }
24719
25104
  function ensureGitignore(cwd) {
24720
25105
  const gitignorePath = join10(cwd, ".gitignore");
24721
- const existing = existsSync9(gitignorePath) ? readFileSync6(gitignorePath, "utf-8") : "";
25106
+ const existing = existsSync9(gitignorePath) ? readFileSync7(gitignorePath, "utf-8") : "";
24722
25107
  let added = 0;
24723
25108
  const lines = existing.split(`
24724
25109
  `);
@@ -24765,7 +25150,7 @@ function ensureObservabilityDb(cwd) {
24765
25150
  function ensureAgentsMd(cwd) {
24766
25151
  const agentsPath = join10(cwd, "AGENTS.md");
24767
25152
  if (existsSync9(agentsPath)) {
24768
- const existing = readFileSync6(agentsPath, "utf-8");
25153
+ const existing = readFileSync7(agentsPath, "utf-8");
24769
25154
  if (existing.includes(AGENTS_MARKER)) {
24770
25155
  skip("AGENTS.md already has Specialists section");
24771
25156
  } else {
@@ -24783,7 +25168,7 @@ function readJsonObject(path) {
24783
25168
  if (!existsSync9(path))
24784
25169
  return {};
24785
25170
  try {
24786
- return JSON.parse(readFileSync6(path, "utf-8"));
25171
+ return JSON.parse(readFileSync7(path, "utf-8"));
24787
25172
  } catch {
24788
25173
  return {};
24789
25174
  }
@@ -25056,35 +25441,77 @@ var exports_db = {};
25056
25441
  __export(exports_db, {
25057
25442
  run: () => run8
25058
25443
  });
25059
- import { existsSync as existsSync10, readdirSync as readdirSync4, readFileSync as readFileSync7 } from "fs";
25060
- import { join as join11 } from "path";
25444
+ import { existsSync as existsSync10, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
25445
+ import { dirname as dirname6, join as join11, resolve as resolve5 } from "path";
25446
+ function formatBytes(bytes) {
25447
+ if (bytes < 1024)
25448
+ return `${bytes} B`;
25449
+ const units = ["KB", "MB", "GB", "TB"];
25450
+ let value = bytes;
25451
+ let unitIndex = -1;
25452
+ while (value >= 1024 && unitIndex < units.length - 1) {
25453
+ value /= 1024;
25454
+ unitIndex += 1;
25455
+ }
25456
+ return `${value.toFixed(2)} ${units[unitIndex]}`;
25457
+ }
25458
+ function parseIsoDate(input2) {
25459
+ const parsed = Date.parse(input2);
25460
+ return Number.isFinite(parsed) ? parsed : null;
25461
+ }
25462
+ function parseDuration(input2) {
25463
+ const match = input2.trim().toLowerCase().match(/^(\d+)([smhdw])$/);
25464
+ if (!match)
25465
+ return null;
25466
+ const amount = Number(match[1]);
25467
+ const unit = match[2];
25468
+ if (!Number.isFinite(amount) || amount <= 0)
25469
+ return null;
25470
+ const multipliers = {
25471
+ s: 1000,
25472
+ m: 60 * 1000,
25473
+ h: 60 * 60 * 1000,
25474
+ d: DAY_MS,
25475
+ w: 7 * DAY_MS
25476
+ };
25477
+ return amount * multipliers[unit];
25478
+ }
25479
+ function parseBeforeArgument(raw) {
25480
+ const durationMs = parseDuration(raw);
25481
+ if (durationMs !== null)
25482
+ return Date.now() - durationMs;
25483
+ const isoMs = parseIsoDate(raw);
25484
+ if (isoMs !== null)
25485
+ return isoMs;
25486
+ throw new Error(`Invalid --before value '${raw}'. Use ISO date or duration like 7d.`);
25487
+ }
25061
25488
  function printDbHelp() {
25062
25489
  console.log([
25063
25490
  "",
25064
- "Usage: specialists db <setup|backfill>",
25491
+ "Usage: specialists db <setup|backfill|vacuum|prune>",
25065
25492
  "",
25066
- "Human-only commands for the shared observability SQLite database.",
25493
+ "Human-only commands for shared observability SQLite database.",
25067
25494
  "",
25068
25495
  "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",
25496
+ " setup Provision database file + schema + .gitignore entries",
25497
+ " init Alias for setup",
25498
+ " backfill [--events] Import historical .specialists/jobs/*/status.json rows",
25499
+ " vacuum Run SQLite VACUUM (refuses when running/starting jobs exist)",
25500
+ " prune --before <iso|duration> Prune old rows (default dry-run)",
25501
+ " [--dry-run] [--apply] [--include-epics]",
25073
25502
  "",
25074
25503
  "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)",
25504
+ " - prune keeps specialist_events last 30 days always",
25505
+ " - prune removes specialist_results and terminal specialist_jobs older than --before",
25506
+ " - prune never touches active-chain jobs",
25507
+ " - prune never touches epic_runs unless --include-epics",
25081
25508
  "",
25082
25509
  "Examples:",
25083
25510
  " specialists db setup",
25084
- " specialists db backfill",
25085
25511
  " specialists db backfill --events",
25086
- " sp db setup",
25087
- " sp db backfill",
25512
+ " specialists db vacuum",
25513
+ " specialists db prune --before 30d --dry-run",
25514
+ " specialists db prune --before 2026-01-01T00:00:00Z --apply --include-epics",
25088
25515
  ""
25089
25516
  ].join(`
25090
25517
  `));
@@ -25094,7 +25521,7 @@ function assertHumanInteractiveTerminal(commandName) {
25094
25521
  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
25522
  if (!inAgentSession)
25096
25523
  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.`);
25524
+ console.error(`specialists db ${commandName} requires interactive terminal. user-only setup command.`);
25098
25525
  process.exit(1);
25099
25526
  }
25100
25527
  function printSetupResult(created, gitignoreUpdated, location) {
@@ -25123,9 +25550,48 @@ function parseBackfillOptions(argv) {
25123
25550
  }
25124
25551
  return { importEvents };
25125
25552
  }
25553
+ function parsePruneOptions(argv) {
25554
+ let beforeValue = null;
25555
+ let apply = false;
25556
+ let dryRun = true;
25557
+ let includeEpics = false;
25558
+ for (let index = 0;index < argv.length; index += 1) {
25559
+ const argument = argv[index];
25560
+ if (argument === "--before") {
25561
+ const value = argv[index + 1];
25562
+ if (!value)
25563
+ throw new Error("Missing value for --before");
25564
+ beforeValue = value;
25565
+ index += 1;
25566
+ continue;
25567
+ }
25568
+ if (argument === "--apply") {
25569
+ apply = true;
25570
+ dryRun = false;
25571
+ continue;
25572
+ }
25573
+ if (argument === "--dry-run") {
25574
+ dryRun = true;
25575
+ apply = false;
25576
+ continue;
25577
+ }
25578
+ if (argument === "--include-epics") {
25579
+ includeEpics = true;
25580
+ continue;
25581
+ }
25582
+ throw new Error(`Unknown option for db prune: '${argument}'`);
25583
+ }
25584
+ if (!beforeValue)
25585
+ throw new Error("Missing required --before for db prune");
25586
+ return {
25587
+ beforeMs: parseBeforeArgument(beforeValue),
25588
+ apply: apply && !dryRun,
25589
+ includeEpics
25590
+ };
25591
+ }
25126
25592
  function parseStatusFile(jobDirectoryPath, fallbackJobId) {
25127
25593
  const statusPath = join11(jobDirectoryPath, "status.json");
25128
- const statusRaw = readFileSync7(statusPath, "utf-8");
25594
+ const statusRaw = readFileSync8(statusPath, "utf-8");
25129
25595
  const parsed = JSON.parse(statusRaw);
25130
25596
  const jobId = typeof parsed.id === "string" && parsed.id.length > 0 ? parsed.id : fallbackJobId;
25131
25597
  const specialist = typeof parsed.specialist === "string" && parsed.specialist.length > 0 ? parsed.specialist : "unknown";
@@ -25142,7 +25608,7 @@ function parseStatusFile(jobDirectoryPath, fallbackJobId) {
25142
25608
  function replayEvents(eventsPath, sqliteClient, status) {
25143
25609
  if (!existsSync10(eventsPath))
25144
25610
  return 0;
25145
- const rawContent = readFileSync7(eventsPath, "utf-8");
25611
+ const rawContent = readFileSync8(eventsPath, "utf-8");
25146
25612
  const lines = rawContent.split(`
25147
25613
  `).map((line) => line.trim()).filter((line) => line.length > 0);
25148
25614
  let importedEvents = 0;
@@ -25239,6 +25705,231 @@ ${bold7("specialists db backfill")}
25239
25705
  }
25240
25706
  console.log("");
25241
25707
  }
25708
+ function runVacuum() {
25709
+ const sqliteClient = createObservabilitySqliteClient();
25710
+ if (!sqliteClient) {
25711
+ throw new Error("Failed to initialize observability SQLite schema. Run `specialists db setup` first and ensure sqlite3 is installed.");
25712
+ }
25713
+ try {
25714
+ const activeJobs = sqliteClient.listActiveJobs(["running", "starting"]);
25715
+ if (activeJobs.length > 0) {
25716
+ const listing = activeJobs.slice(0, 5).map((job) => `${job.job_id}:${job.status}`).join(", ");
25717
+ throw new Error(`Refusing vacuum while active jobs exist (${activeJobs.length}): ${listing}`);
25718
+ }
25719
+ const { beforeBytes, afterBytes } = sqliteClient.vacuumDatabase();
25720
+ const savedBytes = Math.max(0, beforeBytes - afterBytes);
25721
+ console.log(`
25722
+ ${bold7("specialists db vacuum")}
25723
+ `);
25724
+ console.log(` ${green5("\u2713")} before: ${formatBytes(beforeBytes)} (${beforeBytes} bytes)`);
25725
+ console.log(` ${green5("\u2713")} after: ${formatBytes(afterBytes)} (${afterBytes} bytes)`);
25726
+ console.log(` ${green5("\u2713")} saved: ${formatBytes(savedBytes)} (${savedBytes} bytes)`);
25727
+ console.log("");
25728
+ } finally {
25729
+ sqliteClient.close();
25730
+ }
25731
+ }
25732
+ function runPrune(options) {
25733
+ const sqliteClient = createObservabilitySqliteClient();
25734
+ if (!sqliteClient) {
25735
+ throw new Error("Failed to initialize observability SQLite schema. Run `specialists db setup` first and ensure sqlite3 is installed.");
25736
+ }
25737
+ try {
25738
+ const report = sqliteClient.pruneObservabilityData({
25739
+ beforeMs: options.beforeMs,
25740
+ includeEpics: options.includeEpics,
25741
+ apply: options.apply
25742
+ });
25743
+ console.log(`
25744
+ ${bold7("specialists db prune")}
25745
+ `);
25746
+ console.log(` ${report.dryRun ? yellow6("\u25CB dry-run") : green5("\u2713 applied")}`);
25747
+ console.log(` ${green5("\u2713")} before: ${new Date(report.beforeMs).toISOString()}`);
25748
+ console.log(` ${green5("\u2713")} events cutoff (fixed 30d): ${new Date(report.eventsCutoffMs).toISOString()}`);
25749
+ console.log(` ${green5("\u2713")} specialist_events: ${report.deletedEvents}`);
25750
+ console.log(` ${green5("\u2713")} specialist_results: ${report.deletedResults}`);
25751
+ console.log(` ${green5("\u2713")} specialist_jobs: ${report.deletedJobs}`);
25752
+ console.log(` ${report.includeEpics ? green5("\u2713") : yellow6("\u25CB")} epic_runs: ${report.deletedEpicRuns} ${report.includeEpics ? "" : "(skipped, use --include-epics)"}`);
25753
+ console.log(` ${yellow6("\u25CB")} skipped active-chain jobs: ${report.skippedActiveChainJobs}`);
25754
+ console.log("");
25755
+ } finally {
25756
+ sqliteClient.close();
25757
+ }
25758
+ }
25759
+ function parseBenchmarkExportOptions(argv) {
25760
+ const defaultOutput = resolve5(process.cwd(), ".specialists/benchmarks/executor-benchmark-rows.jsonl");
25761
+ let outputPath = defaultOutput;
25762
+ let epicId;
25763
+ let includePrepJobs = false;
25764
+ for (let i = 0;i < argv.length; i += 1) {
25765
+ const argument = argv[i];
25766
+ if (argument === "--output" && argv[i + 1]) {
25767
+ outputPath = resolve5(process.cwd(), argv[i + 1]);
25768
+ i += 1;
25769
+ continue;
25770
+ }
25771
+ if (argument === "--epic" && argv[i + 1]) {
25772
+ epicId = argv[i + 1];
25773
+ i += 1;
25774
+ continue;
25775
+ }
25776
+ if (argument === "--include-prep") {
25777
+ includePrepJobs = true;
25778
+ continue;
25779
+ }
25780
+ throw new Error(`Unknown option for db benchmark-export: '${argument}'`);
25781
+ }
25782
+ return { outputPath, epicId, includePrepJobs };
25783
+ }
25784
+ function parseReviewerVerdict2(output2) {
25785
+ if (!output2)
25786
+ return "MISSING";
25787
+ const match = output2.match(/Verdict:\s*(PASS|PARTIAL|FAIL)/i);
25788
+ if (!match?.[1])
25789
+ return "MISSING";
25790
+ return match[1].toUpperCase();
25791
+ }
25792
+ function parseReviewerScore(output2) {
25793
+ if (!output2)
25794
+ return null;
25795
+ const match = output2.match(/(?:Reviewer\s+)?Score(?:\s*\(0-100\))?\s*[:=]\s*(\d+(?:\.\d+)?)/i);
25796
+ return match?.[1] ? Number(match[1]) : null;
25797
+ }
25798
+ function parseGateResult(output2, key) {
25799
+ if (!output2)
25800
+ return null;
25801
+ 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;
25802
+ const match = output2.match(regex);
25803
+ if (!match?.[1])
25804
+ return null;
25805
+ const normalized = match[1].toLowerCase();
25806
+ return normalized === "true" || normalized === "pass";
25807
+ }
25808
+ function readLatestRunCompleteEvent(events) {
25809
+ for (let index = events.length - 1;index >= 0; index -= 1) {
25810
+ const event = events[index];
25811
+ if (event?.type === "run_complete") {
25812
+ return event;
25813
+ }
25814
+ }
25815
+ return null;
25816
+ }
25817
+ function inferFailureNotes(input2) {
25818
+ const notes = [];
25819
+ if (input2.runComplete?.status === "ERROR") {
25820
+ notes.push(`run_complete_status=ERROR${input2.runComplete.error ? `: ${input2.runComplete.error}` : ""}`);
25821
+ }
25822
+ if (input2.runComplete?.status === "CANCELLED") {
25823
+ notes.push("run_complete_status=CANCELLED");
25824
+ }
25825
+ if (input2.runComplete?.exit_reason) {
25826
+ notes.push(`exit_reason=${input2.runComplete.exit_reason}`);
25827
+ }
25828
+ if (input2.runComplete?.finish_reason) {
25829
+ notes.push(`finish_reason=${input2.runComplete.finish_reason}`);
25830
+ }
25831
+ if (input2.status.error) {
25832
+ notes.push(`status_error=${input2.status.error}`);
25833
+ }
25834
+ if (input2.reviewerVerdict !== "PASS" && input2.hasLaterExecutorInChain) {
25835
+ notes.push("fix_loop_rerun_detected_after_non_pass_review");
25836
+ }
25837
+ if (!input2.runComplete) {
25838
+ notes.push("missing_run_complete_event_fallback_to_status_metrics");
25839
+ }
25840
+ return notes;
25841
+ }
25842
+ function runBenchmarkExport(options) {
25843
+ const sqliteClient = createObservabilitySqliteClient();
25844
+ if (!sqliteClient) {
25845
+ throw new Error("Failed to initialize observability SQLite schema. Run `specialists db setup` first and ensure sqlite3 is installed.");
25846
+ }
25847
+ try {
25848
+ const statuses = sqliteClient.listStatuses().filter((status) => options.includePrepJobs || status.chain_kind === "chain").filter((status) => !options.epicId || status.epic_id === options.epicId);
25849
+ const byChain = new Map;
25850
+ for (const status of statuses) {
25851
+ const chainId = status.chain_id ?? `job:${status.id}`;
25852
+ const group = byChain.get(chainId) ?? [];
25853
+ group.push(status);
25854
+ byChain.set(chainId, group);
25855
+ }
25856
+ const rows = [];
25857
+ for (const chainStatuses of byChain.values()) {
25858
+ const ordered = [...chainStatuses].sort((a, b) => a.started_at_ms - b.started_at_ms);
25859
+ const executorStatuses = ordered.filter((status) => status.specialist === "executor");
25860
+ const reviewerStatuses = ordered.filter((status) => status.specialist === "reviewer");
25861
+ executorStatuses.forEach((executorStatus, executorIndex) => {
25862
+ const nextExecutor = executorStatuses[executorIndex + 1];
25863
+ const reviewer = reviewerStatuses.find((candidate) => {
25864
+ if (candidate.started_at_ms < executorStatus.started_at_ms)
25865
+ return false;
25866
+ if (!nextExecutor)
25867
+ return true;
25868
+ return candidate.started_at_ms < nextExecutor.started_at_ms;
25869
+ }) ?? null;
25870
+ const runComplete = readLatestRunCompleteEvent(sqliteClient.readEvents(executorStatus.id));
25871
+ const reviewerOutput = reviewer ? sqliteClient.readResult(reviewer.id) : null;
25872
+ const reviewerVerdict = parseReviewerVerdict2(reviewerOutput);
25873
+ const totalTokens = runComplete?.token_usage?.total_tokens ?? runComplete?.metrics?.token_usage?.total_tokens ?? executorStatus.metrics?.token_usage?.total_tokens ?? null;
25874
+ const costUsd = runComplete?.token_usage?.cost_usd ?? runComplete?.metrics?.token_usage?.cost_usd ?? executorStatus.metrics?.token_usage?.cost_usd ?? null;
25875
+ const elapsedMs = runComplete ? Math.round(runComplete.elapsed_s * 1000) : typeof executorStatus.elapsed_s === "number" ? Math.round(executorStatus.elapsed_s * 1000) : null;
25876
+ const hasLaterExecutorInChain = Boolean(nextExecutor);
25877
+ const failureNotes = inferFailureNotes({
25878
+ status: executorStatus,
25879
+ runComplete,
25880
+ reviewerVerdict,
25881
+ hasLaterExecutorInChain
25882
+ });
25883
+ rows.push({
25884
+ task_id: executorStatus.chain_root_bead_id ?? executorStatus.bead_id ?? "unknown_task",
25885
+ model_id: executorStatus.model ?? null,
25886
+ executor_job_id: executorStatus.id,
25887
+ reviewer_job_id: reviewer?.id ?? null,
25888
+ lint_pass: parseGateResult(reviewerOutput, "lint"),
25889
+ tsc_pass: parseGateResult(reviewerOutput, "tsc"),
25890
+ reviewer_verdict: reviewerVerdict,
25891
+ reviewer_score_if_present: parseReviewerScore(reviewerOutput),
25892
+ total_tokens: totalTokens,
25893
+ cost_usd: costUsd,
25894
+ elapsed_ms: elapsedMs,
25895
+ failure_notes: failureNotes,
25896
+ source_of_truth: {
25897
+ task_id: "specialist_jobs.chain_root_bead_id fallback bead_id",
25898
+ model_id: "specialist_jobs.status_json.model",
25899
+ executor_job_id: "specialist_jobs.job_id",
25900
+ reviewer_job_id: "specialist_jobs.job_id where specialist=reviewer in same chain window",
25901
+ lint_pass: "reviewer specialist_results.output regex parse; null when absent",
25902
+ tsc_pass: "reviewer specialist_results.output regex parse; null when absent",
25903
+ reviewer_verdict: "reviewer specialist_results.output Verdict: PASS|PARTIAL|FAIL",
25904
+ reviewer_score_if_present: "reviewer specialist_results.output score regex; null when absent",
25905
+ total_tokens: runComplete ? "specialist_events.type=run_complete.token_usage.total_tokens" : "status_json.metrics.token_usage.total_tokens fallback",
25906
+ cost_usd: runComplete ? "specialist_events.type=run_complete.token_usage.cost_usd" : "status_json.metrics.token_usage.cost_usd fallback",
25907
+ elapsed_ms: runComplete ? "specialist_events.type=run_complete.elapsed_s * 1000" : "status_json.elapsed_s * 1000 fallback",
25908
+ failure_notes: "run_complete.error/status + status_json.error + chain sequencing heuristics"
25909
+ }
25910
+ });
25911
+ });
25912
+ }
25913
+ rows.sort((a, b) => a.task_id.localeCompare(b.task_id) || a.executor_job_id.localeCompare(b.executor_job_id));
25914
+ const outputDirectory = dirname6(options.outputPath);
25915
+ mkdirSync5(outputDirectory, { recursive: true });
25916
+ const jsonl = rows.map((row) => JSON.stringify(row)).join(`
25917
+ `);
25918
+ writeFileSync5(options.outputPath, rows.length > 0 ? `${jsonl}
25919
+ ` : "", "utf-8");
25920
+ console.log(`
25921
+ ${bold7("specialists db benchmark-export")}
25922
+ `);
25923
+ console.log(` ${green5("\u2713")} rows exported: ${rows.length}`);
25924
+ console.log(` ${green5("\u2713")} output: ${options.outputPath}`);
25925
+ if (options.epicId) {
25926
+ console.log(` ${green5("\u2713")} epic filter: ${options.epicId}`);
25927
+ }
25928
+ console.log("");
25929
+ } finally {
25930
+ sqliteClient.close();
25931
+ }
25932
+ }
25242
25933
  function runSetup() {
25243
25934
  const location = resolveObservabilityDbLocation(process.cwd());
25244
25935
  if (isPathInsideJobsDirectory(location.dbPath, location.gitRoot)) {
@@ -25270,17 +25961,32 @@ async function run8(argv = process.argv.slice(3)) {
25270
25961
  runBackfill(options);
25271
25962
  return;
25272
25963
  }
25964
+ if (subcommand === "vacuum") {
25965
+ runVacuum();
25966
+ return;
25967
+ }
25968
+ if (subcommand === "prune") {
25969
+ const options = parsePruneOptions(argv.slice(1));
25970
+ runPrune(options);
25971
+ return;
25972
+ }
25973
+ if (subcommand === "benchmark-export") {
25974
+ const options = parseBenchmarkExportOptions(argv.slice(1));
25975
+ runBenchmarkExport(options);
25976
+ return;
25977
+ }
25273
25978
  console.error(`Unknown db subcommand: '${subcommand}'`);
25274
25979
  printDbHelp();
25275
25980
  process.exit(1);
25276
25981
  }
25277
- var bold7 = (s) => `\x1B[1m${s}\x1B[0m`, green5 = (s) => `\x1B[32m${s}\x1B[0m`, yellow6 = (s) => `\x1B[33m${s}\x1B[0m`;
25982
+ 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
25983
  var init_db = __esm(() => {
25279
25984
  init_observability_db();
25280
25985
  init_job_root();
25281
25986
  init_observability_sqlite();
25282
25987
  init_chain_identity();
25283
25988
  init_timeline_events();
25989
+ DAY_MS = 24 * 60 * 60 * 1000;
25284
25990
  });
25285
25991
 
25286
25992
  // src/cli/validate.ts
@@ -25410,7 +26116,7 @@ __export(exports_edit, {
25410
26116
  run: () => run10
25411
26117
  });
25412
26118
  import { spawnSync as spawnSync10 } from "child_process";
25413
- import { existsSync as existsSync12, readdirSync as readdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
26119
+ import { existsSync as existsSync12, readdirSync as readdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
25414
26120
  import { join as join13 } from "path";
25415
26121
  function loadPresets() {
25416
26122
  const paths = [
@@ -25420,7 +26126,7 @@ function loadPresets() {
25420
26126
  for (const p of paths) {
25421
26127
  if (existsSync12(p)) {
25422
26128
  try {
25423
- const data = JSON.parse(readFileSync8(p, "utf-8"));
26129
+ const data = JSON.parse(readFileSync9(p, "utf-8"));
25424
26130
  return data;
25425
26131
  } catch {
25426
26132
  return {};
@@ -25773,7 +26479,7 @@ function getRawValue(args, resolvedPath) {
25773
26479
  if (!MULTILINE_FILE_PATHS.has(resolvedPath.normalizedPath)) {
25774
26480
  fail(`Error: --file is only supported for: ${Array.from(MULTILINE_FILE_PATHS).join(", ")}`);
25775
26481
  }
25776
- return readFileSync8(args.filePath, "utf-8");
26482
+ return readFileSync9(args.filePath, "utf-8");
25777
26483
  }
25778
26484
  function getAtPath(root, segments) {
25779
26485
  let current = root;
@@ -25883,7 +26589,7 @@ async function run10() {
25883
26589
  }
25884
26590
  const targets2 = await resolveTargets(args);
25885
26591
  for (const target of targets2) {
25886
- const raw = readFileSync8(target.filePath, "utf-8");
26592
+ const raw = readFileSync9(target.filePath, "utf-8");
25887
26593
  const doc2 = JSON.parse(raw);
25888
26594
  for (const [fieldPath, fieldValue] of Object.entries(preset.fields)) {
25889
26595
  const resolved = resolvePath2(fieldPath);
@@ -25897,7 +26603,7 @@ async function run10() {
25897
26603
  printDryRun(target.filePath, raw, updated);
25898
26604
  continue;
25899
26605
  }
25900
- writeFileSync5(target.filePath, updated, "utf-8");
26606
+ writeFileSync6(target.filePath, updated, "utf-8");
25901
26607
  const fieldList = Object.keys(preset.fields).map((f) => yellow8(f)).join(", ");
25902
26608
  console.log(`${green7("\u2713")} ${bold9(target.name)}: applied preset ${bold9(args.preset)} (${fieldList})`);
25903
26609
  }
@@ -25909,7 +26615,7 @@ async function run10() {
25909
26615
  fail("Error: no specialists found");
25910
26616
  }
25911
26617
  for (const target of targets) {
25912
- const raw = readFileSync8(target.filePath, "utf-8");
26618
+ const raw = readFileSync9(target.filePath, "utf-8");
25913
26619
  let doc2;
25914
26620
  try {
25915
26621
  const parsed = JSON.parse(raw);
@@ -25933,7 +26639,7 @@ async function run10() {
25933
26639
  printDryRun(target.filePath, raw, updated);
25934
26640
  continue;
25935
26641
  }
25936
- writeFileSync5(target.filePath, updated, "utf-8");
26642
+ writeFileSync6(target.filePath, updated, "utf-8");
25937
26643
  console.log(`${green7("\u2713")} ${bold9(target.name)}: ${yellow8(resolvedPath.normalizedPath)} = ${formatOutputValue(nextValue)}` + dim7(` (${target.filePath})`));
25938
26644
  }
25939
26645
  }
@@ -26052,8 +26758,8 @@ var init_config = __esm(() => {
26052
26758
  });
26053
26759
 
26054
26760
  // 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";
26761
+ import { existsSync as existsSync13, symlinkSync as symlinkSync2, mkdirSync as mkdirSync6 } from "fs";
26762
+ import { join as join14, resolve as resolve6 } from "path";
26057
26763
  import { spawnSync as spawnSync11, execFileSync as execFileSync2 } from "child_process";
26058
26764
  function deriveBranchName(beadId, specialistName) {
26059
26765
  return `feature/${beadId}-${slugify(specialistName)}`;
@@ -26086,11 +26792,11 @@ function provisionWorktree(options) {
26086
26792
  const branch = deriveBranchName(options.beadId, options.specialistName);
26087
26793
  const existingPath = findExistingWorktree(branch, cwd);
26088
26794
  if (existingPath) {
26089
- return { branch, worktreePath: resolve5(existingPath), reused: true };
26795
+ return { branch, worktreePath: resolve6(existingPath), reused: true };
26090
26796
  }
26091
26797
  const worktreeBase = options.worktreeBase ?? join14(commonRoot, ".worktrees", options.beadId);
26092
26798
  const worktreeName = deriveWorktreeName(options.beadId, options.specialistName);
26093
- const worktreePath = resolve5(join14(worktreeBase, worktreeName));
26799
+ const worktreePath = resolve6(join14(worktreeBase, worktreeName));
26094
26800
  createWorktreeViaBd(worktreePath, branch, commonRoot);
26095
26801
  symlinkPiNpmCache(commonRoot, worktreePath);
26096
26802
  return { branch, worktreePath, reused: false };
@@ -26101,7 +26807,7 @@ function symlinkPiNpmCache(commonRoot, worktreePath) {
26101
26807
  if (!existsSync13(source) || existsSync13(target))
26102
26808
  return;
26103
26809
  try {
26104
- mkdirSync5(join14(worktreePath, ".pi"), { recursive: true });
26810
+ mkdirSync6(join14(worktreePath, ".pi"), { recursive: true });
26105
26811
  symlinkSync2(source, target);
26106
26812
  } catch {}
26107
26813
  }
@@ -26161,9 +26867,10 @@ __export(exports_merge, {
26161
26867
  executePublicationPlan: () => executePublicationPlan,
26162
26868
  evaluateMergeWorthiness: () => evaluateMergeWorthiness,
26163
26869
  ensureTerminalJobs: () => ensureTerminalJobs,
26164
- checkEpicUnresolvedGuard: () => checkEpicUnresolvedGuard
26870
+ checkEpicUnresolvedGuard: () => checkEpicUnresolvedGuard,
26871
+ assertMainRepoCleanForMerge: () => assertMainRepoCleanForMerge
26165
26872
  });
26166
- import { existsSync as existsSync14, readdirSync as readdirSync6, readFileSync as readFileSync9 } from "fs";
26873
+ import { existsSync as existsSync14, readdirSync as readdirSync6, readFileSync as readFileSync10 } from "fs";
26167
26874
  import { spawnSync as spawnSync12 } from "child_process";
26168
26875
  import { join as join15 } from "path";
26169
26876
  function parseOptions(argv) {
@@ -26353,7 +27060,7 @@ function readAllJobStatuses() {
26353
27060
  const statusPath = join15(jobsDir, entry.name, "status.json");
26354
27061
  if (!existsSync14(statusPath))
26355
27062
  continue;
26356
- const parsed = readJson(readFileSync9(statusPath, "utf-8"));
27063
+ const parsed = readJson(readFileSync10(statusPath, "utf-8"));
26357
27064
  if (!parsed || typeof parsed !== "object")
26358
27065
  continue;
26359
27066
  statuses.push(parsed);
@@ -26480,6 +27187,29 @@ function readChangedFilesForLastMerge(cwd = process.cwd()) {
26480
27187
  return diff.stdout.split(`
26481
27188
  `).map((line) => line.trim()).filter(Boolean);
26482
27189
  }
27190
+ function isMergeDirtyIgnored(path) {
27191
+ return MERGE_DIRTY_IGNORE_PREFIXES.some((prefix) => path.startsWith(prefix));
27192
+ }
27193
+ function assertMainRepoCleanForMerge(cwd) {
27194
+ const status = runCommand("git", ["status", "--porcelain", "--untracked-files=no"], cwd);
27195
+ if (status.status !== 0) {
27196
+ throw new Error(`Unable to read git status in '${cwd}'.`);
27197
+ }
27198
+ const dirty = status.stdout.split(`
27199
+ `).map((line) => line.trim()).filter(Boolean).map((line) => {
27200
+ const match = /^..\s(.+?)(?:\s->\s(.+))?$/.exec(line);
27201
+ const path = match ? match[2] ?? match[1] ?? "" : line.slice(3).trim();
27202
+ return path.trim();
27203
+ }).filter((path) => path && !isMergeDirtyIgnored(path));
27204
+ if (dirty.length === 0)
27205
+ return;
27206
+ const list = dirty.map((path) => `- ${path}`).join(`
27207
+ `);
27208
+ throw new Error(`Refusing merge: main repo '${cwd}' has uncommitted changes that could cause spurious conflicts.
27209
+ ` + `Dirty files (tracked, non-dist):
27210
+ ${list}
27211
+ ` + `Resolve by committing, stashing, or reverting these changes, then retry merge.`);
27212
+ }
26483
27213
  function parseNameStatusLine(line) {
26484
27214
  const trimmed = line.trim();
26485
27215
  if (!trimmed)
@@ -26648,6 +27378,7 @@ function printUsageAndExit(message) {
26648
27378
  }
26649
27379
  function runMergePlan(targets, options) {
26650
27380
  const mainRepoRoot = resolveMainWorktreeRoot();
27381
+ assertMainRepoCleanForMerge(mainRepoRoot);
26651
27382
  const mergedSteps = [];
26652
27383
  for (const target of targets) {
26653
27384
  rebaseBranchOntoMaster(target.branch, target.worktreePath);
@@ -26740,13 +27471,17 @@ async function run12() {
26740
27471
  const mergedSteps = runMergePlan(targets, { rebuild: options.rebuild });
26741
27472
  printSummary(mergedSteps, options.rebuild);
26742
27473
  }
26743
- var TERMINAL_STATUSES, NOISE_PATH_PREFIXES;
27474
+ var TERMINAL_STATUSES, NOISE_PATH_PREFIXES, MERGE_DIRTY_IGNORE_PREFIXES;
26744
27475
  var init_merge = __esm(() => {
26745
27476
  init_job_root();
26746
27477
  init_observability_sqlite();
26747
27478
  init_epic_lifecycle();
26748
27479
  TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]);
26749
27480
  NOISE_PATH_PREFIXES = [".xtrm/reports/", ".wolf/", ".specialists/jobs/"];
27481
+ MERGE_DIRTY_IGNORE_PREFIXES = [
27482
+ ...NOISE_PATH_PREFIXES,
27483
+ "dist/"
27484
+ ];
26750
27485
  });
26751
27486
 
26752
27487
  // src/cli/format-helpers.ts
@@ -26976,7 +27711,7 @@ __export(exports_run, {
26976
27711
  run: () => run13
26977
27712
  });
26978
27713
  import { join as join16 } from "path";
26979
- import { readFileSync as readFileSync10 } from "fs";
27714
+ import { readFileSync as readFileSync11 } from "fs";
26980
27715
  import { randomBytes } from "crypto";
26981
27716
  import { spawn as cpSpawn, execSync as execSync3 } from "child_process";
26982
27717
  async function parseArgs6(argv) {
@@ -27091,13 +27826,13 @@ async function parseArgs6(argv) {
27091
27826
  process.exit(1);
27092
27827
  }
27093
27828
  if (!prompt && !beadId && !process.stdin.isTTY) {
27094
- prompt = await new Promise((resolve6) => {
27829
+ prompt = await new Promise((resolve7) => {
27095
27830
  let buf = "";
27096
27831
  process.stdin.setEncoding("utf-8");
27097
27832
  process.stdin.on("data", (chunk) => {
27098
27833
  buf += chunk;
27099
27834
  });
27100
- process.stdin.on("end", () => resolve6(buf.trim()));
27835
+ process.stdin.on("end", () => resolve7(buf.trim()));
27101
27836
  });
27102
27837
  }
27103
27838
  if (!prompt && !beadId && !reuseJobId) {
@@ -27255,7 +27990,7 @@ function startEventTailer(jobId, jobsDir, mode, specialist, beadId) {
27255
27990
  const drain = () => {
27256
27991
  let content;
27257
27992
  try {
27258
- content = readFileSync10(eventsPath, "utf-8");
27993
+ content = readFileSync11(eventsPath, "utf-8");
27259
27994
  } catch {
27260
27995
  return;
27261
27996
  }
@@ -27314,6 +28049,23 @@ function formatFooterModel(backend, model) {
27314
28049
  function shellQuote(value) {
27315
28050
  return `'${value.replace(/'/g, `'\\''`)}'`;
27316
28051
  }
28052
+ function extractReviewedJobIdOverride(prompt) {
28053
+ const match = prompt.match(/(?:^|\n)\s*reviewed_job_id\s*:\s*([^\n]+)/i);
28054
+ const candidate = match?.[1]?.trim();
28055
+ return candidate ? candidate : undefined;
28056
+ }
28057
+ function buildReusedWorktreeAwarenessBlock(options) {
28058
+ const owner = options.worktreeOwnerJobId ?? options.reusedFromJobId;
28059
+ return [
28060
+ "## Reused workspace awareness (from --job)",
28061
+ `You are entering an existing worktree reused from job: ${options.reusedFromJobId}.`,
28062
+ `Worktree chain owner job: ${owner}.`,
28063
+ "Workspace may contain uncommitted edits, staged changes, generated files, or partial fixes from prior handoff steps.",
28064
+ "Before edits, run and inspect: git status --short --branch, git diff --stat, git diff --cached --stat.",
28065
+ "Treat existing tree state as real input context \u2014 do not assume clean baseline."
28066
+ ].join(`
28067
+ `);
28068
+ }
27317
28069
  async function run13() {
27318
28070
  const args = await parseArgs6(process.argv.slice(3));
27319
28071
  const loader = new SpecialistLoader;
@@ -27339,7 +28091,7 @@ async function run13() {
27339
28091
  const latestPath = join16(jobsDir2, "latest");
27340
28092
  const oldLatest = (() => {
27341
28093
  try {
27342
- return readFileSync10(latestPath, "utf-8").trim();
28094
+ return readFileSync11(latestPath, "utf-8").trim();
27343
28095
  } catch {
27344
28096
  return "";
27345
28097
  }
@@ -27367,7 +28119,7 @@ async function run13() {
27367
28119
  while (Date.now() < deadline) {
27368
28120
  await new Promise((r) => setTimeout(r, 100));
27369
28121
  try {
27370
- const current = readFileSync10(latestPath, "utf-8").trim();
28122
+ const current = readFileSync11(latestPath, "utf-8").trim();
27371
28123
  if (current && current !== oldLatest) {
27372
28124
  jobId2 = current;
27373
28125
  break;
@@ -27449,10 +28201,19 @@ async function run13() {
27449
28201
  } else if (args.epicId) {
27450
28202
  epicId = args.epicId;
27451
28203
  }
28204
+ variables = {
28205
+ ...variables ?? {},
28206
+ reused_worktree_awareness: ""
28207
+ };
27452
28208
  if (args.reuseJobId) {
28209
+ const reviewedJobId = extractReviewedJobIdOverride(prompt) ?? args.reuseJobId;
27453
28210
  variables = {
27454
28211
  ...variables ?? {},
27455
- reviewed_job_id: args.reuseJobId
28212
+ reviewed_job_id: reviewedJobId,
28213
+ reused_worktree_awareness: buildReusedWorktreeAwarenessBlock({
28214
+ reusedFromJobId: args.reuseJobId,
28215
+ worktreeOwnerJobId
28216
+ })
27456
28217
  };
27457
28218
  }
27458
28219
  if (!prompt && !effectiveBeadId) {
@@ -27613,7 +28374,7 @@ var init_node_resolve = __esm(() => {
27613
28374
  });
27614
28375
 
27615
28376
  // src/specialist/job-control.ts
27616
- import { existsSync as existsSync15, readFileSync as readFileSync11, writeFileSync as writeFileSync6 } from "fs";
28377
+ import { existsSync as existsSync15, readFileSync as readFileSync12, writeFileSync as writeFileSync7 } from "fs";
27617
28378
  import { join as join17 } from "path";
27618
28379
 
27619
28380
  class JobControl {
@@ -27645,8 +28406,8 @@ class JobControl {
27645
28406
  }
27646
28407
  };
27647
28408
  let resolveJobId;
27648
- const jobIdPromise = new Promise((resolve6) => {
27649
- resolveJobId = resolve6;
28409
+ const jobIdPromise = new Promise((resolve7) => {
28410
+ resolveJobId = resolve7;
27650
28411
  });
27651
28412
  this.supervisor = new Supervisor({
27652
28413
  runner: this.runner,
@@ -27692,7 +28453,7 @@ class JobControl {
27692
28453
  if (!existsSync15(resultPath))
27693
28454
  return null;
27694
28455
  try {
27695
- return readFileSync11(resultPath, "utf-8");
28456
+ return readFileSync12(resultPath, "utf-8");
27696
28457
  } catch {
27697
28458
  return null;
27698
28459
  }
@@ -27711,7 +28472,7 @@ class JobControl {
27711
28472
  if (deadline !== undefined && Date.now() >= deadline) {
27712
28473
  throw new Error(`Timed out waiting for terminal status for job ${jobId}`);
27713
28474
  }
27714
- await new Promise((resolve6) => setTimeout(resolve6, backoffMs));
28475
+ await new Promise((resolve7) => setTimeout(resolve7, backoffMs));
27715
28476
  backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
27716
28477
  }
27717
28478
  }
@@ -27725,7 +28486,7 @@ class JobControl {
27725
28486
  }
27726
28487
  const jsonLine = `${JSON.stringify(payload)}
27727
28488
  `;
27728
- writeFileSync6(status.fifo_path, jsonLine, { flag: "a" });
28489
+ writeFileSync7(status.fifo_path, jsonLine, { flag: "a" });
27729
28490
  }
27730
28491
  resultPath(jobId) {
27731
28492
  return join17(this.jobsDir, jobId, "result.txt");
@@ -27890,7 +28651,7 @@ function hashOutput(output2, salt) {
27890
28651
  return createHash3("sha256").update(value).digest("hex");
27891
28652
  }
27892
28653
  function sleep2(ms) {
27893
- return new Promise((resolve6) => setTimeout(resolve6, ms));
28654
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
27894
28655
  }
27895
28656
  function toContextHealth(contextPct) {
27896
28657
  if (contextPct === null)
@@ -29803,10 +30564,10 @@ var exports_node = {};
29803
30564
  __export(exports_node, {
29804
30565
  handleNodeCommand: () => handleNodeCommand
29805
30566
  });
29806
- import { existsSync as existsSync16, readFileSync as readFileSync12, readdirSync as readdirSync7 } from "fs";
30567
+ import { existsSync as existsSync16, readFileSync as readFileSync13, readdirSync as readdirSync7 } from "fs";
29807
30568
  import { randomUUID } from "crypto";
29808
30569
  import { spawnSync as spawnSync14 } from "child_process";
29809
- import { basename as basename4, join as join18, resolve as resolve6 } from "path";
30570
+ import { basename as basename4, join as join18, resolve as resolve7 } from "path";
29810
30571
  function parseNodeArgs(argv) {
29811
30572
  const command = argv[0];
29812
30573
  const supportedCommands = new Set(["run", "list", "promote", "members", "memory", "stop", "spawn-member", "create-bead", "complete", "wait-phase"]);
@@ -29990,7 +30751,7 @@ function toNodeName(filePath) {
29990
30751
  function discoverNodeConfigs(cwd) {
29991
30752
  const discoveredByName = new Map;
29992
30753
  for (const directory of NODE_DISCOVERY_DIRS) {
29993
- const absoluteDir = resolve6(cwd, directory.path);
30754
+ const absoluteDir = resolve7(cwd, directory.path);
29994
30755
  if (!existsSync16(absoluteDir))
29995
30756
  continue;
29996
30757
  const files = readdirSync7(absoluteDir).filter((fileName) => fileName.endsWith(NODE_CONFIG_SUFFIX));
@@ -30005,7 +30766,7 @@ function discoverNodeConfigs(cwd) {
30005
30766
  return [...discoveredByName.values()].sort((left, right) => left.name.localeCompare(right.name));
30006
30767
  }
30007
30768
  function resolveNodeConfigPath(cwd, input2) {
30008
- const explicitPath = resolve6(cwd, input2);
30769
+ const explicitPath = resolve7(cwd, input2);
30009
30770
  if (existsSync16(explicitPath)) {
30010
30771
  return explicitPath;
30011
30772
  }
@@ -30101,7 +30862,7 @@ async function handleNodeRun(args) {
30101
30862
  throw new Error("Observability SQLite DB is unavailable. Run: specialists db setup");
30102
30863
  }
30103
30864
  try {
30104
- const rawConfig = args.inlineJson ? args.inlineJson : readFileSync12(resolveNodeConfigPath(process.cwd(), args.nodeConfigInput), "utf-8");
30865
+ const rawConfig = args.inlineJson ? args.inlineJson : readFileSync13(resolveNodeConfigPath(process.cwd(), args.nodeConfigInput), "utf-8");
30105
30866
  const config2 = parseNodeConfig(rawConfig);
30106
30867
  const loader = new SpecialistLoader;
30107
30868
  const runner = new SpecialistRunner({
@@ -30550,7 +31311,7 @@ async function handleNodeAction(args) {
30550
31311
  if (deadline !== null && Date.now() >= deadline) {
30551
31312
  throw new Error(`Timed out waiting for phase '${args.phaseId}' members: ${memberKeys.join(", ")}`);
30552
31313
  }
30553
- await new Promise((resolve7) => setTimeout(resolve7, 500));
31314
+ await new Promise((resolve8) => setTimeout(resolve8, 500));
30554
31315
  }
30555
31316
  } finally {
30556
31317
  sqliteClient.close();
@@ -30604,46 +31365,254 @@ async function handleNodeCommand(argv) {
30604
31365
  await handleNodeMembers(parsed);
30605
31366
  return;
30606
31367
  }
30607
- if (parsed.command === "memory") {
30608
- await handleNodeMemory(parsed);
30609
- return;
31368
+ if (parsed.command === "memory") {
31369
+ await handleNodeMemory(parsed);
31370
+ return;
31371
+ }
31372
+ if (parsed.command === "stop") {
31373
+ await handleNodeStop(parsed);
31374
+ return;
31375
+ }
31376
+ if (parsed.command === "spawn-member" || parsed.command === "create-bead" || parsed.command === "complete" || parsed.command === "wait-phase") {
31377
+ await handleNodeAction(parsed);
31378
+ return;
31379
+ }
31380
+ emitNodeCommandError(`Unsupported node command: ${parsed.command}`, parsed.jsonMode);
31381
+ }
31382
+ var NODE_CONFIG_SUFFIX = ".node.json", NODE_DISCOVERY_DIRS;
31383
+ var init_node = __esm(() => {
31384
+ init_loader();
31385
+ init_runner();
31386
+ init_circuitBreaker();
31387
+ init_hooks();
31388
+ init_observability_sqlite();
31389
+ init_beads();
31390
+ init_supervisor();
31391
+ init_job_root();
31392
+ init_node_resolve();
31393
+ init_node_supervisor();
31394
+ NODE_DISCOVERY_DIRS = [
31395
+ { path: ".specialists/default/nodes", source: "default" },
31396
+ { path: "config/nodes", source: "project" }
31397
+ ];
31398
+ });
31399
+
31400
+ // src/specialist/epic-reconciler.ts
31401
+ import { mkdirSync as mkdirSync7, openSync as openSync2, readFileSync as readFileSync14, rmSync as rmSync2, writeFileSync as writeFileSync8 } from "fs";
31402
+ import { join as join19 } from "path";
31403
+ function buildEpicLockPath(epicId) {
31404
+ const location = resolveObservabilityDbLocation();
31405
+ const lockDir = join19(location.dbDirectory, "locks");
31406
+ mkdirSync7(lockDir, { recursive: true });
31407
+ return join19(lockDir, `epic-${epicId}.lock`);
31408
+ }
31409
+ function withEpicAdvisoryLock(epicId, action) {
31410
+ const lockPath = buildEpicLockPath(epicId);
31411
+ let lockFd = null;
31412
+ try {
31413
+ lockFd = openSync2(lockPath, "wx");
31414
+ writeFileSync8(lockPath, JSON.stringify({ epic_id: epicId, pid: process.pid, created_at_ms: Date.now() }));
31415
+ } catch {
31416
+ let holder = "unknown";
31417
+ try {
31418
+ holder = readFileSync14(lockPath, "utf-8");
31419
+ } catch {
31420
+ holder = "unknown";
31421
+ }
31422
+ throw new Error(`Epic advisory lock busy for ${epicId}. Holder: ${holder}`);
31423
+ }
31424
+ try {
31425
+ return action();
31426
+ } finally {
31427
+ if (lockFd !== null) {
31428
+ try {
31429
+ rmSync2(lockPath, { force: true });
31430
+ } catch {}
31431
+ }
31432
+ }
31433
+ }
31434
+ function hasRedirectMarkers(statusJson) {
31435
+ if (!statusJson)
31436
+ return false;
31437
+ try {
31438
+ const parsed = JSON.parse(statusJson);
31439
+ return Object.keys(parsed).some((key) => key.toLowerCase().includes("redirect"));
31440
+ } catch {
31441
+ return false;
31442
+ }
31443
+ }
31444
+ function clearRedirectMarkers(statusJson) {
31445
+ if (!statusJson)
31446
+ return statusJson;
31447
+ try {
31448
+ const parsed = JSON.parse(statusJson);
31449
+ const cleanedEntries = Object.entries(parsed).filter(([key]) => !key.toLowerCase().includes("redirect"));
31450
+ return JSON.stringify(Object.fromEntries(cleanedEntries));
31451
+ } catch {
31452
+ return statusJson;
31453
+ }
31454
+ }
31455
+ function collectEpicJobs(sqlite, epicId) {
31456
+ const chainIds = new Set(sqlite.listEpicChains(epicId).map((chain) => chain.chain_id));
31457
+ return sqlite.listStatuses().filter((status) => status.epic_id === epicId || (status.chain_id ? chainIds.has(status.chain_id) : false));
31458
+ }
31459
+ function detectStaleChainRefs(sqlite, epicId) {
31460
+ return sqlite.listEpicChains(epicId).map((chain) => chain.chain_id).filter((chainId) => sqlite.listChainJobIds(chainId).length === 0);
31461
+ }
31462
+ function detectDeadBlockingJobs(jobs) {
31463
+ return jobs.filter((job) => ACTIVE_JOB_STATUSES2.has(job.status) && isJobDead(job)).map((job) => job.id);
31464
+ }
31465
+ function detectIntegrityFlags(sqlite, epicId, jobs) {
31466
+ const chainIds = new Set(sqlite.listEpicChains(epicId).map((chain) => chain.chain_id));
31467
+ const flags = [];
31468
+ for (const job of jobs) {
31469
+ if (job.chain_kind === "chain" && !job.chain_id) {
31470
+ flags.push(`job:${job.id}:chain_kind=chain missing chain_id`);
31471
+ }
31472
+ if (job.chain_id && !chainIds.has(job.chain_id) && job.epic_id === epicId) {
31473
+ flags.push(`job:${job.id}:references chain ${job.chain_id} missing from epic membership`);
31474
+ }
31475
+ if (job.chain_id && chainIds.has(job.chain_id) && job.epic_id && job.epic_id !== epicId) {
31476
+ flags.push(`job:${job.id}:chain ${job.chain_id} linked to epic ${job.epic_id}, expected ${epicId}`);
31477
+ }
31478
+ }
31479
+ return flags;
31480
+ }
31481
+ function markDeadJobsAsError(sqlite, jobs) {
31482
+ const deadBlockingIds = detectDeadBlockingJobs(jobs);
31483
+ const now = Date.now();
31484
+ for (const jobId of deadBlockingIds) {
31485
+ const current = jobs.find((job) => job.id === jobId);
31486
+ if (!current)
31487
+ continue;
31488
+ sqlite.upsertStatus({
31489
+ ...current,
31490
+ status: "error",
31491
+ error: current.error ?? "epic reconciler: detected dead pid/tmux for active job",
31492
+ last_event_at_ms: now
31493
+ });
31494
+ }
31495
+ return deadBlockingIds;
31496
+ }
31497
+ function syncEpicState(sqlite, epicId, apply) {
31498
+ const epicRun = sqlite.readEpicRun(epicId);
31499
+ const jobs = collectEpicJobs(sqlite, epicId);
31500
+ const readinessBefore = loadEpicReadinessSummary(sqlite, epicId);
31501
+ const drift = {
31502
+ stale_chain_refs: detectStaleChainRefs(sqlite, epicId),
31503
+ dead_jobs_blocking_readiness: detectDeadBlockingJobs(jobs),
31504
+ integrity_flags: detectIntegrityFlags(sqlite, epicId, jobs),
31505
+ stale_redirect_markers: epicRun && hasRedirectMarkers(epicRun.status_json) ? [epicId] : []
31506
+ };
31507
+ let deadJobsMarkedError = [];
31508
+ let readinessResynced = false;
31509
+ let redirectMarkersCleared = false;
31510
+ if (apply) {
31511
+ if (drift.dead_jobs_blocking_readiness.length > 0) {
31512
+ deadJobsMarkedError = markDeadJobsAsError(sqlite, jobs);
31513
+ }
31514
+ const readinessNext = loadEpicReadinessSummary(sqlite, epicId);
31515
+ const synced = syncEpicStateFromReadiness(sqlite, readinessNext);
31516
+ readinessResynced = synced.status !== readinessNext.persisted_state;
31517
+ if (epicRun && drift.stale_redirect_markers.length > 0) {
31518
+ const cleaned = clearRedirectMarkers(epicRun.status_json);
31519
+ if (cleaned && cleaned !== epicRun.status_json) {
31520
+ sqlite.upsertEpicRun({
31521
+ ...epicRun,
31522
+ status_json: cleaned,
31523
+ updated_at_ms: Date.now()
31524
+ });
31525
+ redirectMarkersCleared = true;
31526
+ }
31527
+ }
31528
+ }
31529
+ const readinessAfter = loadEpicReadinessSummary(sqlite, epicId);
31530
+ return {
31531
+ epic_id: epicId,
31532
+ apply,
31533
+ drift,
31534
+ repairs: {
31535
+ dead_jobs_marked_error: deadJobsMarkedError,
31536
+ readiness_resynced: readinessResynced,
31537
+ redirect_markers_cleared: redirectMarkersCleared
31538
+ },
31539
+ readiness_before: readinessBefore,
31540
+ readiness_after: readinessAfter
31541
+ };
31542
+ }
31543
+ function listLiveMemberJobIds(sqlite, epicId) {
31544
+ return collectEpicJobs(sqlite, epicId).filter((job) => ACTIVE_JOB_STATUSES2.has(job.status) && !isJobDead(job)).map((job) => job.id);
31545
+ }
31546
+ function buildAbandonedStatusJson(epic, epicId, fromState, reason, forced) {
31547
+ const base = appendEpicTransitionAudit(epic?.status_json, {
31548
+ from: fromState,
31549
+ to: "abandoned",
31550
+ at_ms: Date.now(),
31551
+ reason,
31552
+ trigger: "sp epic abandon",
31553
+ forced
31554
+ });
31555
+ try {
31556
+ const parsed = JSON.parse(base);
31557
+ return JSON.stringify({
31558
+ ...parsed,
31559
+ epic_id: epicId,
31560
+ status: "abandoned",
31561
+ reason,
31562
+ forced
31563
+ });
31564
+ } catch {
31565
+ return base;
31566
+ }
31567
+ }
31568
+ function abandonEpic(sqlite, epicId, reason, force) {
31569
+ const epic = sqlite.readEpicRun(epicId);
31570
+ const fromState = epic?.status ?? "open";
31571
+ if (fromState === "merged") {
31572
+ throw new Error(`Epic ${epicId} already merged. Abandon blocked.`);
30610
31573
  }
30611
- if (parsed.command === "stop") {
30612
- await handleNodeStop(parsed);
30613
- return;
31574
+ if (fromState === "failed" || fromState === "abandoned") {
31575
+ throw new Error(`Epic ${epicId} already terminal in state '${fromState}'.`);
30614
31576
  }
30615
- if (parsed.command === "spawn-member" || parsed.command === "create-bead" || parsed.command === "complete" || parsed.command === "wait-phase") {
30616
- await handleNodeAction(parsed);
30617
- return;
31577
+ const liveMemberJobIds = listLiveMemberJobIds(sqlite, epicId);
31578
+ if (!force && liveMemberJobIds.length > 0) {
31579
+ throw new Error(`Epic ${epicId} has live members: ${liveMemberJobIds.join(", ")}. Retry with --force to abandon anyway.`);
30618
31580
  }
30619
- emitNodeCommandError(`Unsupported node command: ${parsed.command}`, parsed.jsonMode);
31581
+ const statusJson = buildAbandonedStatusJson(epic, epicId, fromState, reason, force);
31582
+ sqlite.upsertEpicRun({
31583
+ epic_id: epicId,
31584
+ status: "abandoned",
31585
+ status_json: statusJson,
31586
+ updated_at_ms: Date.now()
31587
+ });
31588
+ return {
31589
+ epic_id: epicId,
31590
+ from_state: fromState,
31591
+ to_state: "abandoned",
31592
+ forced: force,
31593
+ reason,
31594
+ live_member_job_ids: liveMemberJobIds
31595
+ };
30620
31596
  }
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();
31597
+ var ACTIVE_JOB_STATUSES2;
31598
+ var init_epic_reconciler = __esm(() => {
31599
+ init_epic_lifecycle();
31600
+ init_epic_readiness();
31601
+ init_observability_db();
30629
31602
  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
- ];
31603
+ ACTIVE_JOB_STATUSES2 = new Set(["starting", "running", "waiting"]);
30637
31604
  });
30638
31605
 
30639
31606
  // src/cli/epic.ts
30640
31607
  var exports_epic = {};
30641
31608
  __export(exports_epic, {
31609
+ handleEpicSyncCommand: () => handleEpicSyncCommand,
30642
31610
  handleEpicStatusCommand: () => handleEpicStatusCommand,
30643
31611
  handleEpicResolveCommand: () => handleEpicResolveCommand,
30644
31612
  handleEpicMergeCommand: () => handleEpicMergeCommand,
30645
31613
  handleEpicListCommand: () => handleEpicListCommand,
30646
- handleEpicCommand: () => handleEpicCommand
31614
+ handleEpicCommand: () => handleEpicCommand,
31615
+ handleEpicAbandonCommand: () => handleEpicAbandonCommand
30647
31616
  });
30648
31617
  import { spawnSync as spawnSync15 } from "child_process";
30649
31618
  function runCommand2(command, args, cwd = process.cwd()) {
@@ -30743,6 +31712,58 @@ function parseResolveOptions(argv) {
30743
31712
  }
30744
31713
  return { epicId, dryRun, json };
30745
31714
  }
31715
+ function parseSyncOptions(argv) {
31716
+ const epicId = parseEpicId(argv);
31717
+ let apply = false;
31718
+ let json = false;
31719
+ for (const argument of argv) {
31720
+ if (argument === "--apply") {
31721
+ apply = true;
31722
+ continue;
31723
+ }
31724
+ if (argument === "--json") {
31725
+ json = true;
31726
+ continue;
31727
+ }
31728
+ if (argument.startsWith("-") && argument !== "--apply" && argument !== "--json") {
31729
+ throw new Error(`Unknown option: ${argument}`);
31730
+ }
31731
+ }
31732
+ return { epicId, apply, json };
31733
+ }
31734
+ function parseAbandonOptions(argv) {
31735
+ const epicId = parseEpicId(argv);
31736
+ let reason = "";
31737
+ let force = false;
31738
+ let json = false;
31739
+ for (let index = 0;index < argv.length; index += 1) {
31740
+ const argument = argv[index];
31741
+ if (argument === "--force") {
31742
+ force = true;
31743
+ continue;
31744
+ }
31745
+ if (argument === "--json") {
31746
+ json = true;
31747
+ continue;
31748
+ }
31749
+ if (argument === "--reason") {
31750
+ const value = argv[index + 1];
31751
+ if (!value || value.startsWith("-")) {
31752
+ throw new Error("Missing value for --reason");
31753
+ }
31754
+ reason = value.trim();
31755
+ index += 1;
31756
+ continue;
31757
+ }
31758
+ if (argument.startsWith("-") && argument !== "--force" && argument !== "--json") {
31759
+ throw new Error(`Unknown option: ${argument}`);
31760
+ }
31761
+ }
31762
+ if (reason.length === 0) {
31763
+ throw new Error("Missing required --reason <text>");
31764
+ }
31765
+ return { epicId, reason, force, json };
31766
+ }
30746
31767
  function readEpicChildrenFromBeads(epicId) {
30747
31768
  const result = runCommand2("bd", ["children", epicId]);
30748
31769
  if (result.status !== 0) {
@@ -31106,6 +32127,93 @@ async function handleEpicMergeCommand(argv) {
31106
32127
  process.exit(1);
31107
32128
  }
31108
32129
  }
32130
+ async function handleEpicSyncCommand(argv) {
32131
+ let options;
32132
+ try {
32133
+ options = parseSyncOptions(argv);
32134
+ } catch (error2) {
32135
+ const message = error2 instanceof Error ? error2.message : String(error2);
32136
+ console.error(message);
32137
+ console.error("Usage: specialists epic sync <epic-id> [--apply] [--json]");
32138
+ process.exit(1);
32139
+ }
32140
+ const sqlite = createObservabilitySqliteClient();
32141
+ if (!sqlite) {
32142
+ const message = "Observability SQLite database not available. Run `sp db setup` first.";
32143
+ if (options.json) {
32144
+ console.log(JSON.stringify({ error: message }, null, 2));
32145
+ } else {
32146
+ console.error(message);
32147
+ }
32148
+ process.exit(1);
32149
+ }
32150
+ try {
32151
+ const result = withEpicAdvisoryLock(options.epicId, () => syncEpicState(sqlite, options.epicId, options.apply));
32152
+ if (options.json) {
32153
+ console.log(JSON.stringify(result, null, 2));
32154
+ return;
32155
+ }
32156
+ console.log("");
32157
+ console.log(`Epic ${result.epic_id} sync (${result.apply ? "apply" : "dry-run"})`);
32158
+ console.log(` stale_chain_refs: ${result.drift.stale_chain_refs.length}`);
32159
+ console.log(` dead_jobs_blocking_readiness: ${result.drift.dead_jobs_blocking_readiness.length}`);
32160
+ console.log(` integrity_flags: ${result.drift.integrity_flags.length}`);
32161
+ console.log(` stale_redirect_markers: ${result.drift.stale_redirect_markers.length}`);
32162
+ if (result.apply) {
32163
+ console.log(` repaired_dead_jobs: ${result.repairs.dead_jobs_marked_error.length}`);
32164
+ console.log(` readiness_resynced: ${result.repairs.readiness_resynced}`);
32165
+ console.log(` redirect_markers_cleared: ${result.repairs.redirect_markers_cleared}`);
32166
+ }
32167
+ console.log(` readiness_before: ${result.readiness_before.readiness_state}`);
32168
+ console.log(` readiness_after: ${result.readiness_after.readiness_state}`);
32169
+ console.log("");
32170
+ } finally {
32171
+ sqlite.close();
32172
+ }
32173
+ }
32174
+ async function handleEpicAbandonCommand(argv) {
32175
+ let options;
32176
+ try {
32177
+ options = parseAbandonOptions(argv);
32178
+ } catch (error2) {
32179
+ const message = error2 instanceof Error ? error2.message : String(error2);
32180
+ console.error(message);
32181
+ console.error("Usage: specialists epic abandon <epic-id> --reason <text> [--force] [--json]");
32182
+ process.exit(1);
32183
+ }
32184
+ const sqlite = createObservabilitySqliteClient();
32185
+ if (!sqlite) {
32186
+ const message = "Observability SQLite database not available. Run `sp db setup` first.";
32187
+ if (options.json) {
32188
+ console.log(JSON.stringify({ error: message }, null, 2));
32189
+ } else {
32190
+ console.error(message);
32191
+ }
32192
+ process.exit(1);
32193
+ }
32194
+ try {
32195
+ const result = withEpicAdvisoryLock(options.epicId, () => abandonEpic(sqlite, options.epicId, options.reason, options.force));
32196
+ if (options.json) {
32197
+ console.log(JSON.stringify(result, null, 2));
32198
+ return;
32199
+ }
32200
+ console.log(`Epic ${result.epic_id}: ${result.from_state} -> ${result.to_state}`);
32201
+ console.log(`Reason: ${result.reason}`);
32202
+ if (result.forced) {
32203
+ console.log("Mode: forced");
32204
+ }
32205
+ } catch (error2) {
32206
+ const message = error2 instanceof Error ? error2.message : String(error2);
32207
+ if (options.json) {
32208
+ console.log(JSON.stringify({ epic_id: options.epicId, error: message }, null, 2));
32209
+ } else {
32210
+ console.error(message);
32211
+ }
32212
+ process.exit(1);
32213
+ } finally {
32214
+ sqlite.close();
32215
+ }
32216
+ }
31109
32217
  async function handleEpicStatusCommand(argv) {
31110
32218
  let options;
31111
32219
  try {
@@ -31190,12 +32298,14 @@ async function handleEpicCommand(argv) {
31190
32298
  if (!subcommand || subcommand === "--help" || subcommand === "-h") {
31191
32299
  console.log([
31192
32300
  "",
31193
- "Usage: specialists epic <list|status|resolve|merge> [options]",
32301
+ "Usage: specialists epic <list|status|resolve|sync|abandon|merge> [options]",
31194
32302
  "",
31195
32303
  "Commands:",
31196
32304
  " list [--unresolved] [--json] List epics with lifecycle and readiness summary",
31197
32305
  " status <epic-id> [--json] Show epic state, chain statuses, and merge readiness",
31198
32306
  " resolve <epic-id> [--dry-run] [--json] Transition epic from open to resolving",
32307
+ " sync <epic-id> [--apply] [--json] Reconcile epic drift (dry-run by default)",
32308
+ " abandon <epic-id> --reason <text> [--force] [--json] Transition epic to abandoned",
31199
32309
  " merge <epic-id> [--rebuild] [--pr] [--json] Publish epic-owned chains in dependency order",
31200
32310
  "",
31201
32311
  "Epic lifecycle states:",
@@ -31215,6 +32325,9 @@ async function handleEpicCommand(argv) {
31215
32325
  " specialists epic list --unresolved --json",
31216
32326
  " specialists epic resolve unitAI-3f7b",
31217
32327
  " specialists epic status unitAI-3f7b --json",
32328
+ " specialists epic sync unitAI-3f7b",
32329
+ " specialists epic sync unitAI-3f7b --apply",
32330
+ ' specialists epic abandon unitAI-3f7b --reason "scope changed"',
31218
32331
  " specialists epic merge unitAI-3f7b --rebuild",
31219
32332
  " specialists epic merge unitAI-3f7b --pr",
31220
32333
  ""
@@ -31230,6 +32343,14 @@ async function handleEpicCommand(argv) {
31230
32343
  await handleEpicResolveCommand(argv.slice(1));
31231
32344
  return;
31232
32345
  }
32346
+ if (subcommand === "sync") {
32347
+ await handleEpicSyncCommand(argv.slice(1));
32348
+ return;
32349
+ }
32350
+ if (subcommand === "abandon") {
32351
+ await handleEpicAbandonCommand(argv.slice(1));
32352
+ return;
32353
+ }
31233
32354
  if (subcommand === "merge") {
31234
32355
  await handleEpicMergeCommand(argv.slice(1));
31235
32356
  return;
@@ -31239,12 +32360,13 @@ async function handleEpicCommand(argv) {
31239
32360
  return;
31240
32361
  }
31241
32362
  console.error(`Unknown epic subcommand: ${subcommand}`);
31242
- console.error("Usage: specialists epic <list|status|resolve|merge>");
32363
+ console.error("Usage: specialists epic <list|status|resolve|sync|abandon|merge>");
31243
32364
  process.exit(1);
31244
32365
  }
31245
32366
  var RUNNING_STATUSES;
31246
32367
  var init_epic = __esm(() => {
31247
32368
  init_epic_lifecycle();
32369
+ init_epic_reconciler();
31248
32370
  init_observability_sqlite();
31249
32371
  init_merge();
31250
32372
  RUNNING_STATUSES = new Set(["starting", "running", "waiting", "degraded"]);
@@ -31256,8 +32378,8 @@ __export(exports_status, {
31256
32378
  run: () => run14
31257
32379
  });
31258
32380
  import { spawnSync as spawnSync16 } from "child_process";
31259
- import { existsSync as existsSync17, readFileSync as readFileSync13 } from "fs";
31260
- import { join as join19 } from "path";
32381
+ import { existsSync as existsSync17, readFileSync as readFileSync15 } from "fs";
32382
+ import { join as join20 } from "path";
31261
32383
  function ok2(msg) {
31262
32384
  console.log(` ${green8("\u2713")} ${msg}`);
31263
32385
  }
@@ -31348,10 +32470,10 @@ function countJobEvents(sqliteClient, jobsDir, jobId) {
31348
32470
  } catch (error2) {
31349
32471
  console.warn(`SQLite events read failed for job ${jobId}; falling back to events.jsonl`, error2);
31350
32472
  }
31351
- const eventsFile = join19(jobsDir, jobId, "events.jsonl");
32473
+ const eventsFile = join20(jobsDir, jobId, "events.jsonl");
31352
32474
  if (!existsSync17(eventsFile))
31353
32475
  return 0;
31354
- const raw = readFileSync13(eventsFile, "utf-8").trim();
32476
+ const raw = readFileSync15(eventsFile, "utf-8").trim();
31355
32477
  if (!raw)
31356
32478
  return 0;
31357
32479
  return raw.split(`
@@ -31382,10 +32504,10 @@ function getLatestContextSnapshot(sqliteClient, jobsDir, jobId) {
31382
32504
  } catch (error2) {
31383
32505
  console.warn(`SQLite events read failed for job ${jobId}; falling back to events.jsonl`, error2);
31384
32506
  }
31385
- const eventsFile = join19(jobsDir, jobId, "events.jsonl");
32507
+ const eventsFile = join20(jobsDir, jobId, "events.jsonl");
31386
32508
  if (!existsSync17(eventsFile))
31387
32509
  return null;
31388
- const lines = readFileSync13(eventsFile, "utf-8").split(`
32510
+ const lines = readFileSync15(eventsFile, "utf-8").split(`
31389
32511
  `);
31390
32512
  for (let index = lines.length - 1;index >= 0; index -= 1) {
31391
32513
  const line = lines[index].trim();
@@ -31490,7 +32612,7 @@ async function run14() {
31490
32612
  `).slice(1).map((line) => line.split(/\s+/)[0]).filter(Boolean)) : new Set;
31491
32613
  const bdInstalled = isInstalled2("bd");
31492
32614
  const bdVersion = bdInstalled ? cmd("bd", ["--version"]) : null;
31493
- const beadsPresent = existsSync17(join19(process.cwd(), ".beads"));
32615
+ const beadsPresent = existsSync17(join20(process.cwd(), ".beads"));
31494
32616
  const specialistsBin = cmd("which", ["specialists"]);
31495
32617
  const jobsDir = resolveJobsDir();
31496
32618
  let jobs = [];
@@ -31651,8 +32773,8 @@ __export(exports_ps, {
31651
32773
  run: () => run15
31652
32774
  });
31653
32775
  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";
32776
+ import { existsSync as existsSync18, readdirSync as readdirSync8, readFileSync as readFileSync16 } from "fs";
32777
+ import { join as join21 } from "path";
31656
32778
  function parseArgs7(argv) {
31657
32779
  let nodeId;
31658
32780
  const positional = [];
@@ -31686,21 +32808,21 @@ function readStatusesFromFiles(jobsDir) {
31686
32808
  return [];
31687
32809
  const statuses = [];
31688
32810
  for (const entry of readdirSync8(jobsDir)) {
31689
- const statusPath = join20(jobsDir, entry, "status.json");
32811
+ const statusPath = join21(jobsDir, entry, "status.json");
31690
32812
  if (!existsSync18(statusPath))
31691
32813
  continue;
31692
32814
  try {
31693
- statuses.push(JSON.parse(readFileSync14(statusPath, "utf-8")));
32815
+ statuses.push(JSON.parse(readFileSync16(statusPath, "utf-8")));
31694
32816
  } catch {}
31695
32817
  }
31696
32818
  return statuses.sort((a, b) => b.started_at_ms - a.started_at_ms);
31697
32819
  }
31698
32820
  function readLastToolEventFromFile(jobsDir, jobId) {
31699
- const eventsPath = join20(jobsDir, jobId, "events.jsonl");
32821
+ const eventsPath = join21(jobsDir, jobId, "events.jsonl");
31700
32822
  if (!existsSync18(eventsPath))
31701
32823
  return;
31702
32824
  try {
31703
- const lines = readFileSync14(eventsPath, "utf-8").split(`
32825
+ const lines = readFileSync16(eventsPath, "utf-8").split(`
31704
32826
  `);
31705
32827
  for (let index = lines.length - 1;index >= 0; index -= 1) {
31706
32828
  const line = lines[index]?.trim();
@@ -32458,8 +33580,8 @@ var exports_result = {};
32458
33580
  __export(exports_result, {
32459
33581
  run: () => run16
32460
33582
  });
32461
- import { existsSync as existsSync19, readFileSync as readFileSync15 } from "fs";
32462
- import { join as join21 } from "path";
33583
+ import { existsSync as existsSync19, readFileSync as readFileSync17 } from "fs";
33584
+ import { join as join22 } from "path";
32463
33585
  function parseArgs8(argv) {
32464
33586
  let jobId;
32465
33587
  let nodeId;
@@ -32543,9 +33665,92 @@ function resolveJobIdFromNodeMember(sqliteClient, nodeId, memberKey) {
32543
33665
  }
32544
33666
  return member.job_id;
32545
33667
  }
33668
+ function readTimelineEventsForResult(sqliteClient, jobsDir, jobId) {
33669
+ if (sqliteClient) {
33670
+ try {
33671
+ return sqliteClient.readEvents(jobId);
33672
+ } catch {}
33673
+ }
33674
+ const eventsPath = join22(jobsDir, jobId, "events.jsonl");
33675
+ if (!existsSync19(eventsPath))
33676
+ return [];
33677
+ return readFileSync17(eventsPath, "utf-8").split(`
33678
+ `).map((line) => line.trim()).filter(Boolean).map((line) => parseTimelineEvent(line)).filter((event) => event !== null);
33679
+ }
33680
+ function deriveStartupSnapshot(status, events) {
33681
+ const runStartEvent = events.find((event) => event.type === "run_start");
33682
+ const startupFromEvent = runStartEvent?.type === "run_start" ? runStartEvent.startup_snapshot ?? null : null;
33683
+ const memoryMeta = events.find((event) => event.type === "meta" && !!event.memory_injection);
33684
+ const memoryInjection = memoryMeta?.type === "meta" ? memoryMeta.memory_injection : undefined;
33685
+ const merged = {
33686
+ ...startupFromEvent ?? {},
33687
+ ...status.startup_context ?? {},
33688
+ ...memoryInjection ? { memory_injection: memoryInjection } : {}
33689
+ };
33690
+ if (!merged.job_id)
33691
+ merged.job_id = status.id;
33692
+ if (!merged.specialist_name)
33693
+ merged.specialist_name = status.specialist;
33694
+ if (!merged.bead_id && status.bead_id)
33695
+ merged.bead_id = status.bead_id;
33696
+ if (!merged.reused_from_job_id && status.reused_from_job_id)
33697
+ merged.reused_from_job_id = status.reused_from_job_id;
33698
+ if (!merged.worktree_owner_job_id && status.worktree_owner_job_id)
33699
+ merged.worktree_owner_job_id = status.worktree_owner_job_id;
33700
+ if (!merged.chain_id && status.chain_id)
33701
+ merged.chain_id = status.chain_id;
33702
+ if (!merged.chain_root_job_id && status.chain_root_job_id)
33703
+ merged.chain_root_job_id = status.chain_root_job_id;
33704
+ if (!merged.chain_root_bead_id && status.chain_root_bead_id)
33705
+ merged.chain_root_bead_id = status.chain_root_bead_id;
33706
+ if (!merged.worktree_path && status.worktree_path)
33707
+ merged.worktree_path = status.worktree_path;
33708
+ if (!merged.branch && status.branch)
33709
+ merged.branch = status.branch;
33710
+ return Object.keys(merged).length > 0 ? merged : null;
33711
+ }
33712
+ function formatStartupSnapshot(snapshot) {
33713
+ if (!snapshot)
33714
+ return null;
33715
+ const lines = [`
33716
+ --- startup context ---`];
33717
+ const push = (key, value) => {
33718
+ if (value === undefined || value === null)
33719
+ return;
33720
+ lines.push(`${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`);
33721
+ };
33722
+ push("job_id", snapshot.job_id);
33723
+ push("specialist_name", snapshot.specialist_name);
33724
+ push("bead_id", snapshot.bead_id);
33725
+ push("reused_from_job_id", snapshot.reused_from_job_id);
33726
+ push("worktree_owner_job_id", snapshot.worktree_owner_job_id);
33727
+ push("chain_id", snapshot.chain_id);
33728
+ push("chain_root_job_id", snapshot.chain_root_job_id);
33729
+ push("chain_root_bead_id", snapshot.chain_root_bead_id);
33730
+ push("worktree_path", snapshot.worktree_path);
33731
+ push("branch", snapshot.branch);
33732
+ push("variables_keys", snapshot.variables_keys);
33733
+ push("reviewed_job_id_present", snapshot.reviewed_job_id_present);
33734
+ push("reused_worktree_awareness_present", snapshot.reused_worktree_awareness_present);
33735
+ push("bead_context_present", snapshot.bead_context_present);
33736
+ if (snapshot.memory_injection) {
33737
+ push("memory.static_tokens", snapshot.memory_injection.static_tokens);
33738
+ push("memory.memory_tokens", snapshot.memory_injection.memory_tokens);
33739
+ push("memory.gitnexus_tokens", snapshot.memory_injection.gitnexus_tokens);
33740
+ push("memory.total_tokens", snapshot.memory_injection.total_tokens);
33741
+ }
33742
+ if (snapshot.skills) {
33743
+ push("skills.count", snapshot.skills.count);
33744
+ push("skills.activated", snapshot.skills.activated);
33745
+ }
33746
+ lines.push("---");
33747
+ return `${lines.join(`
33748
+ `)}
33749
+ `;
33750
+ }
32546
33751
  async function run16() {
32547
33752
  const args = parseArgs8(process.argv.slice(3));
32548
- const emitJson = (status, output2, error2) => {
33753
+ const emitJson = (status, output2, error2, startupContext = null) => {
32549
33754
  console.log(JSON.stringify({
32550
33755
  job: status ? {
32551
33756
  id: status.id,
@@ -32555,17 +33760,20 @@ async function run16() {
32555
33760
  backend: status.backend ?? null,
32556
33761
  bead_id: status.bead_id ?? null,
32557
33762
  metrics: status.metrics ?? null,
33763
+ startup_context: startupContext,
32558
33764
  error: status.error ?? null
32559
33765
  } : null,
32560
33766
  output: output2,
33767
+ startup_context: startupContext,
32561
33768
  error: error2
32562
33769
  }, null, 2));
32563
33770
  };
32564
- const jobsDir = join21(process.cwd(), ".specialists", "jobs");
33771
+ const jobsDir = join22(process.cwd(), ".specialists", "jobs");
32565
33772
  const supervisor = new Supervisor({ runner: null, runOptions: null, jobsDir });
32566
33773
  const sqliteClient = createObservabilitySqliteClient();
32567
- const emitHumanResult = (output2, status, trailingFooter) => {
32568
- process.stdout.write(output2);
33774
+ const emitHumanResult = (output2, status, startupContext, trailingFooter) => {
33775
+ const startupBlock = formatStartupSnapshot(startupContext);
33776
+ process.stdout.write(startupBlock ? `${startupBlock}${output2}` : output2);
32569
33777
  const tokenSummaryParts = formatTokenUsageSummary(status.metrics?.token_usage).filter((part) => !part.startsWith("cost="));
32570
33778
  const formattedCost = formatCostUsd(status.metrics?.token_usage?.cost_usd);
32571
33779
  if (tokenSummaryParts.length === 0 && !formattedCost) {
@@ -32594,7 +33802,7 @@ async function run16() {
32594
33802
  const resolvedNodeId = args.nodeId ? resolveNodeRefWithClient(args.nodeId, sqliteClient) : resolveSingleActiveNodeRef(sqliteClient);
32595
33803
  return resolveJobIdFromNodeMember(sqliteClient, resolvedNodeId, args.memberKey);
32596
33804
  })();
32597
- const resultPath = join21(jobsDir, jobId, "result.txt");
33805
+ const resultPath = join22(jobsDir, jobId, "result.txt");
32598
33806
  const readResultOutput = () => {
32599
33807
  try {
32600
33808
  const sqliteResult = sqliteClient?.readResult(jobId) ?? null;
@@ -32606,7 +33814,7 @@ async function run16() {
32606
33814
  if (!existsSync19(resultPath)) {
32607
33815
  return null;
32608
33816
  }
32609
- return readFileSync15(resultPath, "utf-8");
33817
+ return readFileSync17(resultPath, "utf-8");
32610
33818
  };
32611
33819
  if (args.wait) {
32612
33820
  const startMs = Date.now();
@@ -32621,6 +33829,7 @@ async function run16() {
32621
33829
  process.exit(1);
32622
33830
  }
32623
33831
  if (status2.status === "done") {
33832
+ const startupContext2 = deriveStartupSnapshot(status2, readTimelineEventsForResult(sqliteClient, jobsDir, jobId));
32624
33833
  const output3 = readResultOutput();
32625
33834
  if (!output3) {
32626
33835
  if (args.json) {
@@ -32631,16 +33840,17 @@ async function run16() {
32631
33840
  process.exit(1);
32632
33841
  }
32633
33842
  if (args.json) {
32634
- emitJson(status2, output3, null);
33843
+ emitJson(status2, output3, null, startupContext2);
32635
33844
  } else {
32636
- emitHumanResult(output3, status2);
33845
+ emitHumanResult(output3, status2, startupContext2);
32637
33846
  }
32638
33847
  return;
32639
33848
  }
32640
33849
  if (status2.status === "error") {
33850
+ const startupContext2 = deriveStartupSnapshot(status2, readTimelineEventsForResult(sqliteClient, jobsDir, jobId));
32641
33851
  const message = `Job ${jobId} failed: ${status2.error ?? "unknown error"}`;
32642
33852
  if (args.json) {
32643
- emitJson(status2, null, message);
33853
+ emitJson(status2, null, message, startupContext2);
32644
33854
  } else {
32645
33855
  process.stderr.write(`${red3(`Job ${jobId} failed:`)} ${status2.error ?? "unknown error"}
32646
33856
  `);
@@ -32652,7 +33862,8 @@ async function run16() {
32652
33862
  if (elapsedSecs >= args.timeout) {
32653
33863
  const timeoutMessage = `Timeout: job ${jobId} did not complete within ${args.timeout}s`;
32654
33864
  if (args.json) {
32655
- emitJson(status2, null, timeoutMessage);
33865
+ const startupContext2 = deriveStartupSnapshot(status2, readTimelineEventsForResult(sqliteClient, jobsDir, jobId));
33866
+ emitJson(status2, null, timeoutMessage, startupContext2);
32656
33867
  } else {
32657
33868
  process.stderr.write(`${timeoutMessage}
32658
33869
  `);
@@ -32673,11 +33884,12 @@ async function run16() {
32673
33884
  process.exit(1);
32674
33885
  }
32675
33886
  if (status.status === "running" || status.status === "starting") {
33887
+ const startupContext2 = deriveStartupSnapshot(status, readTimelineEventsForResult(sqliteClient, jobsDir, jobId));
32676
33888
  const output3 = readResultOutput();
32677
33889
  if (!output3) {
32678
33890
  const message = `Job ${jobId} is still ${status.status}. Use 'specialists feed --job ${jobId}' to follow.`;
32679
33891
  if (args.json) {
32680
- emitJson(status, null, message);
33892
+ emitJson(status, null, message, startupContext2);
32681
33893
  } else {
32682
33894
  process.stderr.write(`${dim10(message)}
32683
33895
  `);
@@ -32685,20 +33897,21 @@ async function run16() {
32685
33897
  process.exit(1);
32686
33898
  }
32687
33899
  if (args.json) {
32688
- emitJson(status, output3, null);
33900
+ emitJson(status, output3, null, startupContext2);
32689
33901
  } else {
32690
33902
  process.stderr.write(`${dim10(`Job ${jobId} is currently ${status.status}. Showing last completed output while it continues.`)}
32691
33903
  `);
32692
- emitHumanResult(output3, status);
33904
+ emitHumanResult(output3, status, startupContext2);
32693
33905
  }
32694
33906
  return;
32695
33907
  }
32696
33908
  if (status.status === "waiting") {
33909
+ const startupContext2 = deriveStartupSnapshot(status, readTimelineEventsForResult(sqliteClient, jobsDir, jobId));
32697
33910
  const output3 = readResultOutput();
32698
33911
  if (!output3) {
32699
33912
  const message = `Job ${jobId} is waiting for input. Use: specialists resume ${jobId} "..."`;
32700
33913
  if (args.json) {
32701
- emitJson(status, null, message);
33914
+ emitJson(status, null, message, startupContext2);
32702
33915
  } else {
32703
33916
  process.stderr.write(`${dim10(message)}
32704
33917
  `);
@@ -32709,16 +33922,17 @@ async function run16() {
32709
33922
  --- Session is waiting for your input. Use: specialists resume ${jobId} "..." ---
32710
33923
  `;
32711
33924
  if (args.json) {
32712
- emitJson(status, `${output3}${waitingFooter}`, null);
33925
+ emitJson(status, `${output3}${waitingFooter}`, null, startupContext2);
32713
33926
  } else {
32714
- emitHumanResult(output3, status, waitingFooter);
33927
+ emitHumanResult(output3, status, startupContext2, waitingFooter);
32715
33928
  }
32716
33929
  return;
32717
33930
  }
32718
33931
  if (status.status === "error") {
33932
+ const startupContext2 = deriveStartupSnapshot(status, readTimelineEventsForResult(sqliteClient, jobsDir, jobId));
32719
33933
  const message = `Job ${jobId} failed: ${status.error ?? "unknown error"}`;
32720
33934
  if (args.json) {
32721
- emitJson(status, null, message);
33935
+ emitJson(status, null, message, startupContext2);
32722
33936
  } else {
32723
33937
  process.stderr.write(`${red3(`Job ${jobId} failed:`)} ${status.error ?? "unknown error"}
32724
33938
  `);
@@ -32734,11 +33948,12 @@ async function run16() {
32734
33948
  }
32735
33949
  process.exit(1);
32736
33950
  }
33951
+ const startupContext = deriveStartupSnapshot(status, readTimelineEventsForResult(sqliteClient, jobsDir, jobId));
32737
33952
  if (args.json) {
32738
- emitJson(status, output2, null);
33953
+ emitJson(status, output2, null, startupContext);
32739
33954
  return;
32740
33955
  }
32741
- emitHumanResult(output2, status);
33956
+ emitHumanResult(output2, status, startupContext);
32742
33957
  } catch (error2) {
32743
33958
  const message = error2 instanceof Error ? error2.message : String(error2);
32744
33959
  if (args.json) {
@@ -32756,13 +33971,14 @@ var dim10 = (s) => `\x1B[2m${s}\x1B[0m`, red3 = (s) => `\x1B[31m${s}\x1B[0m`;
32756
33971
  var init_result = __esm(() => {
32757
33972
  init_supervisor();
32758
33973
  init_observability_sqlite();
33974
+ init_timeline_events();
32759
33975
  init_node_resolve();
32760
33976
  init_format_helpers();
32761
33977
  });
32762
33978
 
32763
33979
  // 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";
33980
+ import { existsSync as existsSync20, readdirSync as readdirSync9, readFileSync as readFileSync18 } from "fs";
33981
+ import { basename as basename5, join as join23 } from "path";
32766
33982
  function readJobEvents(jobDir) {
32767
33983
  const jobId = basename5(jobDir);
32768
33984
  try {
@@ -32772,10 +33988,10 @@ function readJobEvents(jobDir) {
32772
33988
  return sqliteEvents;
32773
33989
  }
32774
33990
  } catch {}
32775
- const eventsPath = join22(jobDir, "events.jsonl");
33991
+ const eventsPath = join23(jobDir, "events.jsonl");
32776
33992
  if (!existsSync20(eventsPath))
32777
33993
  return [];
32778
- const content = readFileSync16(eventsPath, "utf-8");
33994
+ const content = readFileSync18(eventsPath, "utf-8");
32779
33995
  const lines = content.split(`
32780
33996
  `).filter(Boolean);
32781
33997
  const events = [];
@@ -32788,7 +34004,7 @@ function readJobEvents(jobDir) {
32788
34004
  return events;
32789
34005
  }
32790
34006
  function readJobEventsById(jobsDir, jobId) {
32791
- return readJobEvents(join22(jobsDir, jobId));
34007
+ return readJobEvents(join23(jobsDir, jobId));
32792
34008
  }
32793
34009
  function readAllJobEvents(jobsDir) {
32794
34010
  if (!existsSync20(jobsDir))
@@ -32796,7 +34012,7 @@ function readAllJobEvents(jobsDir) {
32796
34012
  const batches = [];
32797
34013
  const entries = readdirSync9(jobsDir);
32798
34014
  for (const entry of entries) {
32799
- const jobDir = join22(jobsDir, entry);
34015
+ const jobDir = join23(jobsDir, entry);
32800
34016
  try {
32801
34017
  const stat2 = __require("fs").statSync(jobDir);
32802
34018
  if (!stat2.isDirectory())
@@ -32805,12 +34021,12 @@ function readAllJobEvents(jobsDir) {
32805
34021
  continue;
32806
34022
  }
32807
34023
  const jobId = entry;
32808
- const statusPath = join22(jobDir, "status.json");
34024
+ const statusPath = join23(jobDir, "status.json");
32809
34025
  let specialist = "unknown";
32810
34026
  let beadId;
32811
34027
  if (existsSync20(statusPath)) {
32812
34028
  try {
32813
- const status = JSON.parse(readFileSync16(statusPath, "utf-8"));
34029
+ const status = JSON.parse(readFileSync18(statusPath, "utf-8"));
32814
34030
  specialist = status.specialist ?? "unknown";
32815
34031
  beadId = status.bead_id;
32816
34032
  } catch {}
@@ -32908,12 +34124,12 @@ __export(exports_feed, {
32908
34124
  import {
32909
34125
  closeSync as closeSync2,
32910
34126
  existsSync as existsSync21,
32911
- openSync as openSync2,
32912
- readFileSync as readFileSync17,
34127
+ openSync as openSync3,
34128
+ readFileSync as readFileSync19,
32913
34129
  readdirSync as readdirSync10,
32914
- statSync as statSync2
34130
+ statSync as statSync3
32915
34131
  } from "fs";
32916
- import { join as join23 } from "path";
34132
+ import { join as join24 } from "path";
32917
34133
  function getHumanEventKey(event) {
32918
34134
  switch (event.type) {
32919
34135
  case "meta":
@@ -32981,6 +34197,50 @@ function formatWaitingBanner(jobId, specialist) {
32981
34197
  const prefix = magenta3(bold10("WAIT"));
32982
34198
  return `${prefix} ${specialist} (${jobId}) is waiting for input. Use: specialists resume ${jobId} "..."`;
32983
34199
  }
34200
+ function formatStartupContextLine(event) {
34201
+ if (event.type === "run_start") {
34202
+ const snapshot = event.startup_snapshot;
34203
+ if (!snapshot)
34204
+ return null;
34205
+ const parts = [];
34206
+ if (snapshot.job_id)
34207
+ parts.push(`job=${snapshot.job_id}`);
34208
+ if (snapshot.specialist_name)
34209
+ parts.push(`specialist=${snapshot.specialist_name}`);
34210
+ if (snapshot.bead_id)
34211
+ parts.push(`bead=${snapshot.bead_id}`);
34212
+ if (snapshot.reused_from_job_id)
34213
+ parts.push(`reused=${snapshot.reused_from_job_id}`);
34214
+ if (snapshot.worktree_owner_job_id)
34215
+ parts.push(`owner=${snapshot.worktree_owner_job_id}`);
34216
+ if (snapshot.chain_id)
34217
+ parts.push(`chain=${snapshot.chain_id}`);
34218
+ if (snapshot.chain_root_job_id)
34219
+ parts.push(`chain_root_job=${snapshot.chain_root_job_id}`);
34220
+ if (snapshot.chain_root_bead_id)
34221
+ parts.push(`chain_root_bead=${snapshot.chain_root_bead_id}`);
34222
+ if (snapshot.worktree_path)
34223
+ parts.push(`worktree=${snapshot.worktree_path}`);
34224
+ if (snapshot.branch)
34225
+ parts.push(`branch=${snapshot.branch}`);
34226
+ if (snapshot.variables_keys)
34227
+ parts.push(`vars=[${snapshot.variables_keys.join(",")}]`);
34228
+ if (snapshot.reviewed_job_id_present !== undefined)
34229
+ parts.push(`reviewed_present=${snapshot.reviewed_job_id_present}`);
34230
+ if (snapshot.reused_worktree_awareness_present !== undefined)
34231
+ parts.push(`reuse_awareness_present=${snapshot.reused_worktree_awareness_present}`);
34232
+ if (snapshot.bead_context_present !== undefined)
34233
+ parts.push(`bead_context_present=${snapshot.bead_context_present}`);
34234
+ if (snapshot.skills)
34235
+ parts.push(`skills=${snapshot.skills.count}`);
34236
+ return parts.length > 0 ? dim8(` \u21B3 startup ${parts.join(" ")}`) : null;
34237
+ }
34238
+ if (event.type === "meta" && event.memory_injection) {
34239
+ const mem = event.memory_injection;
34240
+ return dim8(` \u21B3 memory static=${mem.static_tokens} dynamic=${mem.memory_tokens} gitnexus=${mem.gitnexus_tokens} total=${mem.total_tokens}`);
34241
+ }
34242
+ return null;
34243
+ }
32984
34244
  function parseSince(value) {
32985
34245
  if (value.includes("T") || value.includes("-")) {
32986
34246
  return new Date(value).getTime();
@@ -33007,8 +34267,8 @@ function parseCursor(value, defaultJobId) {
33007
34267
  function readFileFresh(filePath) {
33008
34268
  let fd = null;
33009
34269
  try {
33010
- fd = openSync2(filePath, "r");
33011
- return readFileSync17(fd, "utf-8");
34270
+ fd = openSync3(filePath, "r");
34271
+ return readFileSync19(fd, "utf-8");
33012
34272
  } catch {
33013
34273
  return null;
33014
34274
  } finally {
@@ -33025,7 +34285,7 @@ function readStatusJson(sqliteClient, jobsDir, jobId) {
33025
34285
  } catch (error2) {
33026
34286
  console.warn(`SQLite status read failed for job ${jobId}; falling back to status.json`, error2);
33027
34287
  }
33028
- const statusPath = join23(jobsDir, jobId, "status.json");
34288
+ const statusPath = join24(jobsDir, jobId, "status.json");
33029
34289
  const raw = readFileFresh(statusPath);
33030
34290
  if (!raw)
33031
34291
  return null;
@@ -33195,6 +34455,9 @@ function printSnapshot(sqliteClient, merged, options, jobsDir) {
33195
34455
  contextPct: meta.contextPct,
33196
34456
  colorize
33197
34457
  }));
34458
+ const startupContextLine = formatStartupContextLine(event);
34459
+ if (startupContextLine)
34460
+ console.log(startupContextLine);
33198
34461
  }
33199
34462
  }
33200
34463
  function compareMergedEvents(a, b) {
@@ -33235,9 +34498,9 @@ function listMatchingJobIds(sqliteClient, jobsDir, options) {
33235
34498
  return [];
33236
34499
  const jobIds = [];
33237
34500
  for (const entry of readdirSync10(jobsDir)) {
33238
- const jobDir = join23(jobsDir, entry);
34501
+ const jobDir = join24(jobsDir, entry);
33239
34502
  try {
33240
- if (!statSync2(jobDir).isDirectory())
34503
+ if (!statSync3(jobDir).isDirectory())
33241
34504
  continue;
33242
34505
  } catch {
33243
34506
  continue;
@@ -33269,7 +34532,7 @@ function readJobEventsFresh(sqliteClient, jobsDir, jobId) {
33269
34532
  } catch (error2) {
33270
34533
  console.warn(`SQLite events read failed for job ${jobId}; falling back to events.jsonl`, error2);
33271
34534
  }
33272
- const eventsPath = join23(jobsDir, jobId, "events.jsonl");
34535
+ const eventsPath = join24(jobsDir, jobId, "events.jsonl");
33273
34536
  const content = readFileFresh(eventsPath);
33274
34537
  if (!content)
33275
34538
  return [];
@@ -33345,7 +34608,7 @@ async function followMerged(sqliteClient, jobsDir, options) {
33345
34608
  }
33346
34609
  const lastPrintedEventKey = new Map;
33347
34610
  const seenMetaKey = new Map;
33348
- await new Promise((resolve7) => {
34611
+ await new Promise((resolve8) => {
33349
34612
  const interval = setInterval(() => {
33350
34613
  const batches = filteredBatches();
33351
34614
  for (const jobId of listMatchingJobIds(sqliteClient, jobsDir, options)) {
@@ -33415,11 +34678,14 @@ async function followMerged(sqliteClient, jobsDir, options) {
33415
34678
  contextPct: meta.contextPct,
33416
34679
  colorize
33417
34680
  }));
34681
+ const startupContextLine = formatStartupContextLine(event);
34682
+ if (startupContextLine)
34683
+ console.log(startupContextLine);
33418
34684
  }
33419
34685
  }
33420
34686
  if (!options.forever && trackedJobs.size > 0 && completedJobs.size === trackedJobs.size) {
33421
34687
  clearInterval(interval);
33422
- resolve7();
34688
+ resolve8();
33423
34689
  }
33424
34690
  }, 500);
33425
34691
  });
@@ -33428,7 +34694,7 @@ async function run17() {
33428
34694
  const options = parseArgs9(process.argv.slice(3));
33429
34695
  const sqliteClient = createObservabilitySqliteClient();
33430
34696
  try {
33431
- const jobsDir = join23(process.cwd(), ".specialists", "jobs");
34697
+ const jobsDir = join24(process.cwd(), ".specialists", "jobs");
33432
34698
  if (!existsSync21(jobsDir)) {
33433
34699
  console.log(dim8("No jobs directory found."));
33434
34700
  return;
@@ -33468,8 +34734,8 @@ var exports_poll = {};
33468
34734
  __export(exports_poll, {
33469
34735
  run: () => run18
33470
34736
  });
33471
- import { existsSync as existsSync22, readFileSync as readFileSync18 } from "fs";
33472
- import { join as join24 } from "path";
34737
+ import { existsSync as existsSync22, readFileSync as readFileSync20 } from "fs";
34738
+ import { join as join25 } from "path";
33473
34739
  function parseArgs10(argv) {
33474
34740
  let jobId;
33475
34741
  let cursor = 0;
@@ -33506,19 +34772,19 @@ function parseArgs10(argv) {
33506
34772
  return { jobId, cursor, outputCursor };
33507
34773
  }
33508
34774
  function readJobState(jobsDir, jobId, cursor, outputCursor) {
33509
- const jobDir = join24(jobsDir, jobId);
33510
- const statusPath = join24(jobDir, "status.json");
34775
+ const jobDir = join25(jobsDir, jobId);
34776
+ const statusPath = join25(jobDir, "status.json");
33511
34777
  let status = null;
33512
34778
  if (existsSync22(statusPath)) {
33513
34779
  try {
33514
- status = JSON.parse(readFileSync18(statusPath, "utf-8"));
34780
+ status = JSON.parse(readFileSync20(statusPath, "utf-8"));
33515
34781
  } catch {}
33516
34782
  }
33517
- const resultPath = join24(jobDir, "result.txt");
34783
+ const resultPath = join25(jobDir, "result.txt");
33518
34784
  let fullOutput = "";
33519
34785
  if (existsSync22(resultPath)) {
33520
34786
  try {
33521
- fullOutput = readFileSync18(resultPath, "utf-8");
34787
+ fullOutput = readFileSync20(resultPath, "utf-8");
33522
34788
  } catch {}
33523
34789
  }
33524
34790
  const events = readJobEventsById(jobsDir, jobId);
@@ -33550,8 +34816,8 @@ function readJobState(jobsDir, jobId, cursor, outputCursor) {
33550
34816
  }
33551
34817
  async function run18() {
33552
34818
  const { jobId, cursor, outputCursor } = parseArgs10(process.argv.slice(3));
33553
- const jobsDir = join24(process.cwd(), ".specialists", "jobs");
33554
- const jobDir = join24(jobsDir, jobId);
34819
+ const jobsDir = join25(process.cwd(), ".specialists", "jobs");
34820
+ const jobDir = join25(jobsDir, jobId);
33555
34821
  if (!existsSync22(jobDir)) {
33556
34822
  const result2 = {
33557
34823
  job_id: jobId,
@@ -33583,7 +34849,7 @@ var exports_steer = {};
33583
34849
  __export(exports_steer, {
33584
34850
  run: () => run19
33585
34851
  });
33586
- import { writeFileSync as writeFileSync7 } from "fs";
34852
+ import { writeFileSync as writeFileSync9 } from "fs";
33587
34853
  async function run19() {
33588
34854
  const jobId = process.argv[3];
33589
34855
  const message = process.argv[4];
@@ -33614,7 +34880,7 @@ async function run19() {
33614
34880
  try {
33615
34881
  const payload = JSON.stringify({ type: "steer", message }) + `
33616
34882
  `;
33617
- writeFileSync7(status.fifo_path, payload, { flag: "a" });
34883
+ writeFileSync9(status.fifo_path, payload, { flag: "a" });
33618
34884
  process.stdout.write(`${green10("\u2713")} Steer message sent to job ${jobId}
33619
34885
  `);
33620
34886
  } catch (err) {
@@ -33637,7 +34903,7 @@ var exports_resume = {};
33637
34903
  __export(exports_resume, {
33638
34904
  run: () => run20
33639
34905
  });
33640
- import { writeFileSync as writeFileSync8 } from "fs";
34906
+ import { writeFileSync as writeFileSync10 } from "fs";
33641
34907
  async function run20() {
33642
34908
  const jobId = process.argv[3];
33643
34909
  const task = process.argv[4];
@@ -33668,7 +34934,7 @@ async function run20() {
33668
34934
  try {
33669
34935
  const payload = JSON.stringify({ type: "resume", task }) + `
33670
34936
  `;
33671
- writeFileSync8(status.fifo_path, payload, { flag: "a" });
34937
+ writeFileSync10(status.fifo_path, payload, { flag: "a" });
33672
34938
  process.stdout.write(`${green11("\u2713")} Resume sent to job ${jobId}
33673
34939
  `);
33674
34940
  process.stdout.write(` Use 'specialists feed ${jobId} --follow' to watch the response.
@@ -33700,15 +34966,15 @@ async function run21() {
33700
34966
  }
33701
34967
 
33702
34968
  // 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";
34969
+ import { existsSync as existsSync23, readdirSync as readdirSync11, readFileSync as readFileSync21 } from "fs";
34970
+ import { join as join26 } from "path";
33705
34971
  import { spawnSync as spawnSync18 } from "child_process";
33706
34972
  function readJobStatus2(jobDir) {
33707
- const statusPath = join25(jobDir, "status.json");
34973
+ const statusPath = join26(jobDir, "status.json");
33708
34974
  if (!existsSync23(statusPath))
33709
34975
  return null;
33710
34976
  try {
33711
- return JSON.parse(readFileSync19(statusPath, "utf-8"));
34977
+ return JSON.parse(readFileSync21(statusPath, "utf-8"));
33712
34978
  } catch {
33713
34979
  return null;
33714
34980
  }
@@ -33726,7 +34992,7 @@ function collectWorktreeGcCandidates(jobsDir) {
33726
34992
  for (const entry of readdirSync11(jobsDir, { withFileTypes: true })) {
33727
34993
  if (!entry.isDirectory())
33728
34994
  continue;
33729
- const status = readJobStatus2(join25(jobsDir, entry.name));
34995
+ const status = readJobStatus2(join26(jobsDir, entry.name));
33730
34996
  if (!status)
33731
34997
  continue;
33732
34998
  if (isActive(status.status))
@@ -33784,11 +35050,11 @@ __export(exports_clean, {
33784
35050
  import {
33785
35051
  existsSync as existsSync24,
33786
35052
  readdirSync as readdirSync12,
33787
- readFileSync as readFileSync20,
33788
- rmSync as rmSync2,
33789
- statSync as statSync3
35053
+ readFileSync as readFileSync22,
35054
+ rmSync as rmSync3,
35055
+ statSync as statSync4
33790
35056
  } from "fs";
33791
- import { join as join26 } from "path";
35057
+ import { join as join27 } from "path";
33792
35058
  function parseTtlDaysFromEnvironment() {
33793
35059
  const rawValue = process.env.SPECIALISTS_JOB_TTL_DAYS ?? process.env.JOB_TTL_DAYS;
33794
35060
  if (!rawValue)
@@ -33844,8 +35110,8 @@ function readDirectorySizeBytes(directoryPath) {
33844
35110
  let totalBytes = 0;
33845
35111
  const entries = readdirSync12(directoryPath, { withFileTypes: true });
33846
35112
  for (const entry of entries) {
33847
- const entryPath = join26(directoryPath, entry.name);
33848
- const stats = statSync3(entryPath);
35113
+ const entryPath = join27(directoryPath, entry.name);
35114
+ const stats = statSync4(entryPath);
33849
35115
  if (stats.isDirectory()) {
33850
35116
  totalBytes += readDirectorySizeBytes(entryPath);
33851
35117
  continue;
@@ -33857,7 +35123,7 @@ function readDirectorySizeBytes(directoryPath) {
33857
35123
  function containsProtectedSqliteArtifact(directoryPath) {
33858
35124
  const entries = readdirSync12(directoryPath, { withFileTypes: true });
33859
35125
  for (const entry of entries) {
33860
- const entryPath = join26(directoryPath, entry.name);
35126
+ const entryPath = join27(directoryPath, entry.name);
33861
35127
  if (entry.isDirectory()) {
33862
35128
  if (containsProtectedSqliteArtifact(entryPath))
33863
35129
  return true;
@@ -33872,21 +35138,21 @@ function containsProtectedSqliteArtifact(directoryPath) {
33872
35138
  function readCompletedJobDirectory(baseDirectory, entry) {
33873
35139
  if (!entry.isDirectory())
33874
35140
  return null;
33875
- const directoryPath = join26(baseDirectory, entry.name);
35141
+ const directoryPath = join27(baseDirectory, entry.name);
33876
35142
  if (containsProtectedSqliteArtifact(directoryPath))
33877
35143
  return null;
33878
- const statusFilePath = join26(directoryPath, "status.json");
35144
+ const statusFilePath = join27(directoryPath, "status.json");
33879
35145
  if (!existsSync24(statusFilePath))
33880
35146
  return null;
33881
35147
  let statusData;
33882
35148
  try {
33883
- statusData = JSON.parse(readFileSync20(statusFilePath, "utf-8"));
35149
+ statusData = JSON.parse(readFileSync22(statusFilePath, "utf-8"));
33884
35150
  } catch {
33885
35151
  return null;
33886
35152
  }
33887
35153
  if (!COMPLETED_STATUSES.has(statusData.status))
33888
35154
  return null;
33889
- const directoryStats = statSync3(directoryPath);
35155
+ const directoryStats = statSync4(directoryPath);
33890
35156
  return {
33891
35157
  id: entry.name,
33892
35158
  directoryPath,
@@ -33923,7 +35189,7 @@ function selectJobsToRemove(completedJobs, options) {
33923
35189
  const cutoffMs = Date.now() - ttlDays * MS_PER_DAY;
33924
35190
  return jobsByNewest.filter((job) => job.modifiedAtMs < cutoffMs);
33925
35191
  }
33926
- function formatBytes(bytes) {
35192
+ function formatBytes2(bytes) {
33927
35193
  if (bytes < 1024)
33928
35194
  return `${bytes} B`;
33929
35195
  const units = ["KB", "MB", "GB", "TB"];
@@ -33938,7 +35204,7 @@ function formatBytes(bytes) {
33938
35204
  function renderSummary(removedCount, freedBytes, dryRun) {
33939
35205
  const action = dryRun ? "Would remove" : "Removed";
33940
35206
  const noun = removedCount === 1 ? "directory" : "directories";
33941
- return `${action} ${removedCount} job ${noun} (${formatBytes(freedBytes)} freed)`;
35207
+ return `${action} ${removedCount} job ${noun} (${formatBytes2(freedBytes)} freed)`;
33942
35208
  }
33943
35209
  function printDryRunPlan(jobs) {
33944
35210
  if (jobs.length === 0)
@@ -33992,7 +35258,7 @@ async function run22() {
33992
35258
  return;
33993
35259
  }
33994
35260
  for (const job of jobsToRemove) {
33995
- rmSync2(job.directoryPath, { recursive: true, force: true });
35261
+ rmSync3(job.directoryPath, { recursive: true, force: true });
33996
35262
  }
33997
35263
  console.log(renderSummary(jobsToRemove.length, freedBytes, false));
33998
35264
  if (worktreeCandidates.length > 0) {
@@ -34115,6 +35381,10 @@ async function run23() {
34115
35381
  const guard = checkEpicUnresolvedGuard(beadId);
34116
35382
  if (guard.blocked && guard.epicId && guard.epicStatus && isEpicUnresolvedState(guard.epicStatus)) {
34117
35383
  console.log(`Chain ${beadId} belongs to unresolved epic ${guard.epicId} (${guard.epicStatus}).`);
35384
+ if (guard.epicStatus === "open") {
35385
+ console.log(`Epic ${guard.epicId} still open. Run: sp epic resolve ${guard.epicId}`);
35386
+ process.exit(1);
35387
+ }
34118
35388
  console.log(`Redirecting session close publication to epic merge (${options.pr ? "PR mode" : "direct mode"}).`);
34119
35389
  const args = ["merge", guard.epicId, ...options.rebuild ? ["--rebuild"] : [], ...options.pr ? ["--pr"] : []];
34120
35390
  await handleEpicMergeCommand(args);
@@ -34137,10 +35407,51 @@ __export(exports_stop, {
34137
35407
  function resolveTerminalStatus(jobId) {
34138
35408
  return hasRunCompleteEvent(jobId) ? "done" : "cancelled";
34139
35409
  }
35410
+ function parseStopArgs(argv) {
35411
+ let jobId;
35412
+ let force = false;
35413
+ for (const token of argv) {
35414
+ if (token === "--force") {
35415
+ force = true;
35416
+ continue;
35417
+ }
35418
+ if (!token.startsWith("-") && !jobId) {
35419
+ jobId = token;
35420
+ continue;
35421
+ }
35422
+ throw new Error(`Unknown option: ${token}`);
35423
+ }
35424
+ return { jobId, force };
35425
+ }
35426
+ async function waitForProcessExit(pid, timeoutMs) {
35427
+ const deadline = Date.now() + timeoutMs;
35428
+ while (Date.now() < deadline) {
35429
+ if (!isProcessAlive(pid))
35430
+ return true;
35431
+ await new Promise((resolve8) => setTimeout(resolve8, 100));
35432
+ }
35433
+ return !isProcessAlive(pid);
35434
+ }
35435
+ function tryKillProcessGroup(pid) {
35436
+ try {
35437
+ process.kill(-pid, "SIGKILL");
35438
+ } catch (err) {
35439
+ if (err.code !== "ESRCH")
35440
+ throw err;
35441
+ }
35442
+ }
34140
35443
  async function run24() {
34141
- const jobId = process.argv[3];
35444
+ let parsed;
35445
+ try {
35446
+ parsed = parseStopArgs(process.argv.slice(3));
35447
+ } catch (err) {
35448
+ console.error(err.message);
35449
+ console.error("Usage: specialists|sp stop <job-id> [--force]");
35450
+ process.exit(1);
35451
+ }
35452
+ const { jobId, force } = parsed;
34142
35453
  if (!jobId) {
34143
- console.error("Usage: specialists|sp stop <job-id>");
35454
+ console.error("Usage: specialists|sp stop <job-id> [--force]");
34144
35455
  process.exit(1);
34145
35456
  }
34146
35457
  const jobsDir = resolveJobsDir(process.cwd());
@@ -34161,33 +35472,53 @@ async function run24() {
34161
35472
  `);
34162
35473
  process.exit(1);
34163
35474
  }
35475
+ const pid = status.pid;
34164
35476
  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}
34170
- `);
34171
- if (tmuxSession) {
34172
- killTmuxSession(tmuxSession);
34173
- process.stdout.write(`${dim11(` tmux session ${tmuxSession} killed`)}
35477
+ const isAlreadyDead = !isProcessAlive(pid, status.started_at_ms);
35478
+ if (force && isAlreadyDead) {
35479
+ supervisor.updateJobStatus(jobId, "error");
35480
+ tryKillProcessGroup(pid);
35481
+ process.stdout.write(`${green12("\u2713")} Marked ${jobId} as error (PID ${pid} already dead)
34174
35482
  `);
34175
- }
34176
- } catch (err) {
34177
- if (err.code === "ESRCH") {
34178
- process.stderr.write(`${red6(`Process ${status.pid} not found.`)} Job may have already completed.
35483
+ } else {
35484
+ const terminalStatus = resolveTerminalStatus(jobId);
35485
+ supervisor.updateJobStatus(jobId, terminalStatus);
35486
+ try {
35487
+ process.kill(pid, "SIGTERM");
35488
+ process.stdout.write(`${green12("\u2713")} Marked ${jobId} as ${terminalStatus} and sent SIGTERM to PID ${pid}
34179
35489
  `);
34180
- if (tmuxSession) {
34181
- killTmuxSession(tmuxSession);
34182
- process.stdout.write(`${dim11(` tmux session ${tmuxSession} killed`)}
35490
+ if (force) {
35491
+ const exited = await waitForProcessExit(pid, 5000);
35492
+ if (!exited) {
35493
+ supervisor.updateJobStatus(jobId, "error");
35494
+ tryKillProcessGroup(pid);
35495
+ process.stderr.write(`${red6("Force stop:")} PID ${pid} ignored SIGTERM, marked ${jobId} as error and killed process group.
34183
35496
  `);
35497
+ }
34184
35498
  }
34185
- } else {
34186
- process.stderr.write(`${red6("Error:")} ${err.message}
35499
+ } catch (err) {
35500
+ if (err.code === "ESRCH") {
35501
+ if (force) {
35502
+ supervisor.updateJobStatus(jobId, "error");
35503
+ tryKillProcessGroup(pid);
35504
+ process.stdout.write(`${green12("\u2713")} Marked ${jobId} as error (PID ${pid} already gone)
34187
35505
  `);
34188
- process.exit(1);
35506
+ } else {
35507
+ process.stderr.write(`${red6(`Process ${pid} not found.`)} Job may have already completed.
35508
+ `);
35509
+ }
35510
+ } else {
35511
+ process.stderr.write(`${red6("Error:")} ${err.message}
35512
+ `);
35513
+ process.exit(1);
35514
+ }
34189
35515
  }
34190
35516
  }
35517
+ if (tmuxSession) {
35518
+ killTmuxSession(tmuxSession);
35519
+ process.stdout.write(`${dim11(` tmux session ${tmuxSession} killed`)}
35520
+ `);
35521
+ }
34191
35522
  } finally {
34192
35523
  await supervisor.dispose();
34193
35524
  }
@@ -34197,6 +35528,7 @@ var init_stop = __esm(() => {
34197
35528
  init_supervisor();
34198
35529
  init_job_root();
34199
35530
  init_observability_sqlite();
35531
+ init_process_liveness();
34200
35532
  init_tmux_utils();
34201
35533
  });
34202
35534
 
@@ -34206,15 +35538,15 @@ __export(exports_attach, {
34206
35538
  run: () => run25
34207
35539
  });
34208
35540
  import { execFileSync as execFileSync3, spawnSync as spawnSync20 } from "child_process";
34209
- import { readFileSync as readFileSync21 } from "fs";
34210
- import { join as join27 } from "path";
35541
+ import { readFileSync as readFileSync23 } from "fs";
35542
+ import { join as join28 } from "path";
34211
35543
  function exitWithError(message) {
34212
35544
  console.error(message);
34213
35545
  process.exit(1);
34214
35546
  }
34215
35547
  function readStatus(statusPath, jobId) {
34216
35548
  try {
34217
- return JSON.parse(readFileSync21(statusPath, "utf-8"));
35549
+ return JSON.parse(readFileSync23(statusPath, "utf-8"));
34218
35550
  } catch (error2) {
34219
35551
  if (error2 && typeof error2 === "object" && "code" in error2 && error2.code === "ENOENT") {
34220
35552
  exitWithError(`Job \`${jobId}\` not found. Run \`specialists status\` to see active jobs.`);
@@ -34228,8 +35560,8 @@ async function run25() {
34228
35560
  if (!jobId) {
34229
35561
  exitWithError("Usage: specialists attach <job-id>");
34230
35562
  }
34231
- const jobsDir = join27(process.cwd(), ".specialists", "jobs");
34232
- const statusPath = join27(jobsDir, jobId, "status.json");
35563
+ const jobsDir = join28(process.cwd(), ".specialists", "jobs");
35564
+ const statusPath = join28(jobsDir, jobId, "status.json");
34233
35565
  const status = readStatus(statusPath, jobId);
34234
35566
  if (status.status === "done" || status.status === "error") {
34235
35567
  exitWithError(`Job \`${jobId}\` has already completed (status: ${status.status}). Use \`specialists result ${jobId}\` to read output.`);
@@ -34490,8 +35822,8 @@ __export(exports_doctor, {
34490
35822
  });
34491
35823
  import { createHash as createHash4 } from "crypto";
34492
35824
  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";
35825
+ import { existsSync as existsSync25, lstatSync as lstatSync2, mkdirSync as mkdirSync8, readdirSync as readdirSync13, readFileSync as readFileSync24, readlinkSync as readlinkSync2, writeFileSync as writeFileSync11 } from "fs";
35826
+ import { dirname as dirname7, join as join29, relative as relative2, resolve as resolve8 } from "path";
34495
35827
  function ok3(msg) {
34496
35828
  console.log(` ${green14("\u2713")} ${msg}`);
34497
35829
  }
@@ -34523,7 +35855,7 @@ function loadJson2(path) {
34523
35855
  if (!existsSync25(path))
34524
35856
  return null;
34525
35857
  try {
34526
- return JSON.parse(readFileSync22(path, "utf8"));
35858
+ return JSON.parse(readFileSync24(path, "utf8"));
34527
35859
  } catch {
34528
35860
  return null;
34529
35861
  }
@@ -34566,7 +35898,7 @@ function checkBd() {
34566
35898
  return false;
34567
35899
  }
34568
35900
  ok3(`bd installed ${dim13(sp("bd", ["--version"]).stdout || "")}`);
34569
- if (existsSync25(join28(CWD, ".beads")))
35901
+ if (existsSync25(join29(CWD, ".beads")))
34570
35902
  ok3(".beads/ present in project");
34571
35903
  else
34572
35904
  warn3(".beads/ not found in project");
@@ -34586,7 +35918,7 @@ function checkHooks() {
34586
35918
  section3("Claude Code hooks (2 expected)");
34587
35919
  let allPresent = true;
34588
35920
  for (const name of HOOK_NAMES) {
34589
- const canonicalPath = join28(HOOKS_DIR, name);
35921
+ const canonicalPath = join29(HOOKS_DIR, name);
34590
35922
  if (!existsSync25(canonicalPath)) {
34591
35923
  fail4(`${relative2(CWD, canonicalPath)} ${red7("missing")}`);
34592
35924
  fix("specialists init");
@@ -34594,10 +35926,10 @@ function checkHooks() {
34594
35926
  } else {
34595
35927
  ok3(relative2(CWD, canonicalPath));
34596
35928
  }
34597
- const claudeHookPath = join28(CLAUDE_HOOKS_DIR, name);
35929
+ const claudeHookPath = join29(CLAUDE_HOOKS_DIR, name);
34598
35930
  const symlinkState = isSymlinkTo(claudeHookPath, canonicalPath);
34599
35931
  if (symlinkState.ok) {
34600
- ok3(`${relative2(CWD, claudeHookPath)} -> ${relative2(dirname6(claudeHookPath), canonicalPath)}`);
35932
+ ok3(`${relative2(CWD, claudeHookPath)} -> ${relative2(dirname7(claudeHookPath), canonicalPath)}`);
34601
35933
  continue;
34602
35934
  }
34603
35935
  allPresent = false;
@@ -34649,14 +35981,14 @@ function checkMCP() {
34649
35981
  }
34650
35982
  function hashFile(path) {
34651
35983
  const hash = createHash4("sha256");
34652
- hash.update(readFileSync22(path));
35984
+ hash.update(readFileSync24(path));
34653
35985
  return hash.digest("hex");
34654
35986
  }
34655
35987
  function collectFileHashes(rootDir) {
34656
35988
  const hashes = new Map;
34657
35989
  const visit2 = (dir) => {
34658
35990
  for (const entry of readdirSync13(dir, { withFileTypes: true })) {
34659
- const fullPath = join28(dir, entry.name);
35991
+ const fullPath = join29(dir, entry.name);
34660
35992
  if (entry.isDirectory()) {
34661
35993
  visit2(fullPath);
34662
35994
  continue;
@@ -34684,8 +36016,8 @@ function isSymlinkTo(linkPath, expectedTargetPath) {
34684
36016
  return { ok: false, reason: "not-symlink" };
34685
36017
  try {
34686
36018
  const rawTarget = readlinkSync2(linkPath);
34687
- const resolvedTarget = resolve7(dirname6(linkPath), rawTarget);
34688
- const resolvedExpected = resolve7(expectedTargetPath);
36019
+ const resolvedTarget = resolve8(dirname7(linkPath), rawTarget);
36020
+ const resolvedExpected = resolve8(expectedTargetPath);
34689
36021
  if (resolvedTarget !== resolvedExpected) {
34690
36022
  return { ok: false, reason: "wrong-target", target: rawTarget };
34691
36023
  }
@@ -34743,7 +36075,7 @@ function checkSkillDrift() {
34743
36075
  }
34744
36076
  let linksOk = true;
34745
36077
  for (const scope of ["claude", "pi"]) {
34746
- const activeRoot = join28(XTRM_ACTIVE_SKILLS_DIR, scope);
36078
+ const activeRoot = join29(XTRM_ACTIVE_SKILLS_DIR, scope);
34747
36079
  if (!existsSync25(activeRoot)) {
34748
36080
  fail4(`${relative2(CWD, activeRoot)}/ missing`);
34749
36081
  fix("specialists init --sync-skills");
@@ -34752,8 +36084,8 @@ function checkSkillDrift() {
34752
36084
  }
34753
36085
  const defaultSkills = readdirSync13(XTRM_DEFAULT_SKILLS_DIR, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
34754
36086
  for (const skillName of defaultSkills) {
34755
- const activeLinkPath = join28(activeRoot, skillName);
34756
- const expectedTarget = join28(XTRM_DEFAULT_SKILLS_DIR, skillName);
36087
+ const activeLinkPath = join29(activeRoot, skillName);
36088
+ const expectedTarget = join29(XTRM_DEFAULT_SKILLS_DIR, skillName);
34757
36089
  const state = isSymlinkTo(activeLinkPath, expectedTarget);
34758
36090
  if (state.ok)
34759
36091
  continue;
@@ -34772,14 +36104,14 @@ function checkSkillDrift() {
34772
36104
  }
34773
36105
  }
34774
36106
  const skillRootChecks = [
34775
- { root: join28(CLAUDE_DIR, "skills"), expected: ACTIVE_CLAUDE_SKILLS_DIR },
34776
- { root: join28(PI_DIR, "skills"), expected: ACTIVE_PI_SKILLS_DIR }
36107
+ { root: join29(CLAUDE_DIR, "skills"), expected: ACTIVE_CLAUDE_SKILLS_DIR },
36108
+ { root: join29(PI_DIR, "skills"), expected: ACTIVE_PI_SKILLS_DIR }
34777
36109
  ];
34778
36110
  let rootLinksOk = true;
34779
36111
  for (const check2 of skillRootChecks) {
34780
36112
  const state = isSymlinkTo(check2.root, check2.expected);
34781
36113
  if (state.ok) {
34782
- ok3(`${relative2(CWD, check2.root)} -> ${relative2(dirname6(check2.root), check2.expected)}`);
36114
+ ok3(`${relative2(CWD, check2.root)} -> ${relative2(dirname7(check2.root), check2.expected)}`);
34783
36115
  continue;
34784
36116
  }
34785
36117
  rootLinksOk = false;
@@ -34799,9 +36131,9 @@ function checkSkillDrift() {
34799
36131
  }
34800
36132
  function checkRuntimeDirs() {
34801
36133
  section3(".specialists/ runtime directories");
34802
- const rootDir = join28(CWD, ".specialists");
34803
- const jobsDir = join28(rootDir, "jobs");
34804
- const readyDir = join28(rootDir, "ready");
36134
+ const rootDir = join29(CWD, ".specialists");
36135
+ const jobsDir = join29(rootDir, "jobs");
36136
+ const readyDir = join29(rootDir, "ready");
34805
36137
  let allOk = true;
34806
36138
  if (!existsSync25(rootDir)) {
34807
36139
  warn3(".specialists/ not found in current project");
@@ -34812,7 +36144,7 @@ function checkRuntimeDirs() {
34812
36144
  for (const [subDir, label] of [[jobsDir, "jobs"], [readyDir, "ready"]]) {
34813
36145
  if (!existsSync25(subDir)) {
34814
36146
  warn3(`.specialists/${label}/ missing \u2014 auto-creating`);
34815
- mkdirSync6(subDir, { recursive: true });
36147
+ mkdirSync8(subDir, { recursive: true });
34816
36148
  ok3(`.specialists/${label}/ created`);
34817
36149
  } else {
34818
36150
  ok3(`.specialists/${label}/ present`);
@@ -34843,10 +36175,10 @@ function compareVersions(left, right) {
34843
36175
  }
34844
36176
  function setStatusError(statusPath) {
34845
36177
  try {
34846
- const raw = readFileSync22(statusPath, "utf8");
36178
+ const raw = readFileSync24(statusPath, "utf8");
34847
36179
  const status = JSON.parse(raw);
34848
36180
  status.status = "error";
34849
- writeFileSync9(statusPath, `${JSON.stringify(status, null, 2)}
36181
+ writeFileSync11(statusPath, `${JSON.stringify(status, null, 2)}
34850
36182
  `, "utf8");
34851
36183
  } catch {}
34852
36184
  }
@@ -34865,11 +36197,11 @@ function cleanupProcesses(jobsDir, dryRun) {
34865
36197
  zombieJobIds: []
34866
36198
  };
34867
36199
  for (const jobId of entries) {
34868
- const statusPath = join28(jobsDir, jobId, "status.json");
36200
+ const statusPath = join29(jobsDir, jobId, "status.json");
34869
36201
  if (!existsSync25(statusPath))
34870
36202
  continue;
34871
36203
  try {
34872
- const status = JSON.parse(readFileSync22(statusPath, "utf8"));
36204
+ const status = JSON.parse(readFileSync24(statusPath, "utf8"));
34873
36205
  result.total += 1;
34874
36206
  if (status.status !== "running" && status.status !== "starting")
34875
36207
  continue;
@@ -34900,9 +36232,52 @@ function renderProcessSummary(result, dryRun) {
34900
36232
  const action = dryRun ? "would be marked error" : "marked error";
34901
36233
  return `${result.zombies} zombie job${result.zombies === 1 ? "" : "s"} found (${result.updated} ${action})`;
34902
36234
  }
36235
+ function runDoctorOrphans() {
36236
+ const sqliteClient = createObservabilitySqliteClient();
36237
+ if (!sqliteClient) {
36238
+ console.log(`
36239
+ ${bold13("specialists doctor orphans")}
36240
+ `);
36241
+ fail4("observability SQLite not available");
36242
+ fix("specialists db setup");
36243
+ console.log("");
36244
+ process.exit(1);
36245
+ }
36246
+ try {
36247
+ const findings = sqliteClient.scanOrphans();
36248
+ const byKind = {
36249
+ orphan: findings.filter((item) => item.kind === "orphan"),
36250
+ stalePointer: findings.filter((item) => item.kind === "stale-pointer"),
36251
+ integrity: findings.filter((item) => item.kind === "integrity-violation")
36252
+ };
36253
+ console.log(`
36254
+ ${bold13("specialists doctor orphans")}
36255
+ `);
36256
+ if (findings.length === 0) {
36257
+ ok3("No orphan/stale/integrity findings");
36258
+ console.log("");
36259
+ return;
36260
+ }
36261
+ const renderGroup = (label, rows) => {
36262
+ if (rows.length === 0)
36263
+ return;
36264
+ console.log(` ${yellow12("\u25CB")} ${label}: ${rows.length}`);
36265
+ for (const row of rows) {
36266
+ console.log(` - [${row.code}] ${row.message}`);
36267
+ }
36268
+ };
36269
+ renderGroup("orphan", byKind.orphan);
36270
+ renderGroup("stale-pointer", byKind.stalePointer);
36271
+ renderGroup("integrity-violation", byKind.integrity);
36272
+ console.log("");
36273
+ process.exit(1);
36274
+ } finally {
36275
+ sqliteClient.close();
36276
+ }
36277
+ }
34903
36278
  function checkZombieJobs() {
34904
36279
  section3("Background jobs");
34905
- const jobsDir = join28(CWD, ".specialists", "jobs");
36280
+ const jobsDir = join29(CWD, ".specialists", "jobs");
34906
36281
  if (!existsSync25(jobsDir)) {
34907
36282
  hint("No .specialists/jobs/ \u2014 skipping");
34908
36283
  return true;
@@ -34921,7 +36296,16 @@ function checkZombieJobs() {
34921
36296
  }
34922
36297
  return result.zombies === 0;
34923
36298
  }
34924
- async function run27() {
36299
+ async function run27(argv = process.argv.slice(3)) {
36300
+ const subcommand = argv[0];
36301
+ if (subcommand === "orphans") {
36302
+ runDoctorOrphans();
36303
+ return;
36304
+ }
36305
+ if (subcommand && subcommand !== "--help" && subcommand !== "-h") {
36306
+ console.error(`Unknown doctor subcommand: '${subcommand}'`);
36307
+ process.exit(1);
36308
+ }
34925
36309
  console.log(`
34926
36310
  ${bold13("specialists doctor")}
34927
36311
  `);
@@ -34946,20 +36330,21 @@ ${bold13("specialists doctor")}
34946
36330
  }
34947
36331
  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
36332
  var init_doctor = __esm(() => {
36333
+ init_observability_sqlite();
34949
36334
  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");
36335
+ CLAUDE_DIR = join29(CWD, ".claude");
36336
+ PI_DIR = join29(CWD, ".pi");
36337
+ XTRM_SKILLS_DIR = join29(CWD, ".xtrm", "skills");
36338
+ XTRM_DEFAULT_SKILLS_DIR = join29(XTRM_SKILLS_DIR, "default");
36339
+ XTRM_ACTIVE_SKILLS_DIR = join29(XTRM_SKILLS_DIR, "active");
36340
+ ACTIVE_CLAUDE_SKILLS_DIR = join29(XTRM_ACTIVE_SKILLS_DIR, "claude");
36341
+ ACTIVE_PI_SKILLS_DIR = join29(XTRM_ACTIVE_SKILLS_DIR, "pi");
36342
+ CONFIG_SKILLS_DIR = join29(CWD, "config", "skills");
36343
+ SPECIALISTS_DIR = join29(CWD, ".specialists");
36344
+ HOOKS_DIR = join29(CWD, ".xtrm", "hooks", "specialists");
36345
+ CLAUDE_HOOKS_DIR = join29(CLAUDE_DIR, "hooks");
36346
+ SETTINGS_FILE = join29(CLAUDE_DIR, "settings.json");
36347
+ MCP_FILE2 = join29(CWD, ".mcp.json");
34963
36348
  HOOK_NAMES = [
34964
36349
  "specialists-complete.mjs",
34965
36350
  "specialists-session-start.mjs"
@@ -42818,7 +44203,7 @@ async function run30() {
42818
44203
  if (wantsHelp()) {
42819
44204
  console.log([
42820
44205
  "",
42821
- "Usage: specialists db <setup|backfill>",
44206
+ "Usage: specialists db <setup|backfill|vacuum|prune>",
42822
44207
  "",
42823
44208
  "Provision the shared observability SQLite database (human-only).",
42824
44209
  "",
@@ -42827,6 +44212,9 @@ async function run30() {
42827
44212
  " init Alias for setup",
42828
44213
  " backfill Backfill specialist_jobs from .specialists/jobs/*/status.json",
42829
44214
  " Use --events to also replay events.jsonl",
44215
+ " vacuum Run SQLite VACUUM (refuses when active jobs running/starting)",
44216
+ " prune Prune old rows: requires --before <iso|duration>, dry-run by default",
44217
+ " Use --apply to execute; --include-epics to also prune epic_runs",
42830
44218
  "",
42831
44219
  "Notes:",
42832
44220
  " - TTY required (blocked in agent/non-interactive sessions)",
@@ -42837,6 +44225,9 @@ async function run30() {
42837
44225
  " specialists db setup",
42838
44226
  " specialists db backfill",
42839
44227
  " specialists db backfill --events",
44228
+ " specialists db vacuum",
44229
+ " specialists db prune --before 30d --dry-run",
44230
+ " specialists db prune --before 2026-01-01T00:00:00Z --apply --include-epics",
42840
44231
  " sp db setup",
42841
44232
  " sp db backfill",
42842
44233
  ""
@@ -43508,7 +44899,7 @@ async function run30() {
43508
44899
  if (wantsHelp()) {
43509
44900
  console.log([
43510
44901
  "",
43511
- "Usage: specialists doctor",
44902
+ "Usage: specialists doctor [orphans]",
43512
44903
  "",
43513
44904
  "Diagnose bootstrap and runtime problems.",
43514
44905
  "",
@@ -43525,15 +44916,19 @@ async function run30() {
43525
44916
  " - prints fix hints for failing checks",
43526
44917
  " - auto-creates missing runtime directories when possible",
43527
44918
  "",
44919
+ "Subcommands:",
44920
+ " orphans Read-only orphan scan: membership/jobs/epics/worktree pointers",
44921
+ "",
43528
44922
  "Examples:",
43529
44923
  " specialists doctor",
44924
+ " specialists doctor orphans",
43530
44925
  ""
43531
44926
  ].join(`
43532
44927
  `));
43533
44928
  return;
43534
44929
  }
43535
44930
  const { run: handler } = await Promise.resolve().then(() => (init_doctor(), exports_doctor));
43536
- return handler();
44931
+ return handler(process.argv.slice(3));
43537
44932
  }
43538
44933
  if (sub === "setup") {
43539
44934
  if (wantsHelp()) {