@jaggerxtrm/specialists 3.10.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +3 -0
  2. package/config/hooks/specialists-session-start.mjs +33 -1
  3. package/config/mandatory-rules/changelog-conventions.md +21 -0
  4. package/config/mandatory-rules/changelog-keeper-scope.md +50 -0
  5. package/config/mandatory-rules/gitnexus-required.md +6 -1
  6. package/config/mandatory-rules/sync-docs-scope-discipline.md +40 -0
  7. package/config/skills/releasing/SKILL.md +82 -0
  8. package/config/skills/specialists-creator/SKILL.md +84 -10
  9. package/config/skills/specialists-creator/scripts/validate-specialist.ts +1 -1
  10. package/config/skills/update-specialists/SKILL.md +41 -7
  11. package/config/skills/using-kpi/SKILL.md +150 -0
  12. package/config/skills/using-script-specialists/SKILL.md +208 -0
  13. package/config/skills/using-specialists-v2/SKILL.md +162 -28
  14. package/config/skills/using-specialists-v3/SKILL.md +284 -0
  15. package/config/skills/using-specialists-v3/evals/evals.json +89 -0
  16. package/config/specialists/changelog-drafter.specialist.json +62 -0
  17. package/config/specialists/changelog-keeper.specialist.json +79 -0
  18. package/config/specialists/code-sanity.specialist.json +106 -0
  19. package/config/specialists/debugger.specialist.json +4 -4
  20. package/config/specialists/executor.specialist.json +4 -4
  21. package/config/specialists/explorer.specialist.json +14 -4
  22. package/config/specialists/memory-processor.specialist.json +4 -4
  23. package/config/specialists/node-coordinator.specialist.json +3 -3
  24. package/config/specialists/overthinker.specialist.json +3 -3
  25. package/config/specialists/planner.specialist.json +4 -4
  26. package/config/specialists/researcher.specialist.json +3 -3
  27. package/config/specialists/reviewer.specialist.json +4 -4
  28. package/config/specialists/security-auditor.specialist.json +68 -0
  29. package/config/specialists/specialists-creator.specialist.json +6 -5
  30. package/config/specialists/sync-docs.specialist.json +15 -18
  31. package/config/specialists/test-runner.specialist.json +3 -3
  32. package/config/specialists/xt-merge.specialist.json +4 -4
  33. package/dist/index.js +3323 -1004
  34. package/dist/lib.js +480 -135
  35. package/dist/types/cli/clean.d.ts.map +1 -1
  36. package/dist/types/cli/config.d.ts.map +1 -1
  37. package/dist/types/cli/db.d.ts.map +1 -1
  38. package/dist/types/cli/doctor.d.ts.map +1 -1
  39. package/dist/types/cli/feed.d.ts.map +1 -1
  40. package/dist/types/cli/help.d.ts.map +1 -1
  41. package/dist/types/cli/init.d.ts.map +1 -1
  42. package/dist/types/cli/list.d.ts +4 -0
  43. package/dist/types/cli/list.d.ts.map +1 -1
  44. package/dist/types/cli/merge.d.ts +4 -2
  45. package/dist/types/cli/merge.d.ts.map +1 -1
  46. package/dist/types/cli/node.d.ts.map +1 -1
  47. package/dist/types/cli/prune-stale-defaults.d.ts +2 -0
  48. package/dist/types/cli/prune-stale-defaults.d.ts.map +1 -0
  49. package/dist/types/cli/ps.d.ts.map +1 -1
  50. package/dist/types/cli/result.d.ts.map +1 -1
  51. package/dist/types/cli/run.d.ts.map +1 -1
  52. package/dist/types/cli/script.d.ts.map +1 -1
  53. package/dist/types/cli/serve-hot-reload.d.ts +13 -0
  54. package/dist/types/cli/serve-hot-reload.d.ts.map +1 -0
  55. package/dist/types/cli/serve.d.ts +28 -0
  56. package/dist/types/cli/serve.d.ts.map +1 -1
  57. package/dist/types/cli/status.d.ts.map +1 -1
  58. package/dist/types/cli/stop.d.ts.map +1 -1
  59. package/dist/types/cli/version-check.d.ts +17 -0
  60. package/dist/types/cli/version-check.d.ts.map +1 -0
  61. package/dist/types/index.d.ts +1 -1
  62. package/dist/types/pi/session.d.ts +10 -0
  63. package/dist/types/pi/session.d.ts.map +1 -1
  64. package/dist/types/specialist/canonical-asset-resolver.d.ts +6 -0
  65. package/dist/types/specialist/canonical-asset-resolver.d.ts.map +1 -0
  66. package/dist/types/specialist/drift-detector.d.ts +39 -0
  67. package/dist/types/specialist/drift-detector.d.ts.map +1 -0
  68. package/dist/types/specialist/epic-lifecycle.d.ts.map +1 -1
  69. package/dist/types/specialist/epic-readiness.d.ts.map +1 -1
  70. package/dist/types/specialist/epic-reconciler.d.ts.map +1 -1
  71. package/dist/types/specialist/loader.d.ts +2 -1
  72. package/dist/types/specialist/loader.d.ts.map +1 -1
  73. package/dist/types/specialist/mandatory-rules.d.ts.map +1 -1
  74. package/dist/types/specialist/manifest-resolver.d.ts +55 -0
  75. package/dist/types/specialist/manifest-resolver.d.ts.map +1 -0
  76. package/dist/types/specialist/node-contract.d.ts +2 -2
  77. package/dist/types/specialist/observability-sqlite.d.ts +43 -0
  78. package/dist/types/specialist/observability-sqlite.d.ts.map +1 -1
  79. package/dist/types/specialist/payload-measure.d.ts +19 -0
  80. package/dist/types/specialist/payload-measure.d.ts.map +1 -0
  81. package/dist/types/specialist/porcelain-parser.d.ts +2 -0
  82. package/dist/types/specialist/porcelain-parser.d.ts.map +1 -0
  83. package/dist/types/specialist/resolution-diagnostics.d.ts +36 -0
  84. package/dist/types/specialist/resolution-diagnostics.d.ts.map +1 -0
  85. package/dist/types/specialist/runner.d.ts +8 -0
  86. package/dist/types/specialist/runner.d.ts.map +1 -1
  87. package/dist/types/specialist/schema.d.ts +27 -0
  88. package/dist/types/specialist/schema.d.ts.map +1 -1
  89. package/dist/types/specialist/script-runner.d.ts +44 -1
  90. package/dist/types/specialist/script-runner.d.ts.map +1 -1
  91. package/dist/types/specialist/supervisor.d.ts +4 -0
  92. package/dist/types/specialist/supervisor.d.ts.map +1 -1
  93. package/dist/types/specialist/timeline-events.d.ts +29 -1
  94. package/dist/types/specialist/timeline-events.d.ts.map +1 -1
  95. package/dist/types/specialist/timeline-query.d.ts.map +1 -1
  96. package/dist/types/specialist/tool-catalog.d.ts +126 -0
  97. package/dist/types/specialist/tool-catalog.d.ts.map +1 -0
  98. package/dist/types/tools/specialist/feed_specialist.tool.d.ts +2 -2
  99. package/dist/types/tools/specialist/use_specialist.tool.d.ts.map +1 -1
  100. package/package.json +1 -1
package/dist/lib.js CHANGED
@@ -6906,7 +6906,8 @@ var require_public_api = __commonJS((exports) => {
6906
6906
 
6907
6907
  // src/specialist/script-runner.ts
6908
6908
  import { spawn } from "node:child_process";
6909
- import { randomUUID } from "node:crypto";
6909
+ import { createHash, randomUUID } from "node:crypto";
6910
+ import { readFileSync as readFileSync2 } from "node:fs";
6910
6911
 
6911
6912
  // src/specialist/templateEngine.ts
6912
6913
  function renderTemplate(template, variables) {
@@ -7044,7 +7045,8 @@ function migrateToV2(db) {
7044
7045
  status_json TEXT NOT NULL,
7045
7046
  bead_id TEXT,
7046
7047
  updated_at_ms INTEGER NOT NULL,
7047
- last_output TEXT
7048
+ last_output TEXT,
7049
+ startup_payload_json TEXT
7048
7050
  );
7049
7051
  INSERT OR IGNORE INTO specialist_jobs_v2
7050
7052
  SELECT
@@ -7054,7 +7056,8 @@ function migrateToV2(db) {
7054
7056
  status_json,
7055
7057
  JSON_EXTRACT(status_json, '$.bead_id'),
7056
7058
  updated_at_ms,
7057
- last_output
7059
+ last_output,
7060
+ startup_payload_json
7058
7061
  FROM specialist_jobs;
7059
7062
  DROP TABLE IF EXISTS specialist_jobs;
7060
7063
  ALTER TABLE specialist_jobs_v2 RENAME TO specialist_jobs;
@@ -7081,7 +7084,8 @@ function migrateToV3(db) {
7081
7084
  status TEXT NOT NULL,
7082
7085
  status_json TEXT NOT NULL,
7083
7086
  updated_at_ms INTEGER NOT NULL,
7084
- last_output TEXT
7087
+ last_output TEXT,
7088
+ startup_payload_json TEXT
7085
7089
  );
7086
7090
  INSERT OR IGNORE INTO specialist_jobs_v3
7087
7091
  SELECT
@@ -7093,7 +7097,8 @@ function migrateToV3(db) {
7093
7097
  COALESCE(JSON_EXTRACT(status_json, '$.status'), 'starting'),
7094
7098
  status_json,
7095
7099
  updated_at_ms,
7096
- last_output
7100
+ last_output,
7101
+ startup_payload_json
7097
7102
  FROM specialist_jobs;
7098
7103
  DROP TABLE IF EXISTS specialist_jobs;
7099
7104
  ALTER TABLE specialist_jobs_v3 RENAME TO specialist_jobs;
@@ -7108,6 +7113,15 @@ function migrateToV3(db) {
7108
7113
  function migrateToV11(db) {
7109
7114
  const hasV11 = db.query("SELECT 1 FROM schema_version WHERE version = 11 LIMIT 1").get();
7110
7115
  if (hasV11) {
7116
+ const metricsColumns = new Set(db.query("PRAGMA table_info(specialist_job_metrics)").all().map((column) => column.name).filter((name) => typeof name === "string" && name.length > 0));
7117
+ for (const column of [
7118
+ { name: "active_runtime_ms", definition: "INTEGER" },
7119
+ { name: "waiting_ms", definition: "INTEGER" }
7120
+ ]) {
7121
+ if (!metricsColumns.has(column.name)) {
7122
+ db.run(`ALTER TABLE specialist_job_metrics ADD COLUMN ${column.name} ${column.definition}`);
7123
+ }
7124
+ }
7111
7125
  db.run("CREATE INDEX IF NOT EXISTS idx_job_metrics_spec_model_updated ON specialist_job_metrics(specialist, model, updated_at_ms DESC)");
7112
7126
  db.run("CREATE INDEX IF NOT EXISTS idx_job_metrics_updated ON specialist_job_metrics(updated_at_ms DESC)");
7113
7127
  return;
@@ -7126,6 +7140,8 @@ function migrateToV11(db) {
7126
7140
  started_at_ms INTEGER,
7127
7141
  completed_at_ms INTEGER,
7128
7142
  elapsed_ms INTEGER,
7143
+ active_runtime_ms INTEGER,
7144
+ waiting_ms INTEGER,
7129
7145
  total_turns INTEGER NOT NULL DEFAULT 0,
7130
7146
  total_tools INTEGER NOT NULL DEFAULT 0,
7131
7147
  tool_call_counts_json TEXT NOT NULL,
@@ -7294,7 +7310,8 @@ function initSchema(db) {
7294
7310
  { name: "chain_root_bead_id", definition: "TEXT" },
7295
7311
  { name: "epic_id", definition: "TEXT" },
7296
7312
  { name: "status", definition: "TEXT NOT NULL DEFAULT 'starting'" },
7297
- { name: "last_output", definition: "TEXT" }
7313
+ { name: "last_output", definition: "TEXT" },
7314
+ { name: "startup_payload_json", definition: "TEXT" }
7298
7315
  ].filter(({ name }) => !specialistJobsColumns.has(name));
7299
7316
  for (const missingColumn of missingSpecialistJobsColumns) {
7300
7317
  db.run(`ALTER TABLE specialist_jobs ADD COLUMN ${missingColumn.name} ${missingColumn.definition}`);
@@ -7316,7 +7333,8 @@ function initSchema(db) {
7316
7333
  status TEXT NOT NULL,
7317
7334
  status_json TEXT NOT NULL,
7318
7335
  updated_at_ms INTEGER NOT NULL,
7319
- last_output TEXT
7336
+ last_output TEXT,
7337
+ startup_payload_json TEXT
7320
7338
  );
7321
7339
  INSERT OR IGNORE INTO specialist_jobs_new
7322
7340
  SELECT
@@ -7333,7 +7351,8 @@ function initSchema(db) {
7333
7351
  COALESCE(status, JSON_EXTRACT(status_json, '$.status'), 'starting'),
7334
7352
  status_json,
7335
7353
  updated_at_ms,
7336
- last_output
7354
+ last_output,
7355
+ startup_payload_json
7337
7356
  FROM specialist_jobs;
7338
7357
  DROP TABLE IF EXISTS specialist_jobs;
7339
7358
  ALTER TABLE specialist_jobs_new RENAME TO specialist_jobs;
@@ -7456,6 +7475,7 @@ function migrateToV8(db) {
7456
7475
  }
7457
7476
  db.run("CREATE INDEX IF NOT EXISTS idx_jobs_chain ON specialist_jobs(chain_id) WHERE chain_id IS NOT NULL");
7458
7477
  db.run("CREATE INDEX IF NOT EXISTS idx_jobs_epic ON specialist_jobs(epic_id) WHERE epic_id IS NOT NULL");
7478
+ db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_active_bead_specialist ON specialist_jobs(bead_id, specialist) WHERE bead_id IS NOT NULL AND status IN ('starting', 'running')");
7459
7479
  db.run(`
7460
7480
  CREATE TABLE IF NOT EXISTS epic_runs (
7461
7481
  epic_id TEXT PRIMARY KEY,
@@ -7552,6 +7572,37 @@ function migrateToV10(db) {
7552
7572
  VALUES (10, strftime('%s', 'now') * 1000);
7553
7573
  `);
7554
7574
  }
7575
+ var STALE_CLAIM_AGE_MS = 60000;
7576
+ function defaultIsPidAlive(pid) {
7577
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
7578
+ return false;
7579
+ try {
7580
+ process.kill(pid, 0);
7581
+ return true;
7582
+ } catch {
7583
+ return false;
7584
+ }
7585
+ }
7586
+ function claimJobStartWithStore(store, status, event, options = {}) {
7587
+ const isPidAlive = options.isPidAlive ?? defaultIsPidAlive;
7588
+ const nowMs = options.nowMs ?? Date.now;
7589
+ const staleAgeMs = options.staleClaimAgeMs ?? STALE_CLAIM_AGE_MS;
7590
+ return withRetry(() => store.transaction(() => {
7591
+ const existing = store.findActiveJob(status.bead_id ?? null, status.specialist);
7592
+ if (existing?.job_id && existing.job_id !== status.id) {
7593
+ const updatedAtMs = existing.updated_at_ms ?? 0;
7594
+ const isStale = updatedAtMs > 0 && nowMs() - updatedAtMs > staleAgeMs && !isPidAlive(existing.pid);
7595
+ if (isStale && store.cancelStaleClaim) {
7596
+ store.cancelStaleClaim(existing.job_id);
7597
+ } else {
7598
+ return { ok: false, existingJobId: existing.job_id, existingStatus: existing.status ?? "starting" };
7599
+ }
7600
+ }
7601
+ store.writeStatusRow(status);
7602
+ store.writeEventRow(status.id, status.specialist, status.bead_id, event);
7603
+ return { ok: true };
7604
+ }), "claimJobStart");
7605
+ }
7555
7606
 
7556
7607
  class SqliteClient {
7557
7608
  db;
@@ -7566,8 +7617,8 @@ class SqliteClient {
7566
7617
  writeStatusRow(status, lastOutput) {
7567
7618
  const statusJson = JSON.stringify(status);
7568
7619
  this.db.run(`
7569
- INSERT INTO specialist_jobs (job_id, specialist, worktree_column, bead_id, node_id, chain_kind, chain_id, chain_root_job_id, chain_root_bead_id, epic_id, status, status_json, updated_at_ms, last_output)
7570
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
7620
+ INSERT INTO specialist_jobs (job_id, specialist, worktree_column, bead_id, node_id, chain_kind, chain_id, chain_root_job_id, chain_root_bead_id, epic_id, status, status_json, updated_at_ms, last_output, startup_payload_json)
7621
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
7571
7622
  ON CONFLICT(job_id) DO UPDATE SET
7572
7623
  specialist = excluded.specialist,
7573
7624
  worktree_column = excluded.worktree_column,
@@ -7581,7 +7632,8 @@ class SqliteClient {
7581
7632
  status = excluded.status,
7582
7633
  status_json = excluded.status_json,
7583
7634
  updated_at_ms = excluded.updated_at_ms,
7584
- last_output = COALESCE(excluded.last_output, specialist_jobs.last_output);
7635
+ last_output = COALESCE(excluded.last_output, specialist_jobs.last_output),
7636
+ startup_payload_json = COALESCE(excluded.startup_payload_json, specialist_jobs.startup_payload_json);
7585
7637
  `, [
7586
7638
  status.id,
7587
7639
  status.specialist,
@@ -7596,7 +7648,8 @@ class SqliteClient {
7596
7648
  status.status,
7597
7649
  statusJson,
7598
7650
  Date.now(),
7599
- lastOutput ?? null
7651
+ lastOutput ?? null,
7652
+ status.startup_payload_json ?? null
7600
7653
  ]);
7601
7654
  }
7602
7655
  writeEpicRunRow(epic) {
@@ -7642,6 +7695,36 @@ class SqliteClient {
7642
7695
  VALUES (?, ?, ?, ?, ?, ?, ?)
7643
7696
  `, [jobId, seq, specialist, beadId ?? null, event.t, event.type, eventJson]);
7644
7697
  }
7698
+ claimJobStart(status, event) {
7699
+ return claimJobStartWithStore({
7700
+ transaction: (callback) => this.db.transaction(callback)(),
7701
+ findActiveJob: (beadId, specialist) => this.db.query(`
7702
+ SELECT
7703
+ job_id,
7704
+ status,
7705
+ updated_at_ms,
7706
+ CAST(JSON_EXTRACT(status_json, '$.pid') AS INTEGER) AS pid
7707
+ FROM specialist_jobs
7708
+ WHERE bead_id = ?
7709
+ AND specialist = ?
7710
+ AND status IN ('starting', 'running')
7711
+ ORDER BY updated_at_ms DESC
7712
+ LIMIT 1
7713
+ `).get(beadId, specialist),
7714
+ writeStatusRow: (nextStatus) => this.writeStatusRow(nextStatus),
7715
+ writeEventRow: (jobId, specialist, beadId, nextEvent) => this.writeEventRow(jobId, specialist, beadId, nextEvent),
7716
+ cancelStaleClaim: (jobId) => {
7717
+ const nowMs = Date.now();
7718
+ this.db.run(`
7719
+ UPDATE specialist_jobs
7720
+ SET status = 'cancelled',
7721
+ status_json = JSON_PATCH(status_json, JSON_OBJECT('status', 'cancelled', 'cancelled_reason', 'orphan-claim-stale')),
7722
+ updated_at_ms = ?
7723
+ WHERE job_id = ?
7724
+ `, [nowMs, jobId]);
7725
+ }
7726
+ }, status, event);
7727
+ }
7645
7728
  writeResultRow(jobId, output) {
7646
7729
  this.db.run(`
7647
7730
  INSERT INTO specialist_results (job_id, output, updated_at_ms)
@@ -8102,6 +8185,15 @@ class SqliteClient {
8102
8185
  return statuses;
8103
8186
  }, "listStatuses");
8104
8187
  }
8188
+ removeJobs(jobIds) {
8189
+ return withRetry(() => {
8190
+ if (jobIds.length === 0)
8191
+ return 0;
8192
+ const placeholders = jobIds.map(() => "?").join(", ");
8193
+ const result = this.db.query(`DELETE FROM specialist_jobs WHERE job_id IN (${placeholders})`).run(...jobIds);
8194
+ return result.changes ?? 0;
8195
+ }, "removeJobs");
8196
+ }
8105
8197
  readEpicRun(epicId) {
8106
8198
  return withRetry(() => {
8107
8199
  const row = this.db.query("SELECT epic_id, status, status_json, updated_at_ms FROM epic_runs WHERE epic_id = ? LIMIT 1").get(epicId);
@@ -8131,7 +8223,6 @@ class SqliteClient {
8131
8223
  SELECT chain_id, epic_id, chain_root_bead_id, chain_root_job_id, updated_at_ms
8132
8224
  FROM epic_chain_membership
8133
8225
  WHERE epic_id = ?
8134
- AND (chain_root_job_id IS NULL OR chain_root_job_id != chain_id)
8135
8226
  ORDER BY updated_at_ms DESC
8136
8227
  `).all(epicId);
8137
8228
  }, "listEpicChains");
@@ -8149,6 +8240,16 @@ class SqliteClient {
8149
8240
  return removable;
8150
8241
  }, "deleteEpicChainMembership");
8151
8242
  }
8243
+ listReferencedChainRootJobIds() {
8244
+ return withRetry(() => {
8245
+ const rows = this.db.query(`
8246
+ SELECT DISTINCT chain_root_job_id
8247
+ FROM epic_chain_membership
8248
+ WHERE chain_root_job_id IS NOT NULL AND chain_root_job_id != ''
8249
+ `).all();
8250
+ return rows.map((row) => row.chain_root_job_id).filter((jobId) => typeof jobId === "string" && jobId.length > 0);
8251
+ }, "listReferencedChainRootJobIds");
8252
+ }
8152
8253
  listEpicChainsWithLatestJob(epicId) {
8153
8254
  return withRetry(() => {
8154
8255
  const rows = this.db.query(`
@@ -8226,6 +8327,18 @@ class SqliteClient {
8226
8327
  return rows.map((row) => row.job_id).filter((jobId) => typeof jobId === "string" && jobId.length > 0);
8227
8328
  }, "listChainJobIds");
8228
8329
  }
8330
+ listLiveJobsForBead(beadId) {
8331
+ return withRetry(() => {
8332
+ const rows = this.db.query(`
8333
+ SELECT job_id
8334
+ FROM specialist_jobs
8335
+ WHERE bead_id = ?
8336
+ AND status IN ('starting', 'running', 'waiting')
8337
+ ORDER BY updated_at_ms ASC
8338
+ `).all(beadId);
8339
+ return rows.map((row) => row.job_id).filter((jobId) => typeof jobId === "string" && jobId.length > 0);
8340
+ }, "listLiveJobsForBead");
8341
+ }
8229
8342
  resolveChainEpicLinkByJobId(jobId) {
8230
8343
  return withRetry(() => {
8231
8344
  const row = this.db.query(`
@@ -8305,7 +8418,7 @@ class SqliteClient {
8305
8418
  aggregateJobMetrics(jobId) {
8306
8419
  return withRetry(() => {
8307
8420
  const jobRow = this.db.query(`
8308
- SELECT job_id, specialist, status, chain_kind, chain_id, bead_id, node_id, epic_id, updated_at_ms
8421
+ SELECT job_id, specialist, status, chain_kind, chain_id, bead_id, node_id, epic_id, updated_at_ms, startup_payload_json
8309
8422
  FROM specialist_jobs
8310
8423
  WHERE job_id = ?
8311
8424
  `).get(jobId);
@@ -8323,9 +8436,22 @@ class SqliteClient {
8323
8436
  let runCompleteJson = null;
8324
8437
  let model = null;
8325
8438
  let elapsedMs = null;
8439
+ let activeRuntimeMs = 0;
8440
+ let waitingMs = 0;
8441
+ let phase = null;
8442
+ let phaseStartedAtMs = null;
8443
+ const closePhase = (endAtMs) => {
8444
+ if (phase === null || phaseStartedAtMs === null || endAtMs < phaseStartedAtMs)
8445
+ return;
8446
+ const durationMs = endAtMs - phaseStartedAtMs;
8447
+ if (phase === "running") {
8448
+ activeRuntimeMs += durationMs;
8449
+ } else {
8450
+ waitingMs += durationMs;
8451
+ }
8452
+ };
8326
8453
  for (const event of events) {
8327
8454
  startedAtMs = startedAtMs === null ? event.t : Math.min(startedAtMs, event.t);
8328
- completedAtMs = completedAtMs === null ? event.t : Math.max(completedAtMs, event.t);
8329
8455
  if (event.type === "tool") {
8330
8456
  totalTools += 1;
8331
8457
  toolCallCounts[event.tool] = (toolCallCounts[event.tool] ?? 0) + 1;
@@ -8343,16 +8469,45 @@ class SqliteClient {
8343
8469
  tokenTrajectory.push({ t: event.t, source: event.source, token_usage: event.token_usage });
8344
8470
  continue;
8345
8471
  }
8472
+ if (event.type === "run_start") {
8473
+ phase = "running";
8474
+ phaseStartedAtMs = event.t;
8475
+ continue;
8476
+ }
8477
+ if (event.type === "status_change") {
8478
+ if (event.status === "running" || event.status === "waiting") {
8479
+ closePhase(event.t);
8480
+ phase = event.status;
8481
+ phaseStartedAtMs = event.t;
8482
+ continue;
8483
+ }
8484
+ if (event.status === "done" || event.status === "error" || event.status === "cancelled") {
8485
+ closePhase(event.t);
8486
+ phase = null;
8487
+ phaseStartedAtMs = null;
8488
+ }
8489
+ continue;
8490
+ }
8346
8491
  if (event.type === "run_complete") {
8492
+ closePhase(event.t);
8493
+ completedAtMs = event.t;
8347
8494
  runCompleteJson = JSON.stringify(event);
8348
8495
  model = event.model ?? model;
8349
8496
  elapsedMs = Math.round(event.elapsed_s * 1000);
8497
+ phase = null;
8498
+ phaseStartedAtMs = null;
8350
8499
  continue;
8351
8500
  }
8352
8501
  if (event.type === "stale_warning" && event.reason === "tool_duration") {
8353
8502
  stallGaps.push({ t: event.t, tool: event.tool ?? null, silence_ms: event.silence_ms, threshold_ms: event.threshold_ms });
8354
8503
  }
8355
8504
  }
8505
+ if (startedAtMs !== null && completedAtMs === null) {
8506
+ completedAtMs = events.length > 0 ? events[events.length - 1].t : startedAtMs;
8507
+ }
8508
+ if (elapsedMs === null && startedAtMs !== null && completedAtMs !== null) {
8509
+ elapsedMs = Math.max(0, completedAtMs - startedAtMs);
8510
+ }
8356
8511
  const record = {
8357
8512
  job_id: jobRow.job_id,
8358
8513
  specialist: jobRow.specialist,
@@ -8366,6 +8521,8 @@ class SqliteClient {
8366
8521
  started_at_ms: startedAtMs,
8367
8522
  completed_at_ms: completedAtMs,
8368
8523
  elapsed_ms: elapsedMs,
8524
+ active_runtime_ms: activeRuntimeMs,
8525
+ waiting_ms: waitingMs,
8369
8526
  total_turns: totalTurns,
8370
8527
  total_tools: totalTools,
8371
8528
  tool_call_counts_json: stringifyJson(toolCallCounts),
@@ -8373,15 +8530,16 @@ class SqliteClient {
8373
8530
  context_trajectory_json: stringifyJson(contextTrajectory),
8374
8531
  stall_gaps_json: stringifyJson(stallGaps),
8375
8532
  run_complete_json: runCompleteJson,
8533
+ startup_payload_json: jobRow.startup_payload_json ?? null,
8376
8534
  updated_at_ms: jobRow.updated_at_ms
8377
8535
  };
8378
8536
  this.db.run(`
8379
8537
  INSERT INTO specialist_job_metrics (
8380
8538
  job_id, specialist, model, status, chain_kind, chain_id, bead_id, node_id, epic_id,
8381
- started_at_ms, completed_at_ms, elapsed_ms, total_turns, total_tools,
8539
+ started_at_ms, completed_at_ms, elapsed_ms, active_runtime_ms, waiting_ms, total_turns, total_tools,
8382
8540
  tool_call_counts_json, token_trajectory_json, context_trajectory_json, stall_gaps_json,
8383
8541
  run_complete_json, updated_at_ms
8384
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8542
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8385
8543
  ON CONFLICT(job_id) DO UPDATE SET
8386
8544
  specialist = excluded.specialist,
8387
8545
  model = excluded.model,
@@ -8394,6 +8552,8 @@ class SqliteClient {
8394
8552
  started_at_ms = excluded.started_at_ms,
8395
8553
  completed_at_ms = excluded.completed_at_ms,
8396
8554
  elapsed_ms = excluded.elapsed_ms,
8555
+ active_runtime_ms = excluded.active_runtime_ms,
8556
+ waiting_ms = excluded.waiting_ms,
8397
8557
  total_turns = excluded.total_turns,
8398
8558
  total_tools = excluded.total_tools,
8399
8559
  tool_call_counts_json = excluded.tool_call_counts_json,
@@ -8415,6 +8575,8 @@ class SqliteClient {
8415
8575
  record.started_at_ms,
8416
8576
  record.completed_at_ms,
8417
8577
  record.elapsed_ms,
8578
+ record.active_runtime_ms,
8579
+ record.waiting_ms,
8418
8580
  record.total_turns,
8419
8581
  record.total_tools,
8420
8582
  record.tool_call_counts_json,
@@ -8447,6 +8609,29 @@ class SqliteClient {
8447
8609
  return this.db.query(`SELECT * FROM specialist_job_metrics ${where} ORDER BY updated_at_ms DESC, job_id DESC`).all(...params);
8448
8610
  }, "listJobMetrics");
8449
8611
  }
8612
+ listElapsedMsBySpecialist(sinceMs, limitPerSpecialist = 200) {
8613
+ return withRetry(() => {
8614
+ const rows = this.db.query(`
8615
+ WITH ranked AS (
8616
+ SELECT specialist, elapsed_ms,
8617
+ ROW_NUMBER() OVER (PARTITION BY specialist ORDER BY updated_at_ms DESC) AS rn
8618
+ FROM specialist_job_metrics
8619
+ WHERE status = 'completed' AND updated_at_ms >= ? AND elapsed_ms IS NOT NULL
8620
+ )
8621
+ SELECT specialist, elapsed_ms
8622
+ FROM ranked
8623
+ WHERE rn <= ?
8624
+ ORDER BY specialist, rn
8625
+ `).all(sinceMs, limitPerSpecialist);
8626
+ const bySpecialist = {};
8627
+ for (const row of rows) {
8628
+ if (!row.specialist || typeof row.elapsed_ms !== "number" || !Number.isFinite(row.elapsed_ms))
8629
+ continue;
8630
+ (bySpecialist[row.specialist] ??= []).push(row.elapsed_ms);
8631
+ }
8632
+ return bySpecialist;
8633
+ }, "listElapsedMsBySpecialist");
8634
+ }
8450
8635
  readResult(jobId) {
8451
8636
  return withRetry(() => {
8452
8637
  const row = this.db.query("SELECT output FROM specialist_results WHERE job_id = ? LIMIT 1").get(jobId);
@@ -8847,27 +9032,72 @@ function createObservabilitySqliteClient(cwd = process.cwd()) {
8847
9032
  }
8848
9033
 
8849
9034
  // src/specialist/script-runner.ts
8850
- function hasUnsubstitutedVariables(template) {
8851
- const match = template.match(/\$([a-zA-Z_][a-zA-Z0-9_]*)/);
8852
- return match?.[1] ?? null;
9035
+ class CompatGuardError extends Error {
9036
+ field;
9037
+ constructor(field, message) {
9038
+ super(message);
9039
+ this.field = field;
9040
+ this.name = "CompatGuardError";
9041
+ }
9042
+ }
9043
+ function hasUnsubstitutedVariables(template, variables) {
9044
+ const matches = template.match(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
9045
+ for (const match of matches) {
9046
+ const key = match.slice(1);
9047
+ if (variables[key] === undefined)
9048
+ return key;
9049
+ }
9050
+ return null;
8853
9051
  }
8854
- function compatGuard(spec) {
9052
+ function compatGuard(spec, trust) {
8855
9053
  const execution = spec.specialist.execution;
8856
9054
  if (execution.interactive)
8857
- throw new Error("interactive specialists are not allowed");
9055
+ throw new CompatGuardError("execution.interactive", "interactive specialists are not allowed");
8858
9056
  if (execution.requires_worktree)
8859
- throw new Error("worktree specialists are not allowed");
9057
+ throw new CompatGuardError("execution.requires_worktree", "worktree specialists are not allowed");
8860
9058
  if (execution.permission_required !== "READ_ONLY")
8861
- throw new Error("permission_required must be READ_ONLY");
8862
- if ((spec.specialist.skills?.scripts?.length ?? 0) > 0)
8863
- throw new Error("scripts not allowed");
9059
+ throw new CompatGuardError("execution.permission_required", "permission_required must be READ_ONLY");
9060
+ const hasScripts = (spec.specialist.skills?.scripts?.length ?? 0) > 0;
9061
+ if (hasScripts && !trust?.allowLocalScripts) {
9062
+ throw new CompatGuardError("skills.scripts", "scripts not allowed (enable with --allow-local-scripts)");
9063
+ }
9064
+ const hasPaths = (spec.specialist.skills?.paths?.length ?? 0) > 0;
9065
+ const hasSkillInherit = Boolean(spec.specialist.prompt.skill_inherit);
9066
+ if (hasPaths && !trust?.allowSkills) {
9067
+ throw new CompatGuardError("skills.paths", "skills not allowed (enable with --allow-skills)");
9068
+ }
9069
+ if (hasSkillInherit && !trust?.allowSkills) {
9070
+ throw new CompatGuardError("prompt.skill_inherit", "skills not allowed (enable with --allow-skills)");
9071
+ }
9072
+ if (hasPaths && trust?.allowSkills && trust.allowSkillsRoots && trust.allowSkillsRoots.length > 0) {
9073
+ const paths = spec.specialist.skills?.paths ?? [];
9074
+ for (const path of paths) {
9075
+ const allowed = trust.allowSkillsRoots.some((root) => path.startsWith(root));
9076
+ if (!allowed) {
9077
+ throw new CompatGuardError("skills.paths", `skill path '${path}' not under any --allow-skills-roots entry`);
9078
+ }
9079
+ }
9080
+ }
9081
+ }
9082
+ function computeSkillSources(spec) {
9083
+ const paths = spec.specialist.skills?.paths ?? [];
9084
+ const sources = [];
9085
+ for (const path of paths) {
9086
+ try {
9087
+ const content = readFileSync2(path);
9088
+ const sha256 = createHash("sha256").update(content).digest("hex");
9089
+ sources.push({ path, sha256 });
9090
+ } catch {
9091
+ sources.push({ path, sha256: "unreadable" });
9092
+ }
9093
+ }
9094
+ return sources;
8864
9095
  }
8865
9096
  function renderTaskTemplate(template, variables) {
8866
- const output = renderTemplate(template, variables);
8867
- const missing = hasUnsubstitutedVariables(output);
9097
+ const missing = hasUnsubstitutedVariables(template, variables);
8868
9098
  if (missing)
8869
9099
  throw new Error(`Missing template variable: ${missing}`);
8870
- return output;
9100
+ return renderTemplate(template, variables);
8871
9101
  }
8872
9102
  function mapErrorType(message) {
8873
9103
  if (message.includes("Specialist not found"))
@@ -8897,59 +9127,32 @@ function textFromMessage(message) {
8897
9127
  return "";
8898
9128
  return message.content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("");
8899
9129
  }
8900
- function extractAssistantText(lines) {
8901
- for (let i = lines.length - 1;i >= 0; i--) {
8902
- const line = lines[i].trim();
8903
- if (!line)
8904
- continue;
8905
- let event;
8906
- try {
8907
- event = JSON.parse(line);
8908
- } catch {
8909
- continue;
8910
- }
8911
- if (event.type === "message_end") {
8912
- const text = textFromMessage(event.message);
9130
+ function extractAssistantTextFromEvent(event) {
9131
+ if (event.type === "message_end") {
9132
+ const text = textFromMessage(event.message);
9133
+ if (text)
9134
+ return text;
9135
+ }
9136
+ if (event.type === "agent_end" && Array.isArray(event.messages)) {
9137
+ for (let j = event.messages.length - 1;j >= 0; j--) {
9138
+ const text = textFromMessage(event.messages[j]);
8913
9139
  if (text)
8914
9140
  return text;
8915
9141
  }
8916
- if (event.type === "agent_end" && Array.isArray(event.messages)) {
8917
- for (let j = event.messages.length - 1;j >= 0; j--) {
8918
- const text = textFromMessage(event.messages[j]);
8919
- if (text)
8920
- return text;
8921
- }
8922
- }
8923
- if (event.type === "assistant" && typeof event.data?.text === "string")
8924
- return event.data.text;
8925
- const legacyContent = event.data?.content?.[0]?.text;
8926
- if (typeof legacyContent === "string")
8927
- return legacyContent;
8928
9142
  }
8929
- return "";
9143
+ if (event.type === "assistant" && typeof event.data?.text === "string")
9144
+ return event.data.text;
9145
+ const legacyContent = event.data?.content?.[0]?.text;
9146
+ if (typeof legacyContent === "string")
9147
+ return legacyContent;
9148
+ return;
8930
9149
  }
8931
9150
  function stripMarkdownFences(text) {
8932
9151
  const trimmed = text.trim();
8933
9152
  const fenced = trimmed.match(/^```(?:json|JSON)?\s*\n?([\s\S]*?)\n?```\s*$/);
8934
9153
  return fenced ? fenced[1].trim() : trimmed;
8935
9154
  }
8936
- function extractPiErrorMessage(lines) {
8937
- for (let i = lines.length - 1;i >= 0; i--) {
8938
- const line = lines[i].trim();
8939
- if (!line)
8940
- continue;
8941
- try {
8942
- const event = JSON.parse(line);
8943
- const errMsg = event.message?.errorMessage;
8944
- if (typeof errMsg === "string" && errMsg.length > 0)
8945
- return errMsg;
8946
- } catch {
8947
- continue;
8948
- }
8949
- }
8950
- return null;
8951
- }
8952
- function writeTraceRow(client, specialist, model, traceId, output, durationMs) {
9155
+ function writeTraceRow(client, specialist, model, traceId, output, durationMs, skillSources, onAuditFailure) {
8953
9156
  if (!client)
8954
9157
  return;
8955
9158
  const status = {
@@ -8960,106 +9163,231 @@ function writeTraceRow(client, specialist, model, traceId, output, durationMs) {
8960
9163
  started_at_ms: Date.now() - durationMs,
8961
9164
  elapsed_s: durationMs / 1000,
8962
9165
  last_event_at_ms: Date.now(),
8963
- surface: "script_specialist"
9166
+ surface: "script_specialist",
9167
+ ...skillSources && skillSources.length > 0 ? { skill_sources: skillSources } : {}
8964
9168
  };
8965
- client.upsertStatus(status);
8966
- client.upsertResult(traceId, output);
9169
+ try {
9170
+ client.upsertStatus(status);
9171
+ client.upsertResult(traceId, output);
9172
+ } catch (error) {
9173
+ onAuditFailure?.(error);
9174
+ }
9175
+ }
9176
+ var DEFAULT_PENDING_LINE_LIMIT_BYTES = 16 * 1024 * 1024;
9177
+ var DEFAULT_ASSISTANT_TEXT_LIMIT_BYTES = 4 * 1024 * 1024;
9178
+ var DEFAULT_STDERR_LIMIT_BYTES = 1 * 1024 * 1024;
9179
+ function resolveAssistantTextLimitBytes(spec) {
9180
+ return spec.specialist.execution.stdout_limit_bytes ?? resolveEnvAssistantTextLimitBytes() ?? DEFAULT_ASSISTANT_TEXT_LIMIT_BYTES;
9181
+ }
9182
+ function resolveEnvAssistantTextLimitBytes() {
9183
+ const raw = process.env.SPECIALISTS_SCRIPT_STDOUT_LIMIT_BYTES;
9184
+ if (raw === undefined)
9185
+ return;
9186
+ const envLimit = Number(raw);
9187
+ if (!Number.isFinite(envLimit) || envLimit <= 0)
9188
+ return;
9189
+ process.stderr.write(`warning: SPECIALISTS_SCRIPT_STDOUT_LIMIT_BYTES is deprecated; applies to assistant text cap
9190
+ `);
9191
+ return Math.floor(envLimit);
8967
9192
  }
8968
9193
  function openObservabilityClient(options) {
8969
9194
  const dbPath = options.observabilityDbPath ?? options.projectDir;
8970
9195
  return createObservabilitySqliteClient(dbPath);
8971
9196
  }
9197
+ function resolveScriptSpecialistName(name) {
9198
+ if (name === "changelog-keeper")
9199
+ return "changelog-drafter";
9200
+ return name;
9201
+ }
8972
9202
  async function runScriptSpecialist(input, options) {
8973
9203
  const traceId = randomUUID();
8974
9204
  const startedAt = Date.now();
8975
9205
  try {
8976
- const spec = await options.loader.get(input.specialist);
8977
- compatGuard(spec);
9206
+ const resolvedSpecialist = resolveScriptSpecialistName(input.specialist);
9207
+ const spec = await options.loader.get(resolvedSpecialist);
9208
+ compatGuard(spec, options.trust);
9209
+ const skillSources = options.trust?.allowSkills ? computeSkillSources(spec) : undefined;
8978
9210
  const template = input.template ?? spec.specialist.prompt.task_template;
8979
9211
  const prompt = renderTaskTemplate(template, input.variables ?? {});
8980
- const model = input.model_override ?? spec.specialist.execution.model ?? options.fallbackModel ?? "unknown";
9212
+ if (process.env.SPECIALISTS_SCRIPT_STUB_OUTPUT) {
9213
+ return {
9214
+ success: true,
9215
+ output: prompt,
9216
+ meta: {
9217
+ specialist: resolvedSpecialist,
9218
+ requested_specialist: input.requested_specialist ?? input.specialist,
9219
+ resolved_specialist: resolvedSpecialist,
9220
+ model: "stub",
9221
+ duration_ms: Date.now() - startedAt,
9222
+ trace_id: traceId
9223
+ }
9224
+ };
9225
+ }
8981
9226
  const timeoutMs = input.timeout_ms ?? spec.specialist.execution.timeout_ms ?? 120000;
9227
+ const modelCandidates = collectModelCandidates(input, spec, options);
9228
+ const assistantTextLimitBytes = resolveAssistantTextLimitBytes(spec);
9229
+ const attempts = [];
9230
+ for (const model of modelCandidates) {
9231
+ const attempt = await runSingleAttempt(prompt, model, input.thinking_level ?? spec.specialist.execution.thinking_level, timeoutMs, assistantTextLimitBytes, options);
9232
+ attempts.push(attempt);
9233
+ const parsed = classifyAttempt(attempt);
9234
+ if (parsed.retryable)
9235
+ continue;
9236
+ const durationMs2 = Date.now() - startedAt;
9237
+ const observability2 = openObservabilityClient(options);
9238
+ if (input.trace !== false && observability2)
9239
+ writeTraceRow(observability2, resolvedSpecialist, model, traceId, parsed.text, durationMs2, skillSources, options.onAuditFailure);
9240
+ if (parsed.kind === "success") {
9241
+ let parsed_json;
9242
+ if (spec.specialist.execution.response_format === "json") {
9243
+ try {
9244
+ parsed_json = JSON.parse(stripMarkdownFences(parsed.text));
9245
+ const required = Array.isArray(spec.specialist.prompt.output_schema?.required) ? spec.specialist.prompt.output_schema.required.filter((value) => typeof value === "string") : [];
9246
+ for (const key of required) {
9247
+ if (parsed_json === null || typeof parsed_json !== "object" || !(key in parsed_json))
9248
+ throw new Error(`Missing required output field: ${key}`);
9249
+ }
9250
+ } catch (error) {
9251
+ return { success: false, error: error instanceof Error ? error.message : String(error), error_type: "invalid_json", meta: { specialist: resolvedSpecialist, requested_specialist: input.requested_specialist ?? input.specialist, resolved_specialist: resolvedSpecialist, model, duration_ms: durationMs2, trace_id: traceId } };
9252
+ }
9253
+ }
9254
+ return { success: true, output: parsed.text, parsed_json, meta: { specialist: resolvedSpecialist, requested_specialist: input.requested_specialist ?? input.specialist, resolved_specialist: resolvedSpecialist, model, duration_ms: durationMs2, trace_id: traceId } };
9255
+ }
9256
+ return { success: false, error: parsed.error, error_type: parsed.errorType, meta: { specialist: resolvedSpecialist, requested_specialist: input.requested_specialist ?? input.specialist, resolved_specialist: resolvedSpecialist, model, duration_ms: durationMs2, trace_id: traceId } };
9257
+ }
9258
+ const lastAttempt = attempts.at(-1);
9259
+ const durationMs = Date.now() - startedAt;
9260
+ const observability = openObservabilityClient(options);
9261
+ if (input.trace !== false && observability)
9262
+ writeTraceRow(observability, resolvedSpecialist, modelCandidates.at(-1) ?? "unknown", traceId, lastAttempt?.text ?? "", durationMs, skillSources, options.onAuditFailure);
9263
+ return { success: false, error: lastAttempt?.stderr || "pi produced no assistant text", error_type: "internal", meta: { specialist: resolvedSpecialist, requested_specialist: input.requested_specialist ?? input.specialist, resolved_specialist: resolvedSpecialist, model: modelCandidates.at(-1) ?? "unknown", duration_ms: durationMs, trace_id: traceId } };
9264
+ } catch (error) {
9265
+ const message = error instanceof Error ? error.message : String(error);
9266
+ const resolvedSpecialist = resolveScriptSpecialistName(input.specialist);
9267
+ return { success: false, error: message, error_type: mapErrorType(message), meta: { specialist: resolvedSpecialist, requested_specialist: input.requested_specialist ?? input.specialist, resolved_specialist: resolvedSpecialist, duration_ms: Date.now() - startedAt, trace_id: traceId } };
9268
+ }
9269
+ }
9270
+ function collectModelCandidates(input, spec, options) {
9271
+ const candidates = [input.model_override, spec.specialist.execution.model, spec.specialist.execution.fallback_model, options.fallbackModel].filter((value) => typeof value === "string" && value.length > 0);
9272
+ return [...new Set(candidates)];
9273
+ }
9274
+ function runSingleAttempt(prompt, model, thinkingLevel, timeoutMs, assistantTextLimitBytes, options) {
9275
+ return new Promise((resolve, reject) => {
8982
9276
  const args = ["--mode", "json", "--no-session", "--no-extensions", "--no-tools", "--model", model];
8983
- const thinkingLevel = input.thinking_level ?? spec.specialist.execution.thinking_level;
8984
9277
  if (thinkingLevel)
8985
9278
  args.push("--thinking", thinkingLevel);
8986
9279
  args.push(prompt);
8987
9280
  const pi = spawn("pi", args, { stdio: ["ignore", "pipe", "pipe"] });
8988
9281
  options.onChild?.(pi);
8989
- const chunks = [];
8990
9282
  let stderr = "";
8991
9283
  let timedOut = false;
8992
9284
  let outputTooLarge = false;
8993
- const stdoutLimit = 4 * 1024 * 1024;
8994
- let stdoutBytes = 0;
9285
+ let outputTooLargeReason;
9286
+ let pending = "";
9287
+ let assistantText = "";
9288
+ let pendingBytes = 0;
9289
+ let stderrBytes = 0;
8995
9290
  const timer = setTimeout(() => {
8996
9291
  timedOut = true;
8997
9292
  pi.kill("SIGTERM");
8998
9293
  setTimeout(() => pi.kill("SIGKILL"), 2000);
8999
9294
  }, timeoutMs);
9000
9295
  pi.stdout.on("data", (chunk) => {
9296
+ if (outputTooLarge)
9297
+ return;
9001
9298
  const buffer = Buffer.from(chunk);
9002
- chunks.push(buffer);
9003
- stdoutBytes += buffer.length;
9004
- if (stdoutBytes > stdoutLimit && !outputTooLarge) {
9299
+ pending += buffer.toString("utf-8");
9300
+ pendingBytes += buffer.length;
9301
+ if (pendingBytes > DEFAULT_PENDING_LINE_LIMIT_BYTES) {
9005
9302
  outputTooLarge = true;
9303
+ outputTooLargeReason = "malformed_line_too_large";
9006
9304
  pi.kill("SIGTERM");
9007
9305
  setTimeout(() => pi.kill("SIGKILL"), 2000);
9306
+ return;
9008
9307
  }
9009
- });
9010
- pi.stderr.on("data", (chunk) => {
9011
- stderr += String(chunk);
9012
- });
9013
- const exitCode = await new Promise((resolve, reject) => {
9014
- pi.on("error", reject);
9015
- pi.on("close", (code) => resolve(code ?? 0));
9016
- }).finally(() => clearTimeout(timer));
9017
- const stdout = Buffer.concat(chunks).toString("utf-8");
9018
- const text = extractAssistantText(stdout.split(/\r?\n/));
9019
- const durationMs = Date.now() - startedAt;
9020
- const observability = openObservabilityClient(options);
9021
- if (input.trace !== false && observability)
9022
- writeTraceRow(observability, input.specialist, model, traceId, text, durationMs);
9023
- if (outputTooLarge) {
9024
- return { success: false, error: "stdout exceeded 4MB cap", error_type: "output_too_large", meta: { specialist: input.specialist, model, duration_ms: durationMs, trace_id: traceId } };
9025
- }
9026
- if (timedOut) {
9027
- return { success: false, error: stderr || "timed out", error_type: "timeout", meta: { specialist: input.specialist, model, duration_ms: durationMs, trace_id: traceId } };
9028
- }
9029
- if (exitCode !== 0) {
9030
- return { success: false, error: stderr || `pi exit ${exitCode}`, error_type: mapErrorType(stderr), meta: { specialist: input.specialist, model, duration_ms: durationMs, trace_id: traceId } };
9031
- }
9032
- if (!text) {
9033
- const piError = extractPiErrorMessage(stdout.split(/\r?\n/));
9034
- if (piError) {
9035
- return { success: false, error: piError, error_type: mapErrorType(piError), meta: { specialist: input.specialist, model, duration_ms: durationMs, trace_id: traceId } };
9036
- }
9037
- return { success: false, error: "pi produced no assistant text", error_type: "internal", meta: { specialist: input.specialist, model, duration_ms: durationMs, trace_id: traceId } };
9038
- }
9039
- let parsed_json;
9040
- if (spec.specialist.execution.response_format === "json") {
9041
- try {
9042
- parsed_json = JSON.parse(stripMarkdownFences(text));
9043
- const required = Array.isArray(spec.specialist.prompt.output_schema?.required) ? spec.specialist.prompt.output_schema.required.filter((value) => typeof value === "string") : [];
9044
- for (const key of required) {
9045
- if (parsed_json === null || typeof parsed_json !== "object" || !(key in parsed_json)) {
9046
- throw new Error(`Missing required output field: ${key}`);
9308
+ const lines = pending.split(/\r?\n/);
9309
+ pending = lines.pop() ?? "";
9310
+ pendingBytes = Buffer.byteLength(pending);
9311
+ for (const rawLine of lines) {
9312
+ const line = rawLine.trim();
9313
+ if (!line)
9314
+ continue;
9315
+ try {
9316
+ const event = JSON.parse(line);
9317
+ const nextAssistantText = extractAssistantTextFromEvent(event);
9318
+ if (nextAssistantText !== undefined) {
9319
+ if (Buffer.byteLength(nextAssistantText, "utf8") > assistantTextLimitBytes) {
9320
+ outputTooLarge = true;
9321
+ outputTooLargeReason = "assistant_text_too_large";
9322
+ pi.kill("SIGTERM");
9323
+ setTimeout(() => pi.kill("SIGKILL"), 2000);
9324
+ return;
9325
+ }
9326
+ assistantText = nextAssistantText;
9047
9327
  }
9328
+ } catch {
9329
+ continue;
9048
9330
  }
9049
- } catch (error) {
9050
- return { success: false, error: error instanceof Error ? error.message : String(error), error_type: "invalid_json", meta: { specialist: input.specialist, model, duration_ms: durationMs, trace_id: traceId } };
9051
9331
  }
9052
- }
9053
- return { success: true, output: text, parsed_json, meta: { specialist: input.specialist, model, duration_ms: durationMs, trace_id: traceId } };
9054
- } catch (error) {
9055
- const message = error instanceof Error ? error.message : String(error);
9056
- return { success: false, error: message, error_type: mapErrorType(message), meta: { specialist: input.specialist, duration_ms: Date.now() - startedAt, trace_id: traceId } };
9057
- }
9332
+ });
9333
+ pi.stderr.on("data", (chunk) => {
9334
+ if (outputTooLarge)
9335
+ return;
9336
+ const text = String(chunk);
9337
+ stderr += text;
9338
+ stderrBytes += Buffer.byteLength(text, "utf8");
9339
+ if (stderrBytes > DEFAULT_STDERR_LIMIT_BYTES) {
9340
+ outputTooLarge = true;
9341
+ outputTooLargeReason = "stderr_too_large";
9342
+ stderr = stderr.slice(0, DEFAULT_STDERR_LIMIT_BYTES);
9343
+ pi.kill("SIGTERM");
9344
+ setTimeout(() => pi.kill("SIGKILL"), 2000);
9345
+ }
9346
+ });
9347
+ pi.on("error", reject);
9348
+ pi.on("close", (code) => {
9349
+ clearTimeout(timer);
9350
+ resolve({
9351
+ model,
9352
+ text: assistantText,
9353
+ stderr,
9354
+ exitCode: code ?? 0,
9355
+ timedOut,
9356
+ outputTooLarge,
9357
+ outputTooLargeReason
9358
+ });
9359
+ });
9360
+ });
9361
+ }
9362
+ function classifyAttempt(attempt) {
9363
+ if (attempt.outputTooLarge) {
9364
+ if (attempt.outputTooLargeReason === "assistant_text_too_large")
9365
+ return { retryable: false, kind: "failure", error: "assistant message too large", errorType: "output_too_large", text: attempt.text };
9366
+ if (attempt.outputTooLargeReason === "stderr_too_large")
9367
+ return { retryable: false, kind: "failure", error: "stderr too large", errorType: "output_too_large", text: attempt.text };
9368
+ if (attempt.outputTooLargeReason === "malformed_line_too_large")
9369
+ return { retryable: false, kind: "failure", error: "malformed line too large", errorType: "output_too_large", text: attempt.text };
9370
+ return { retryable: false, kind: "failure", error: "output exceeded cap", errorType: "output_too_large", text: attempt.text };
9371
+ }
9372
+ if (attempt.timedOut)
9373
+ return { retryable: false, kind: "failure", error: attempt.stderr || "timed out", errorType: "timeout", text: attempt.text };
9374
+ const retryable = isRetryableModelFailure(attempt.stderr, attempt.text);
9375
+ if (attempt.exitCode !== 0) {
9376
+ const errorType = mapErrorType(attempt.stderr);
9377
+ return { retryable, kind: "failure", error: attempt.stderr || `pi exit ${attempt.exitCode}`, errorType, text: attempt.text };
9378
+ }
9379
+ if (!attempt.text) {
9380
+ return { retryable, kind: "failure", error: attempt.stderr || "pi produced no assistant text", errorType: mapErrorType(attempt.stderr), text: attempt.text };
9381
+ }
9382
+ return { retryable: false, kind: "success", error: "", errorType: "internal", text: attempt.text };
9383
+ }
9384
+ function isRetryableModelFailure(stderr, text) {
9385
+ return stderr.includes("0 tokens") || stderr.includes("quota") || stderr.includes("rate limit") || stderr.includes("403") || stderr.includes("401") || stderr.includes("insufficient_quota") || !text && !stderr.trim();
9058
9386
  }
9059
9387
  // src/specialist/loader.ts
9060
9388
  import { readdir, readFile, stat } from "node:fs/promises";
9061
9389
  import { basename, join as join2 } from "node:path";
9062
- import { existsSync as existsSync2 } from "node:fs";
9390
+ import { existsSync as existsSync3 } from "node:fs";
9063
9391
 
9064
9392
  // node_modules/yaml/dist/index.js
9065
9393
  var composer = require_composer();
@@ -12937,6 +13265,7 @@ var ExecutionSchema = objectType({
12937
13265
  stall_timeout_ms: numberType().optional(),
12938
13266
  max_retries: numberType().int().min(0).default(0),
12939
13267
  interactive: booleanType().default(false),
13268
+ stdout_limit_bytes: numberType().int().positive().optional(),
12940
13269
  response_format: enumType(["text", "json", "markdown"]).default("text"),
12941
13270
  output_type: enumType(["codegen", "analysis", "review", "synthesis", "orchestration", "workflow", "research", "custom"]).default("custom"),
12942
13271
  permission_required: enumType(["READ_ONLY", "LOW", "MEDIUM", "HIGH"]).default("READ_ONLY"),
@@ -13087,6 +13416,20 @@ ${result.warnings.map((w) => ` ⚠ ${w}`).join(`
13087
13416
  return SpecialistSchema.parseAsync(raw);
13088
13417
  }
13089
13418
 
13419
+ // src/specialist/canonical-asset-resolver.ts
13420
+ import { existsSync as existsSync2 } from "node:fs";
13421
+ import { fileURLToPath } from "node:url";
13422
+ function resolveCanonicalAssetDir(relativePath) {
13423
+ const configPath = `config/${relativePath}`;
13424
+ let resolved = fileURLToPath(new URL(`../${configPath}`, import.meta.url));
13425
+ if (existsSync2(resolved))
13426
+ return resolved;
13427
+ resolved = fileURLToPath(new URL(`../../${configPath}`, import.meta.url));
13428
+ if (existsSync2(resolved))
13429
+ return resolved;
13430
+ return null;
13431
+ }
13432
+
13090
13433
  // src/specialist/loader.ts
13091
13434
  class SpecialistLoader {
13092
13435
  cache = new Map;
@@ -13101,11 +13444,12 @@ class SpecialistLoader {
13101
13444
  { path: join2(this.projectDir, ".specialists", "default"), scope: "default", source: "default-mirror" },
13102
13445
  { path: join2(this.projectDir, ".specialists", "default", "specialists"), scope: "default", source: "legacy" },
13103
13446
  { path: join2(this.projectDir, "config", "specialists"), scope: "package", source: "package-fallback" },
13447
+ { path: resolveCanonicalAssetDir("specialists") ?? "", scope: "package", source: "package-live" },
13104
13448
  { path: join2(this.projectDir, "specialists"), scope: "default", source: "legacy" },
13105
13449
  { path: join2(this.projectDir, ".claude", "specialists"), scope: "default", source: "legacy" },
13106
13450
  { path: join2(this.projectDir, ".agent-forge", "specialists"), scope: "default", source: "legacy" }
13107
13451
  ];
13108
- return dirs.filter((d) => existsSync2(d.path));
13452
+ return dirs.filter((d) => d.path && existsSync3(d.path));
13109
13453
  }
13110
13454
  toJson(content, isYaml) {
13111
13455
  if (!isYaml)
@@ -13114,11 +13458,11 @@ class SpecialistLoader {
13114
13458
  }
13115
13459
  resolveSpecialistPath(dirPath, specialistName) {
13116
13460
  const jsonPath = join2(dirPath, `${specialistName}.specialist.json`);
13117
- if (existsSync2(jsonPath)) {
13461
+ if (existsSync3(jsonPath)) {
13118
13462
  return { filePath: jsonPath, deprecatedYaml: false };
13119
13463
  }
13120
13464
  const yamlPath = join2(dirPath, `${specialistName}.specialist.yaml`);
13121
- if (existsSync2(yamlPath)) {
13465
+ if (existsSync3(yamlPath)) {
13122
13466
  return { filePath: yamlPath, deprecatedYaml: true };
13123
13467
  }
13124
13468
  return null;
@@ -13159,6 +13503,7 @@ class SpecialistLoader {
13159
13503
  thinking_level: spec.specialist.execution.thinking_level,
13160
13504
  skills: spec.specialist.skills?.paths ?? [],
13161
13505
  scripts: spec.specialist.skills?.scripts ?? [],
13506
+ mandatoryRuleTemplateSets: spec.specialist.mandatory_rules?.template_sets ?? [],
13162
13507
  scope: dir.scope,
13163
13508
  source: dir.source,
13164
13509
  filePath: resolved.filePath,