@jaggerxtrm/specialists 3.10.0 → 3.13.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 (104) hide show
  1. package/README.md +3 -0
  2. package/config/hooks/specialists-session-start.mjs +33 -1
  3. package/config/mandatory-rules/bead-id-verbatim.md +14 -0
  4. package/config/mandatory-rules/changelog-conventions.md +21 -0
  5. package/config/mandatory-rules/changelog-keeper-scope.md +50 -0
  6. package/config/mandatory-rules/gitnexus-required.md +6 -1
  7. package/config/mandatory-rules/per-turn-handoff-schema.md +16 -0
  8. package/config/mandatory-rules/sync-docs-scope-discipline.md +40 -0
  9. package/config/skills/releasing/SKILL.md +82 -0
  10. package/config/skills/specialists-creator/SKILL.md +100 -10
  11. package/config/skills/specialists-creator/scripts/validate-specialist.ts +1 -1
  12. package/config/skills/update-specialists/SKILL.md +192 -325
  13. package/config/skills/using-kpi/SKILL.md +236 -0
  14. package/config/skills/using-script-specialists/SKILL.md +208 -0
  15. package/config/skills/using-specialists-v2/SKILL.md +162 -28
  16. package/config/skills/using-specialists-v3/SKILL.md +562 -0
  17. package/config/skills/using-specialists-v3/evals/evals.json +89 -0
  18. package/config/specialists/changelog-drafter.specialist.json +62 -0
  19. package/config/specialists/changelog-keeper.specialist.json +80 -0
  20. package/config/specialists/code-sanity.specialist.json +108 -0
  21. package/config/specialists/debugger.specialist.json +7 -5
  22. package/config/specialists/executor.specialist.json +7 -5
  23. package/config/specialists/explorer.specialist.json +16 -5
  24. package/config/specialists/memory-processor.specialist.json +4 -4
  25. package/config/specialists/node-coordinator.specialist.json +3 -3
  26. package/config/specialists/overthinker.specialist.json +5 -4
  27. package/config/specialists/planner.specialist.json +7 -5
  28. package/config/specialists/researcher.specialist.json +5 -4
  29. package/config/specialists/reviewer.specialist.json +7 -5
  30. package/config/specialists/security-auditor.specialist.json +111 -0
  31. package/config/specialists/specialists-creator.specialist.json +6 -5
  32. package/config/specialists/sync-docs.specialist.json +18 -19
  33. package/config/specialists/test-runner.specialist.json +5 -4
  34. package/config/specialists/xt-merge.specialist.json +4 -4
  35. package/dist/index.js +3379 -1168
  36. package/dist/lib.js +518 -154
  37. package/dist/types/cli/clean.d.ts.map +1 -1
  38. package/dist/types/cli/config.d.ts.map +1 -1
  39. package/dist/types/cli/db.d.ts.map +1 -1
  40. package/dist/types/cli/doctor.d.ts.map +1 -1
  41. package/dist/types/cli/feed.d.ts.map +1 -1
  42. package/dist/types/cli/help.d.ts.map +1 -1
  43. package/dist/types/cli/init.d.ts.map +1 -1
  44. package/dist/types/cli/list.d.ts +4 -0
  45. package/dist/types/cli/list.d.ts.map +1 -1
  46. package/dist/types/cli/merge.d.ts +4 -2
  47. package/dist/types/cli/merge.d.ts.map +1 -1
  48. package/dist/types/cli/node.d.ts.map +1 -1
  49. package/dist/types/cli/prune-stale-defaults.d.ts +2 -0
  50. package/dist/types/cli/prune-stale-defaults.d.ts.map +1 -0
  51. package/dist/types/cli/ps.d.ts.map +1 -1
  52. package/dist/types/cli/result.d.ts.map +1 -1
  53. package/dist/types/cli/run.d.ts.map +1 -1
  54. package/dist/types/cli/script.d.ts.map +1 -1
  55. package/dist/types/cli/serve-hot-reload.d.ts +13 -0
  56. package/dist/types/cli/serve-hot-reload.d.ts.map +1 -0
  57. package/dist/types/cli/serve.d.ts +28 -0
  58. package/dist/types/cli/serve.d.ts.map +1 -1
  59. package/dist/types/cli/status.d.ts.map +1 -1
  60. package/dist/types/cli/stop.d.ts.map +1 -1
  61. package/dist/types/cli/version-check.d.ts +20 -0
  62. package/dist/types/cli/version-check.d.ts.map +1 -0
  63. package/dist/types/index.d.ts +1 -1
  64. package/dist/types/pi/session.d.ts +10 -0
  65. package/dist/types/pi/session.d.ts.map +1 -1
  66. package/dist/types/specialist/canonical-asset-resolver.d.ts +6 -0
  67. package/dist/types/specialist/canonical-asset-resolver.d.ts.map +1 -0
  68. package/dist/types/specialist/drift-detector.d.ts +39 -0
  69. package/dist/types/specialist/drift-detector.d.ts.map +1 -0
  70. package/dist/types/specialist/epic-lifecycle.d.ts.map +1 -1
  71. package/dist/types/specialist/epic-readiness.d.ts.map +1 -1
  72. package/dist/types/specialist/epic-reconciler.d.ts.map +1 -1
  73. package/dist/types/specialist/loader.d.ts +2 -1
  74. package/dist/types/specialist/loader.d.ts.map +1 -1
  75. package/dist/types/specialist/mandatory-rules.d.ts +5 -0
  76. package/dist/types/specialist/mandatory-rules.d.ts.map +1 -1
  77. package/dist/types/specialist/manifest-resolver.d.ts +55 -0
  78. package/dist/types/specialist/manifest-resolver.d.ts.map +1 -0
  79. package/dist/types/specialist/node-contract.d.ts +2 -2
  80. package/dist/types/specialist/observability-sqlite.d.ts +43 -0
  81. package/dist/types/specialist/observability-sqlite.d.ts.map +1 -1
  82. package/dist/types/specialist/payload-measure.d.ts +19 -0
  83. package/dist/types/specialist/payload-measure.d.ts.map +1 -0
  84. package/dist/types/specialist/porcelain-parser.d.ts +2 -0
  85. package/dist/types/specialist/porcelain-parser.d.ts.map +1 -0
  86. package/dist/types/specialist/resolution-diagnostics.d.ts +36 -0
  87. package/dist/types/specialist/resolution-diagnostics.d.ts.map +1 -0
  88. package/dist/types/specialist/runner.d.ts +8 -0
  89. package/dist/types/specialist/runner.d.ts.map +1 -1
  90. package/dist/types/specialist/schema.d.ts +27 -0
  91. package/dist/types/specialist/schema.d.ts.map +1 -1
  92. package/dist/types/specialist/script-runner.d.ts +44 -1
  93. package/dist/types/specialist/script-runner.d.ts.map +1 -1
  94. package/dist/types/specialist/supervisor.d.ts +4 -0
  95. package/dist/types/specialist/supervisor.d.ts.map +1 -1
  96. package/dist/types/specialist/timeline-events.d.ts +29 -1
  97. package/dist/types/specialist/timeline-events.d.ts.map +1 -1
  98. package/dist/types/specialist/timeline-query.d.ts.map +1 -1
  99. package/dist/types/specialist/tool-catalog.d.ts +126 -0
  100. package/dist/types/specialist/tool-catalog.d.ts.map +1 -0
  101. package/dist/types/tools/specialist/feed_specialist.tool.d.ts +2 -2
  102. package/dist/types/tools/specialist/use_specialist.tool.d.ts.map +1 -1
  103. package/package.json +4 -4
  104. package/config/specialists/.serena/project.yml +0 -151
package/dist/lib.js CHANGED
@@ -555,6 +555,8 @@ var require_Alias = __commonJS((exports) => {
555
555
  });
556
556
  }
557
557
  resolve(doc, ctx) {
558
+ if (ctx?.maxAliasCount === 0)
559
+ throw new ReferenceError("Alias resolution is disabled");
558
560
  let nodes;
559
561
  if (ctx?.aliasResolveCache) {
560
562
  nodes = ctx.aliasResolveCache;
@@ -1334,6 +1336,7 @@ var require_stringify = __commonJS((exports) => {
1334
1336
  nullStr: "null",
1335
1337
  simpleKeys: false,
1336
1338
  singleQuote: null,
1339
+ trailingComma: false,
1337
1340
  trueStr: "true",
1338
1341
  verifyAliasOrder: true
1339
1342
  }, doc.schema.toStringOptions, options);
@@ -1603,18 +1606,18 @@ var require_merge = __commonJS((exports) => {
1603
1606
  };
1604
1607
  var isMergeKey = (ctx, key) => (merge.identify(key) || identity.isScalar(key) && (!key.type || key.type === Scalar.Scalar.PLAIN) && merge.identify(key.value)) && ctx?.doc.schema.tags.some((tag) => tag.tag === merge.tag && tag.default);
1605
1608
  function addMergeToJSMap(ctx, map, value) {
1606
- value = ctx && identity.isAlias(value) ? value.resolve(ctx.doc) : value;
1607
- if (identity.isSeq(value))
1608
- for (const it of value.items)
1609
+ const source = resolveAliasValue(ctx, value);
1610
+ if (identity.isSeq(source))
1611
+ for (const it of source.items)
1609
1612
  mergeValue(ctx, map, it);
1610
- else if (Array.isArray(value))
1611
- for (const it of value)
1613
+ else if (Array.isArray(source))
1614
+ for (const it of source)
1612
1615
  mergeValue(ctx, map, it);
1613
1616
  else
1614
- mergeValue(ctx, map, value);
1617
+ mergeValue(ctx, map, source);
1615
1618
  }
1616
1619
  function mergeValue(ctx, map, value) {
1617
- const source = ctx && identity.isAlias(value) ? value.resolve(ctx.doc) : value;
1620
+ const source = resolveAliasValue(ctx, value);
1618
1621
  if (!identity.isMap(source))
1619
1622
  throw new Error("Merge sources must be maps or map aliases");
1620
1623
  const srcMap = source.toJSON(null, ctx, Map);
@@ -1635,6 +1638,9 @@ var require_merge = __commonJS((exports) => {
1635
1638
  }
1636
1639
  return map;
1637
1640
  }
1641
+ function resolveAliasValue(ctx, value) {
1642
+ return ctx && identity.isAlias(value) ? value.resolve(ctx.doc, ctx) : value;
1643
+ }
1638
1644
  exports.addMergeToJSMap = addMergeToJSMap;
1639
1645
  exports.isMergeKey = isMergeKey;
1640
1646
  exports.merge = merge;
@@ -1842,13 +1848,20 @@ ${indent}${line}` : `
1842
1848
  if (comment)
1843
1849
  reqNewline = true;
1844
1850
  let str = stringify.stringify(item, itemCtx, () => comment = null);
1845
- if (i < items.length - 1)
1851
+ reqNewline || (reqNewline = lines.length > linesAtValue || str.includes(`
1852
+ `));
1853
+ if (i < items.length - 1) {
1846
1854
  str += ",";
1855
+ } else if (ctx.options.trailingComma) {
1856
+ if (ctx.options.lineWidth > 0) {
1857
+ reqNewline || (reqNewline = lines.reduce((sum, line) => sum + line.length + 2, 2) + (str.length + 2) > ctx.options.lineWidth);
1858
+ }
1859
+ if (reqNewline) {
1860
+ str += ",";
1861
+ }
1862
+ }
1847
1863
  if (comment)
1848
1864
  str += stringifyComment.lineComment(str, itemIndent, commentString(comment));
1849
- if (!reqNewline && (lines.length > linesAtValue || str.includes(`
1850
- `)))
1851
- reqNewline = true;
1852
1865
  lines.push(str);
1853
1866
  linesAtValue = lines.length;
1854
1867
  }
@@ -2203,7 +2216,7 @@ var require_stringifyNumber = __commonJS((exports) => {
2203
2216
  if (!isFinite(num))
2204
2217
  return isNaN(num) ? ".nan" : num < 0 ? "-.inf" : ".inf";
2205
2218
  let n = Object.is(value, -0) ? "-0" : JSON.stringify(value);
2206
- if (!format && minFractionDigits && (!tag || tag === "tag:yaml.org,2002:float") && /^\d/.test(n)) {
2219
+ if (!format && minFractionDigits && (!tag || tag === "tag:yaml.org,2002:float") && /^-?\d/.test(n) && !n.includes("e")) {
2207
2220
  let i = n.indexOf(".");
2208
2221
  if (i < 0) {
2209
2222
  i = n.length;
@@ -4428,7 +4441,7 @@ var require_resolve_flow_scalar = __commonJS((exports) => {
4428
4441
  while (next === " " || next === "\t")
4429
4442
  next = source[++i + 1];
4430
4443
  } else if (next === "x" || next === "u" || next === "U") {
4431
- const length = { x: 2, u: 4, U: 8 }[next];
4444
+ const length = next === "x" ? 2 : next === "u" ? 4 : 8;
4432
4445
  res += parseCharCode(source, i + 1, length, onError);
4433
4446
  i += length;
4434
4447
  } else {
@@ -4497,12 +4510,13 @@ var require_resolve_flow_scalar = __commonJS((exports) => {
4497
4510
  const cc = source.substr(offset, length);
4498
4511
  const ok = cc.length === length && /^[0-9a-fA-F]+$/.test(cc);
4499
4512
  const code = ok ? parseInt(cc, 16) : NaN;
4500
- if (isNaN(code)) {
4513
+ try {
4514
+ return String.fromCodePoint(code);
4515
+ } catch {
4501
4516
  const raw = source.substr(offset - 2, length + 2);
4502
4517
  onError(offset - 2, "BAD_DQ_ESCAPE", `Invalid escape sequence ${raw}`);
4503
4518
  return raw;
4504
4519
  }
4505
- return String.fromCodePoint(code);
4506
4520
  }
4507
4521
  exports.resolveFlowScalar = resolveFlowScalar;
4508
4522
  });
@@ -4643,17 +4657,22 @@ var require_compose_node = __commonJS((exports) => {
4643
4657
  case "block-map":
4644
4658
  case "block-seq":
4645
4659
  case "flow-collection":
4646
- node = composeCollection.composeCollection(CN, ctx, token, props, onError);
4647
- if (anchor)
4648
- node.anchor = anchor.source.substring(1);
4660
+ try {
4661
+ node = composeCollection.composeCollection(CN, ctx, token, props, onError);
4662
+ if (anchor)
4663
+ node.anchor = anchor.source.substring(1);
4664
+ } catch (error) {
4665
+ const message = error instanceof Error ? error.message : String(error);
4666
+ onError(token, "RESOURCE_EXHAUSTION", message);
4667
+ }
4649
4668
  break;
4650
4669
  default: {
4651
4670
  const message = token.type === "error" ? token.message : `Unsupported token (type: ${token.type})`;
4652
4671
  onError(token, "UNEXPECTED_TOKEN", message);
4653
- node = composeEmptyNode(ctx, token.offset, undefined, null, props, onError);
4654
4672
  isSrcToken = false;
4655
4673
  }
4656
4674
  }
4675
+ node ?? (node = composeEmptyNode(ctx, token.offset, undefined, null, props, onError));
4657
4676
  if (anchor && node.anchor === "")
4658
4677
  onError(anchor, "BAD_ALIAS", "Anchor cannot be an empty string");
4659
4678
  if (atKey && ctx.options.stringKeys && (!identity.isScalar(node) || typeof node.value !== "string" || node.tag && node.tag !== "tag:yaml.org,2002:str")) {
@@ -6906,7 +6925,8 @@ var require_public_api = __commonJS((exports) => {
6906
6925
 
6907
6926
  // src/specialist/script-runner.ts
6908
6927
  import { spawn } from "node:child_process";
6909
- import { randomUUID } from "node:crypto";
6928
+ import { createHash, randomUUID } from "node:crypto";
6929
+ import { readFileSync as readFileSync2 } from "node:fs";
6910
6930
 
6911
6931
  // src/specialist/templateEngine.ts
6912
6932
  function renderTemplate(template, variables) {
@@ -7044,7 +7064,8 @@ function migrateToV2(db) {
7044
7064
  status_json TEXT NOT NULL,
7045
7065
  bead_id TEXT,
7046
7066
  updated_at_ms INTEGER NOT NULL,
7047
- last_output TEXT
7067
+ last_output TEXT,
7068
+ startup_payload_json TEXT
7048
7069
  );
7049
7070
  INSERT OR IGNORE INTO specialist_jobs_v2
7050
7071
  SELECT
@@ -7054,7 +7075,8 @@ function migrateToV2(db) {
7054
7075
  status_json,
7055
7076
  JSON_EXTRACT(status_json, '$.bead_id'),
7056
7077
  updated_at_ms,
7057
- last_output
7078
+ last_output,
7079
+ startup_payload_json
7058
7080
  FROM specialist_jobs;
7059
7081
  DROP TABLE IF EXISTS specialist_jobs;
7060
7082
  ALTER TABLE specialist_jobs_v2 RENAME TO specialist_jobs;
@@ -7081,7 +7103,8 @@ function migrateToV3(db) {
7081
7103
  status TEXT NOT NULL,
7082
7104
  status_json TEXT NOT NULL,
7083
7105
  updated_at_ms INTEGER NOT NULL,
7084
- last_output TEXT
7106
+ last_output TEXT,
7107
+ startup_payload_json TEXT
7085
7108
  );
7086
7109
  INSERT OR IGNORE INTO specialist_jobs_v3
7087
7110
  SELECT
@@ -7093,7 +7116,8 @@ function migrateToV3(db) {
7093
7116
  COALESCE(JSON_EXTRACT(status_json, '$.status'), 'starting'),
7094
7117
  status_json,
7095
7118
  updated_at_ms,
7096
- last_output
7119
+ last_output,
7120
+ startup_payload_json
7097
7121
  FROM specialist_jobs;
7098
7122
  DROP TABLE IF EXISTS specialist_jobs;
7099
7123
  ALTER TABLE specialist_jobs_v3 RENAME TO specialist_jobs;
@@ -7108,6 +7132,15 @@ function migrateToV3(db) {
7108
7132
  function migrateToV11(db) {
7109
7133
  const hasV11 = db.query("SELECT 1 FROM schema_version WHERE version = 11 LIMIT 1").get();
7110
7134
  if (hasV11) {
7135
+ 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));
7136
+ for (const column of [
7137
+ { name: "active_runtime_ms", definition: "INTEGER" },
7138
+ { name: "waiting_ms", definition: "INTEGER" }
7139
+ ]) {
7140
+ if (!metricsColumns.has(column.name)) {
7141
+ db.run(`ALTER TABLE specialist_job_metrics ADD COLUMN ${column.name} ${column.definition}`);
7142
+ }
7143
+ }
7111
7144
  db.run("CREATE INDEX IF NOT EXISTS idx_job_metrics_spec_model_updated ON specialist_job_metrics(specialist, model, updated_at_ms DESC)");
7112
7145
  db.run("CREATE INDEX IF NOT EXISTS idx_job_metrics_updated ON specialist_job_metrics(updated_at_ms DESC)");
7113
7146
  return;
@@ -7126,6 +7159,8 @@ function migrateToV11(db) {
7126
7159
  started_at_ms INTEGER,
7127
7160
  completed_at_ms INTEGER,
7128
7161
  elapsed_ms INTEGER,
7162
+ active_runtime_ms INTEGER,
7163
+ waiting_ms INTEGER,
7129
7164
  total_turns INTEGER NOT NULL DEFAULT 0,
7130
7165
  total_tools INTEGER NOT NULL DEFAULT 0,
7131
7166
  tool_call_counts_json TEXT NOT NULL,
@@ -7294,7 +7329,8 @@ function initSchema(db) {
7294
7329
  { name: "chain_root_bead_id", definition: "TEXT" },
7295
7330
  { name: "epic_id", definition: "TEXT" },
7296
7331
  { name: "status", definition: "TEXT NOT NULL DEFAULT 'starting'" },
7297
- { name: "last_output", definition: "TEXT" }
7332
+ { name: "last_output", definition: "TEXT" },
7333
+ { name: "startup_payload_json", definition: "TEXT" }
7298
7334
  ].filter(({ name }) => !specialistJobsColumns.has(name));
7299
7335
  for (const missingColumn of missingSpecialistJobsColumns) {
7300
7336
  db.run(`ALTER TABLE specialist_jobs ADD COLUMN ${missingColumn.name} ${missingColumn.definition}`);
@@ -7316,7 +7352,8 @@ function initSchema(db) {
7316
7352
  status TEXT NOT NULL,
7317
7353
  status_json TEXT NOT NULL,
7318
7354
  updated_at_ms INTEGER NOT NULL,
7319
- last_output TEXT
7355
+ last_output TEXT,
7356
+ startup_payload_json TEXT
7320
7357
  );
7321
7358
  INSERT OR IGNORE INTO specialist_jobs_new
7322
7359
  SELECT
@@ -7333,7 +7370,8 @@ function initSchema(db) {
7333
7370
  COALESCE(status, JSON_EXTRACT(status_json, '$.status'), 'starting'),
7334
7371
  status_json,
7335
7372
  updated_at_ms,
7336
- last_output
7373
+ last_output,
7374
+ startup_payload_json
7337
7375
  FROM specialist_jobs;
7338
7376
  DROP TABLE IF EXISTS specialist_jobs;
7339
7377
  ALTER TABLE specialist_jobs_new RENAME TO specialist_jobs;
@@ -7456,6 +7494,7 @@ function migrateToV8(db) {
7456
7494
  }
7457
7495
  db.run("CREATE INDEX IF NOT EXISTS idx_jobs_chain ON specialist_jobs(chain_id) WHERE chain_id IS NOT NULL");
7458
7496
  db.run("CREATE INDEX IF NOT EXISTS idx_jobs_epic ON specialist_jobs(epic_id) WHERE epic_id IS NOT NULL");
7497
+ 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
7498
  db.run(`
7460
7499
  CREATE TABLE IF NOT EXISTS epic_runs (
7461
7500
  epic_id TEXT PRIMARY KEY,
@@ -7552,6 +7591,37 @@ function migrateToV10(db) {
7552
7591
  VALUES (10, strftime('%s', 'now') * 1000);
7553
7592
  `);
7554
7593
  }
7594
+ var STALE_CLAIM_AGE_MS = 60000;
7595
+ function defaultIsPidAlive(pid) {
7596
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
7597
+ return false;
7598
+ try {
7599
+ process.kill(pid, 0);
7600
+ return true;
7601
+ } catch {
7602
+ return false;
7603
+ }
7604
+ }
7605
+ function claimJobStartWithStore(store, status, event, options = {}) {
7606
+ const isPidAlive = options.isPidAlive ?? defaultIsPidAlive;
7607
+ const nowMs = options.nowMs ?? Date.now;
7608
+ const staleAgeMs = options.staleClaimAgeMs ?? STALE_CLAIM_AGE_MS;
7609
+ return withRetry(() => store.transaction(() => {
7610
+ const existing = store.findActiveJob(status.bead_id ?? null, status.specialist);
7611
+ if (existing?.job_id && existing.job_id !== status.id) {
7612
+ const updatedAtMs = existing.updated_at_ms ?? 0;
7613
+ const isStale = updatedAtMs > 0 && nowMs() - updatedAtMs > staleAgeMs && !isPidAlive(existing.pid);
7614
+ if (isStale && store.cancelStaleClaim) {
7615
+ store.cancelStaleClaim(existing.job_id);
7616
+ } else {
7617
+ return { ok: false, existingJobId: existing.job_id, existingStatus: existing.status ?? "starting" };
7618
+ }
7619
+ }
7620
+ store.writeStatusRow(status);
7621
+ store.writeEventRow(status.id, status.specialist, status.bead_id, event);
7622
+ return { ok: true };
7623
+ }), "claimJobStart");
7624
+ }
7555
7625
 
7556
7626
  class SqliteClient {
7557
7627
  db;
@@ -7566,8 +7636,8 @@ class SqliteClient {
7566
7636
  writeStatusRow(status, lastOutput) {
7567
7637
  const statusJson = JSON.stringify(status);
7568
7638
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
7639
+ 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)
7640
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
7571
7641
  ON CONFLICT(job_id) DO UPDATE SET
7572
7642
  specialist = excluded.specialist,
7573
7643
  worktree_column = excluded.worktree_column,
@@ -7581,7 +7651,8 @@ class SqliteClient {
7581
7651
  status = excluded.status,
7582
7652
  status_json = excluded.status_json,
7583
7653
  updated_at_ms = excluded.updated_at_ms,
7584
- last_output = COALESCE(excluded.last_output, specialist_jobs.last_output);
7654
+ last_output = COALESCE(excluded.last_output, specialist_jobs.last_output),
7655
+ startup_payload_json = COALESCE(excluded.startup_payload_json, specialist_jobs.startup_payload_json);
7585
7656
  `, [
7586
7657
  status.id,
7587
7658
  status.specialist,
@@ -7596,7 +7667,8 @@ class SqliteClient {
7596
7667
  status.status,
7597
7668
  statusJson,
7598
7669
  Date.now(),
7599
- lastOutput ?? null
7670
+ lastOutput ?? null,
7671
+ status.startup_payload_json ?? null
7600
7672
  ]);
7601
7673
  }
7602
7674
  writeEpicRunRow(epic) {
@@ -7642,6 +7714,36 @@ class SqliteClient {
7642
7714
  VALUES (?, ?, ?, ?, ?, ?, ?)
7643
7715
  `, [jobId, seq, specialist, beadId ?? null, event.t, event.type, eventJson]);
7644
7716
  }
7717
+ claimJobStart(status, event) {
7718
+ return claimJobStartWithStore({
7719
+ transaction: (callback) => this.db.transaction(callback)(),
7720
+ findActiveJob: (beadId, specialist) => this.db.query(`
7721
+ SELECT
7722
+ job_id,
7723
+ status,
7724
+ updated_at_ms,
7725
+ CAST(JSON_EXTRACT(status_json, '$.pid') AS INTEGER) AS pid
7726
+ FROM specialist_jobs
7727
+ WHERE bead_id = ?
7728
+ AND specialist = ?
7729
+ AND status IN ('starting', 'running')
7730
+ ORDER BY updated_at_ms DESC
7731
+ LIMIT 1
7732
+ `).get(beadId, specialist),
7733
+ writeStatusRow: (nextStatus) => this.writeStatusRow(nextStatus),
7734
+ writeEventRow: (jobId, specialist, beadId, nextEvent) => this.writeEventRow(jobId, specialist, beadId, nextEvent),
7735
+ cancelStaleClaim: (jobId) => {
7736
+ const nowMs = Date.now();
7737
+ this.db.run(`
7738
+ UPDATE specialist_jobs
7739
+ SET status = 'cancelled',
7740
+ status_json = JSON_PATCH(status_json, JSON_OBJECT('status', 'cancelled', 'cancelled_reason', 'orphan-claim-stale')),
7741
+ updated_at_ms = ?
7742
+ WHERE job_id = ?
7743
+ `, [nowMs, jobId]);
7744
+ }
7745
+ }, status, event);
7746
+ }
7645
7747
  writeResultRow(jobId, output) {
7646
7748
  this.db.run(`
7647
7749
  INSERT INTO specialist_results (job_id, output, updated_at_ms)
@@ -8102,6 +8204,15 @@ class SqliteClient {
8102
8204
  return statuses;
8103
8205
  }, "listStatuses");
8104
8206
  }
8207
+ removeJobs(jobIds) {
8208
+ return withRetry(() => {
8209
+ if (jobIds.length === 0)
8210
+ return 0;
8211
+ const placeholders = jobIds.map(() => "?").join(", ");
8212
+ const result = this.db.query(`DELETE FROM specialist_jobs WHERE job_id IN (${placeholders})`).run(...jobIds);
8213
+ return result.changes ?? 0;
8214
+ }, "removeJobs");
8215
+ }
8105
8216
  readEpicRun(epicId) {
8106
8217
  return withRetry(() => {
8107
8218
  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 +8242,6 @@ class SqliteClient {
8131
8242
  SELECT chain_id, epic_id, chain_root_bead_id, chain_root_job_id, updated_at_ms
8132
8243
  FROM epic_chain_membership
8133
8244
  WHERE epic_id = ?
8134
- AND (chain_root_job_id IS NULL OR chain_root_job_id != chain_id)
8135
8245
  ORDER BY updated_at_ms DESC
8136
8246
  `).all(epicId);
8137
8247
  }, "listEpicChains");
@@ -8149,6 +8259,16 @@ class SqliteClient {
8149
8259
  return removable;
8150
8260
  }, "deleteEpicChainMembership");
8151
8261
  }
8262
+ listReferencedChainRootJobIds() {
8263
+ return withRetry(() => {
8264
+ const rows = this.db.query(`
8265
+ SELECT DISTINCT chain_root_job_id
8266
+ FROM epic_chain_membership
8267
+ WHERE chain_root_job_id IS NOT NULL AND chain_root_job_id != ''
8268
+ `).all();
8269
+ return rows.map((row) => row.chain_root_job_id).filter((jobId) => typeof jobId === "string" && jobId.length > 0);
8270
+ }, "listReferencedChainRootJobIds");
8271
+ }
8152
8272
  listEpicChainsWithLatestJob(epicId) {
8153
8273
  return withRetry(() => {
8154
8274
  const rows = this.db.query(`
@@ -8226,6 +8346,18 @@ class SqliteClient {
8226
8346
  return rows.map((row) => row.job_id).filter((jobId) => typeof jobId === "string" && jobId.length > 0);
8227
8347
  }, "listChainJobIds");
8228
8348
  }
8349
+ listLiveJobsForBead(beadId) {
8350
+ return withRetry(() => {
8351
+ const rows = this.db.query(`
8352
+ SELECT job_id
8353
+ FROM specialist_jobs
8354
+ WHERE bead_id = ?
8355
+ AND status IN ('starting', 'running', 'waiting')
8356
+ ORDER BY updated_at_ms ASC
8357
+ `).all(beadId);
8358
+ return rows.map((row) => row.job_id).filter((jobId) => typeof jobId === "string" && jobId.length > 0);
8359
+ }, "listLiveJobsForBead");
8360
+ }
8229
8361
  resolveChainEpicLinkByJobId(jobId) {
8230
8362
  return withRetry(() => {
8231
8363
  const row = this.db.query(`
@@ -8305,7 +8437,7 @@ class SqliteClient {
8305
8437
  aggregateJobMetrics(jobId) {
8306
8438
  return withRetry(() => {
8307
8439
  const jobRow = this.db.query(`
8308
- SELECT job_id, specialist, status, chain_kind, chain_id, bead_id, node_id, epic_id, updated_at_ms
8440
+ SELECT job_id, specialist, status, chain_kind, chain_id, bead_id, node_id, epic_id, updated_at_ms, startup_payload_json
8309
8441
  FROM specialist_jobs
8310
8442
  WHERE job_id = ?
8311
8443
  `).get(jobId);
@@ -8323,9 +8455,22 @@ class SqliteClient {
8323
8455
  let runCompleteJson = null;
8324
8456
  let model = null;
8325
8457
  let elapsedMs = null;
8458
+ let activeRuntimeMs = 0;
8459
+ let waitingMs = 0;
8460
+ let phase = null;
8461
+ let phaseStartedAtMs = null;
8462
+ const closePhase = (endAtMs) => {
8463
+ if (phase === null || phaseStartedAtMs === null || endAtMs < phaseStartedAtMs)
8464
+ return;
8465
+ const durationMs = endAtMs - phaseStartedAtMs;
8466
+ if (phase === "running") {
8467
+ activeRuntimeMs += durationMs;
8468
+ } else {
8469
+ waitingMs += durationMs;
8470
+ }
8471
+ };
8326
8472
  for (const event of events) {
8327
8473
  startedAtMs = startedAtMs === null ? event.t : Math.min(startedAtMs, event.t);
8328
- completedAtMs = completedAtMs === null ? event.t : Math.max(completedAtMs, event.t);
8329
8474
  if (event.type === "tool") {
8330
8475
  totalTools += 1;
8331
8476
  toolCallCounts[event.tool] = (toolCallCounts[event.tool] ?? 0) + 1;
@@ -8343,16 +8488,45 @@ class SqliteClient {
8343
8488
  tokenTrajectory.push({ t: event.t, source: event.source, token_usage: event.token_usage });
8344
8489
  continue;
8345
8490
  }
8491
+ if (event.type === "run_start") {
8492
+ phase = "running";
8493
+ phaseStartedAtMs = event.t;
8494
+ continue;
8495
+ }
8496
+ if (event.type === "status_change") {
8497
+ if (event.status === "running" || event.status === "waiting") {
8498
+ closePhase(event.t);
8499
+ phase = event.status;
8500
+ phaseStartedAtMs = event.t;
8501
+ continue;
8502
+ }
8503
+ if (event.status === "done" || event.status === "error" || event.status === "cancelled") {
8504
+ closePhase(event.t);
8505
+ phase = null;
8506
+ phaseStartedAtMs = null;
8507
+ }
8508
+ continue;
8509
+ }
8346
8510
  if (event.type === "run_complete") {
8511
+ closePhase(event.t);
8512
+ completedAtMs = event.t;
8347
8513
  runCompleteJson = JSON.stringify(event);
8348
8514
  model = event.model ?? model;
8349
8515
  elapsedMs = Math.round(event.elapsed_s * 1000);
8516
+ phase = null;
8517
+ phaseStartedAtMs = null;
8350
8518
  continue;
8351
8519
  }
8352
8520
  if (event.type === "stale_warning" && event.reason === "tool_duration") {
8353
8521
  stallGaps.push({ t: event.t, tool: event.tool ?? null, silence_ms: event.silence_ms, threshold_ms: event.threshold_ms });
8354
8522
  }
8355
8523
  }
8524
+ if (startedAtMs !== null && completedAtMs === null) {
8525
+ completedAtMs = events.length > 0 ? events[events.length - 1].t : startedAtMs;
8526
+ }
8527
+ if (elapsedMs === null && startedAtMs !== null && completedAtMs !== null) {
8528
+ elapsedMs = Math.max(0, completedAtMs - startedAtMs);
8529
+ }
8356
8530
  const record = {
8357
8531
  job_id: jobRow.job_id,
8358
8532
  specialist: jobRow.specialist,
@@ -8366,6 +8540,8 @@ class SqliteClient {
8366
8540
  started_at_ms: startedAtMs,
8367
8541
  completed_at_ms: completedAtMs,
8368
8542
  elapsed_ms: elapsedMs,
8543
+ active_runtime_ms: activeRuntimeMs,
8544
+ waiting_ms: waitingMs,
8369
8545
  total_turns: totalTurns,
8370
8546
  total_tools: totalTools,
8371
8547
  tool_call_counts_json: stringifyJson(toolCallCounts),
@@ -8373,15 +8549,16 @@ class SqliteClient {
8373
8549
  context_trajectory_json: stringifyJson(contextTrajectory),
8374
8550
  stall_gaps_json: stringifyJson(stallGaps),
8375
8551
  run_complete_json: runCompleteJson,
8552
+ startup_payload_json: jobRow.startup_payload_json ?? null,
8376
8553
  updated_at_ms: jobRow.updated_at_ms
8377
8554
  };
8378
8555
  this.db.run(`
8379
8556
  INSERT INTO specialist_job_metrics (
8380
8557
  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,
8558
+ started_at_ms, completed_at_ms, elapsed_ms, active_runtime_ms, waiting_ms, total_turns, total_tools,
8382
8559
  tool_call_counts_json, token_trajectory_json, context_trajectory_json, stall_gaps_json,
8383
8560
  run_complete_json, updated_at_ms
8384
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8561
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8385
8562
  ON CONFLICT(job_id) DO UPDATE SET
8386
8563
  specialist = excluded.specialist,
8387
8564
  model = excluded.model,
@@ -8394,6 +8571,8 @@ class SqliteClient {
8394
8571
  started_at_ms = excluded.started_at_ms,
8395
8572
  completed_at_ms = excluded.completed_at_ms,
8396
8573
  elapsed_ms = excluded.elapsed_ms,
8574
+ active_runtime_ms = excluded.active_runtime_ms,
8575
+ waiting_ms = excluded.waiting_ms,
8397
8576
  total_turns = excluded.total_turns,
8398
8577
  total_tools = excluded.total_tools,
8399
8578
  tool_call_counts_json = excluded.tool_call_counts_json,
@@ -8415,6 +8594,8 @@ class SqliteClient {
8415
8594
  record.started_at_ms,
8416
8595
  record.completed_at_ms,
8417
8596
  record.elapsed_ms,
8597
+ record.active_runtime_ms,
8598
+ record.waiting_ms,
8418
8599
  record.total_turns,
8419
8600
  record.total_tools,
8420
8601
  record.tool_call_counts_json,
@@ -8447,6 +8628,29 @@ class SqliteClient {
8447
8628
  return this.db.query(`SELECT * FROM specialist_job_metrics ${where} ORDER BY updated_at_ms DESC, job_id DESC`).all(...params);
8448
8629
  }, "listJobMetrics");
8449
8630
  }
8631
+ listElapsedMsBySpecialist(sinceMs, limitPerSpecialist = 200) {
8632
+ return withRetry(() => {
8633
+ const rows = this.db.query(`
8634
+ WITH ranked AS (
8635
+ SELECT specialist, elapsed_ms,
8636
+ ROW_NUMBER() OVER (PARTITION BY specialist ORDER BY updated_at_ms DESC) AS rn
8637
+ FROM specialist_job_metrics
8638
+ WHERE status = 'completed' AND updated_at_ms >= ? AND elapsed_ms IS NOT NULL
8639
+ )
8640
+ SELECT specialist, elapsed_ms
8641
+ FROM ranked
8642
+ WHERE rn <= ?
8643
+ ORDER BY specialist, rn
8644
+ `).all(sinceMs, limitPerSpecialist);
8645
+ const bySpecialist = {};
8646
+ for (const row of rows) {
8647
+ if (!row.specialist || typeof row.elapsed_ms !== "number" || !Number.isFinite(row.elapsed_ms))
8648
+ continue;
8649
+ (bySpecialist[row.specialist] ??= []).push(row.elapsed_ms);
8650
+ }
8651
+ return bySpecialist;
8652
+ }, "listElapsedMsBySpecialist");
8653
+ }
8450
8654
  readResult(jobId) {
8451
8655
  return withRetry(() => {
8452
8656
  const row = this.db.query("SELECT output FROM specialist_results WHERE job_id = ? LIMIT 1").get(jobId);
@@ -8847,27 +9051,72 @@ function createObservabilitySqliteClient(cwd = process.cwd()) {
8847
9051
  }
8848
9052
 
8849
9053
  // 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;
9054
+ class CompatGuardError extends Error {
9055
+ field;
9056
+ constructor(field, message) {
9057
+ super(message);
9058
+ this.field = field;
9059
+ this.name = "CompatGuardError";
9060
+ }
8853
9061
  }
8854
- function compatGuard(spec) {
9062
+ function hasUnsubstitutedVariables(template, variables) {
9063
+ const matches = template.match(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
9064
+ for (const match of matches) {
9065
+ const key = match.slice(1);
9066
+ if (variables[key] === undefined)
9067
+ return key;
9068
+ }
9069
+ return null;
9070
+ }
9071
+ function compatGuard(spec, trust) {
8855
9072
  const execution = spec.specialist.execution;
8856
9073
  if (execution.interactive)
8857
- throw new Error("interactive specialists are not allowed");
9074
+ throw new CompatGuardError("execution.interactive", "interactive specialists are not allowed");
8858
9075
  if (execution.requires_worktree)
8859
- throw new Error("worktree specialists are not allowed");
9076
+ throw new CompatGuardError("execution.requires_worktree", "worktree specialists are not allowed");
8860
9077
  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");
9078
+ throw new CompatGuardError("execution.permission_required", "permission_required must be READ_ONLY");
9079
+ const hasScripts = (spec.specialist.skills?.scripts?.length ?? 0) > 0;
9080
+ if (hasScripts && !trust?.allowLocalScripts) {
9081
+ throw new CompatGuardError("skills.scripts", "scripts not allowed (enable with --allow-local-scripts)");
9082
+ }
9083
+ const hasPaths = (spec.specialist.skills?.paths?.length ?? 0) > 0;
9084
+ const hasSkillInherit = Boolean(spec.specialist.prompt.skill_inherit);
9085
+ if (hasPaths && !trust?.allowSkills) {
9086
+ throw new CompatGuardError("skills.paths", "skills not allowed (enable with --allow-skills)");
9087
+ }
9088
+ if (hasSkillInherit && !trust?.allowSkills) {
9089
+ throw new CompatGuardError("prompt.skill_inherit", "skills not allowed (enable with --allow-skills)");
9090
+ }
9091
+ if (hasPaths && trust?.allowSkills && trust.allowSkillsRoots && trust.allowSkillsRoots.length > 0) {
9092
+ const paths = spec.specialist.skills?.paths ?? [];
9093
+ for (const path of paths) {
9094
+ const allowed = trust.allowSkillsRoots.some((root) => path.startsWith(root));
9095
+ if (!allowed) {
9096
+ throw new CompatGuardError("skills.paths", `skill path '${path}' not under any --allow-skills-roots entry`);
9097
+ }
9098
+ }
9099
+ }
9100
+ }
9101
+ function computeSkillSources(spec) {
9102
+ const paths = spec.specialist.skills?.paths ?? [];
9103
+ const sources = [];
9104
+ for (const path of paths) {
9105
+ try {
9106
+ const content = readFileSync2(path);
9107
+ const sha256 = createHash("sha256").update(content).digest("hex");
9108
+ sources.push({ path, sha256 });
9109
+ } catch {
9110
+ sources.push({ path, sha256: "unreadable" });
9111
+ }
9112
+ }
9113
+ return sources;
8864
9114
  }
8865
9115
  function renderTaskTemplate(template, variables) {
8866
- const output = renderTemplate(template, variables);
8867
- const missing = hasUnsubstitutedVariables(output);
9116
+ const missing = hasUnsubstitutedVariables(template, variables);
8868
9117
  if (missing)
8869
9118
  throw new Error(`Missing template variable: ${missing}`);
8870
- return output;
9119
+ return renderTemplate(template, variables);
8871
9120
  }
8872
9121
  function mapErrorType(message) {
8873
9122
  if (message.includes("Specialist not found"))
@@ -8897,59 +9146,32 @@ function textFromMessage(message) {
8897
9146
  return "";
8898
9147
  return message.content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("");
8899
9148
  }
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);
9149
+ function extractAssistantTextFromEvent(event) {
9150
+ if (event.type === "message_end") {
9151
+ const text = textFromMessage(event.message);
9152
+ if (text)
9153
+ return text;
9154
+ }
9155
+ if (event.type === "agent_end" && Array.isArray(event.messages)) {
9156
+ for (let j = event.messages.length - 1;j >= 0; j--) {
9157
+ const text = textFromMessage(event.messages[j]);
8913
9158
  if (text)
8914
9159
  return text;
8915
9160
  }
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
9161
  }
8929
- return "";
9162
+ if (event.type === "assistant" && typeof event.data?.text === "string")
9163
+ return event.data.text;
9164
+ const legacyContent = event.data?.content?.[0]?.text;
9165
+ if (typeof legacyContent === "string")
9166
+ return legacyContent;
9167
+ return;
8930
9168
  }
8931
9169
  function stripMarkdownFences(text) {
8932
9170
  const trimmed = text.trim();
8933
9171
  const fenced = trimmed.match(/^```(?:json|JSON)?\s*\n?([\s\S]*?)\n?```\s*$/);
8934
9172
  return fenced ? fenced[1].trim() : trimmed;
8935
9173
  }
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) {
9174
+ function writeTraceRow(client, specialist, model, traceId, output, durationMs, skillSources, onAuditFailure) {
8953
9175
  if (!client)
8954
9176
  return;
8955
9177
  const status = {
@@ -8960,106 +9182,231 @@ function writeTraceRow(client, specialist, model, traceId, output, durationMs) {
8960
9182
  started_at_ms: Date.now() - durationMs,
8961
9183
  elapsed_s: durationMs / 1000,
8962
9184
  last_event_at_ms: Date.now(),
8963
- surface: "script_specialist"
9185
+ surface: "script_specialist",
9186
+ ...skillSources && skillSources.length > 0 ? { skill_sources: skillSources } : {}
8964
9187
  };
8965
- client.upsertStatus(status);
8966
- client.upsertResult(traceId, output);
9188
+ try {
9189
+ client.upsertStatus(status);
9190
+ client.upsertResult(traceId, output);
9191
+ } catch (error) {
9192
+ onAuditFailure?.(error);
9193
+ }
9194
+ }
9195
+ var DEFAULT_PENDING_LINE_LIMIT_BYTES = 16 * 1024 * 1024;
9196
+ var DEFAULT_ASSISTANT_TEXT_LIMIT_BYTES = 4 * 1024 * 1024;
9197
+ var DEFAULT_STDERR_LIMIT_BYTES = 1 * 1024 * 1024;
9198
+ function resolveAssistantTextLimitBytes(spec) {
9199
+ return spec.specialist.execution.stdout_limit_bytes ?? resolveEnvAssistantTextLimitBytes() ?? DEFAULT_ASSISTANT_TEXT_LIMIT_BYTES;
9200
+ }
9201
+ function resolveEnvAssistantTextLimitBytes() {
9202
+ const raw = process.env.SPECIALISTS_SCRIPT_STDOUT_LIMIT_BYTES;
9203
+ if (raw === undefined)
9204
+ return;
9205
+ const envLimit = Number(raw);
9206
+ if (!Number.isFinite(envLimit) || envLimit <= 0)
9207
+ return;
9208
+ process.stderr.write(`warning: SPECIALISTS_SCRIPT_STDOUT_LIMIT_BYTES is deprecated; applies to assistant text cap
9209
+ `);
9210
+ return Math.floor(envLimit);
8967
9211
  }
8968
9212
  function openObservabilityClient(options) {
8969
9213
  const dbPath = options.observabilityDbPath ?? options.projectDir;
8970
9214
  return createObservabilitySqliteClient(dbPath);
8971
9215
  }
9216
+ function resolveScriptSpecialistName(name) {
9217
+ if (name === "changelog-keeper")
9218
+ return "changelog-drafter";
9219
+ return name;
9220
+ }
8972
9221
  async function runScriptSpecialist(input, options) {
8973
9222
  const traceId = randomUUID();
8974
9223
  const startedAt = Date.now();
8975
9224
  try {
8976
- const spec = await options.loader.get(input.specialist);
8977
- compatGuard(spec);
9225
+ const resolvedSpecialist = resolveScriptSpecialistName(input.specialist);
9226
+ const spec = await options.loader.get(resolvedSpecialist);
9227
+ compatGuard(spec, options.trust);
9228
+ const skillSources = options.trust?.allowSkills ? computeSkillSources(spec) : undefined;
8978
9229
  const template = input.template ?? spec.specialist.prompt.task_template;
8979
9230
  const prompt = renderTaskTemplate(template, input.variables ?? {});
8980
- const model = input.model_override ?? spec.specialist.execution.model ?? options.fallbackModel ?? "unknown";
9231
+ if (process.env.SPECIALISTS_SCRIPT_STUB_OUTPUT) {
9232
+ return {
9233
+ success: true,
9234
+ output: prompt,
9235
+ meta: {
9236
+ specialist: resolvedSpecialist,
9237
+ requested_specialist: input.requested_specialist ?? input.specialist,
9238
+ resolved_specialist: resolvedSpecialist,
9239
+ model: "stub",
9240
+ duration_ms: Date.now() - startedAt,
9241
+ trace_id: traceId
9242
+ }
9243
+ };
9244
+ }
8981
9245
  const timeoutMs = input.timeout_ms ?? spec.specialist.execution.timeout_ms ?? 120000;
9246
+ const modelCandidates = collectModelCandidates(input, spec, options);
9247
+ const assistantTextLimitBytes = resolveAssistantTextLimitBytes(spec);
9248
+ const attempts = [];
9249
+ for (const model of modelCandidates) {
9250
+ const attempt = await runSingleAttempt(prompt, model, input.thinking_level ?? spec.specialist.execution.thinking_level, timeoutMs, assistantTextLimitBytes, options);
9251
+ attempts.push(attempt);
9252
+ const parsed = classifyAttempt(attempt);
9253
+ if (parsed.retryable)
9254
+ continue;
9255
+ const durationMs2 = Date.now() - startedAt;
9256
+ const observability2 = openObservabilityClient(options);
9257
+ if (input.trace !== false && observability2)
9258
+ writeTraceRow(observability2, resolvedSpecialist, model, traceId, parsed.text, durationMs2, skillSources, options.onAuditFailure);
9259
+ if (parsed.kind === "success") {
9260
+ let parsed_json;
9261
+ if (spec.specialist.execution.response_format === "json") {
9262
+ try {
9263
+ parsed_json = JSON.parse(stripMarkdownFences(parsed.text));
9264
+ const required = Array.isArray(spec.specialist.prompt.output_schema?.required) ? spec.specialist.prompt.output_schema.required.filter((value) => typeof value === "string") : [];
9265
+ for (const key of required) {
9266
+ if (parsed_json === null || typeof parsed_json !== "object" || !(key in parsed_json))
9267
+ throw new Error(`Missing required output field: ${key}`);
9268
+ }
9269
+ } catch (error) {
9270
+ 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 } };
9271
+ }
9272
+ }
9273
+ 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 } };
9274
+ }
9275
+ 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 } };
9276
+ }
9277
+ const lastAttempt = attempts.at(-1);
9278
+ const durationMs = Date.now() - startedAt;
9279
+ const observability = openObservabilityClient(options);
9280
+ if (input.trace !== false && observability)
9281
+ writeTraceRow(observability, resolvedSpecialist, modelCandidates.at(-1) ?? "unknown", traceId, lastAttempt?.text ?? "", durationMs, skillSources, options.onAuditFailure);
9282
+ 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 } };
9283
+ } catch (error) {
9284
+ const message = error instanceof Error ? error.message : String(error);
9285
+ const resolvedSpecialist = resolveScriptSpecialistName(input.specialist);
9286
+ 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 } };
9287
+ }
9288
+ }
9289
+ function collectModelCandidates(input, spec, options) {
9290
+ const candidates = [input.model_override, spec.specialist.execution.model, spec.specialist.execution.fallback_model, options.fallbackModel].filter((value) => typeof value === "string" && value.length > 0);
9291
+ return [...new Set(candidates)];
9292
+ }
9293
+ function runSingleAttempt(prompt, model, thinkingLevel, timeoutMs, assistantTextLimitBytes, options) {
9294
+ return new Promise((resolve, reject) => {
8982
9295
  const args = ["--mode", "json", "--no-session", "--no-extensions", "--no-tools", "--model", model];
8983
- const thinkingLevel = input.thinking_level ?? spec.specialist.execution.thinking_level;
8984
9296
  if (thinkingLevel)
8985
9297
  args.push("--thinking", thinkingLevel);
8986
9298
  args.push(prompt);
8987
9299
  const pi = spawn("pi", args, { stdio: ["ignore", "pipe", "pipe"] });
8988
9300
  options.onChild?.(pi);
8989
- const chunks = [];
8990
9301
  let stderr = "";
8991
9302
  let timedOut = false;
8992
9303
  let outputTooLarge = false;
8993
- const stdoutLimit = 4 * 1024 * 1024;
8994
- let stdoutBytes = 0;
9304
+ let outputTooLargeReason;
9305
+ let pending = "";
9306
+ let assistantText = "";
9307
+ let pendingBytes = 0;
9308
+ let stderrBytes = 0;
8995
9309
  const timer = setTimeout(() => {
8996
9310
  timedOut = true;
8997
9311
  pi.kill("SIGTERM");
8998
9312
  setTimeout(() => pi.kill("SIGKILL"), 2000);
8999
9313
  }, timeoutMs);
9000
9314
  pi.stdout.on("data", (chunk) => {
9315
+ if (outputTooLarge)
9316
+ return;
9001
9317
  const buffer = Buffer.from(chunk);
9002
- chunks.push(buffer);
9003
- stdoutBytes += buffer.length;
9004
- if (stdoutBytes > stdoutLimit && !outputTooLarge) {
9318
+ pending += buffer.toString("utf-8");
9319
+ pendingBytes += buffer.length;
9320
+ if (pendingBytes > DEFAULT_PENDING_LINE_LIMIT_BYTES) {
9005
9321
  outputTooLarge = true;
9322
+ outputTooLargeReason = "malformed_line_too_large";
9006
9323
  pi.kill("SIGTERM");
9007
9324
  setTimeout(() => pi.kill("SIGKILL"), 2000);
9325
+ return;
9008
9326
  }
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}`);
9327
+ const lines = pending.split(/\r?\n/);
9328
+ pending = lines.pop() ?? "";
9329
+ pendingBytes = Buffer.byteLength(pending);
9330
+ for (const rawLine of lines) {
9331
+ const line = rawLine.trim();
9332
+ if (!line)
9333
+ continue;
9334
+ try {
9335
+ const event = JSON.parse(line);
9336
+ const nextAssistantText = extractAssistantTextFromEvent(event);
9337
+ if (nextAssistantText !== undefined) {
9338
+ if (Buffer.byteLength(nextAssistantText, "utf8") > assistantTextLimitBytes) {
9339
+ outputTooLarge = true;
9340
+ outputTooLargeReason = "assistant_text_too_large";
9341
+ pi.kill("SIGTERM");
9342
+ setTimeout(() => pi.kill("SIGKILL"), 2000);
9343
+ return;
9344
+ }
9345
+ assistantText = nextAssistantText;
9047
9346
  }
9347
+ } catch {
9348
+ continue;
9048
9349
  }
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
9350
  }
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
- }
9351
+ });
9352
+ pi.stderr.on("data", (chunk) => {
9353
+ if (outputTooLarge)
9354
+ return;
9355
+ const text = String(chunk);
9356
+ stderr += text;
9357
+ stderrBytes += Buffer.byteLength(text, "utf8");
9358
+ if (stderrBytes > DEFAULT_STDERR_LIMIT_BYTES) {
9359
+ outputTooLarge = true;
9360
+ outputTooLargeReason = "stderr_too_large";
9361
+ stderr = stderr.slice(0, DEFAULT_STDERR_LIMIT_BYTES);
9362
+ pi.kill("SIGTERM");
9363
+ setTimeout(() => pi.kill("SIGKILL"), 2000);
9364
+ }
9365
+ });
9366
+ pi.on("error", reject);
9367
+ pi.on("close", (code) => {
9368
+ clearTimeout(timer);
9369
+ resolve({
9370
+ model,
9371
+ text: assistantText,
9372
+ stderr,
9373
+ exitCode: code ?? 0,
9374
+ timedOut,
9375
+ outputTooLarge,
9376
+ outputTooLargeReason
9377
+ });
9378
+ });
9379
+ });
9380
+ }
9381
+ function classifyAttempt(attempt) {
9382
+ if (attempt.outputTooLarge) {
9383
+ if (attempt.outputTooLargeReason === "assistant_text_too_large")
9384
+ return { retryable: false, kind: "failure", error: "assistant message too large", errorType: "output_too_large", text: attempt.text };
9385
+ if (attempt.outputTooLargeReason === "stderr_too_large")
9386
+ return { retryable: false, kind: "failure", error: "stderr too large", errorType: "output_too_large", text: attempt.text };
9387
+ if (attempt.outputTooLargeReason === "malformed_line_too_large")
9388
+ return { retryable: false, kind: "failure", error: "malformed line too large", errorType: "output_too_large", text: attempt.text };
9389
+ return { retryable: false, kind: "failure", error: "output exceeded cap", errorType: "output_too_large", text: attempt.text };
9390
+ }
9391
+ if (attempt.timedOut)
9392
+ return { retryable: false, kind: "failure", error: attempt.stderr || "timed out", errorType: "timeout", text: attempt.text };
9393
+ const retryable = isRetryableModelFailure(attempt.stderr, attempt.text);
9394
+ if (attempt.exitCode !== 0) {
9395
+ const errorType = mapErrorType(attempt.stderr);
9396
+ return { retryable, kind: "failure", error: attempt.stderr || `pi exit ${attempt.exitCode}`, errorType, text: attempt.text };
9397
+ }
9398
+ if (!attempt.text) {
9399
+ return { retryable, kind: "failure", error: attempt.stderr || "pi produced no assistant text", errorType: mapErrorType(attempt.stderr), text: attempt.text };
9400
+ }
9401
+ return { retryable: false, kind: "success", error: "", errorType: "internal", text: attempt.text };
9402
+ }
9403
+ function isRetryableModelFailure(stderr, text) {
9404
+ 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
9405
  }
9059
9406
  // src/specialist/loader.ts
9060
9407
  import { readdir, readFile, stat } from "node:fs/promises";
9061
9408
  import { basename, join as join2 } from "node:path";
9062
- import { existsSync as existsSync2 } from "node:fs";
9409
+ import { existsSync as existsSync3 } from "node:fs";
9063
9410
 
9064
9411
  // node_modules/yaml/dist/index.js
9065
9412
  var composer = require_composer();
@@ -12937,6 +13284,7 @@ var ExecutionSchema = objectType({
12937
13284
  stall_timeout_ms: numberType().optional(),
12938
13285
  max_retries: numberType().int().min(0).default(0),
12939
13286
  interactive: booleanType().default(false),
13287
+ stdout_limit_bytes: numberType().int().positive().optional(),
12940
13288
  response_format: enumType(["text", "json", "markdown"]).default("text"),
12941
13289
  output_type: enumType(["codegen", "analysis", "review", "synthesis", "orchestration", "workflow", "research", "custom"]).default("custom"),
12942
13290
  permission_required: enumType(["READ_ONLY", "LOW", "MEDIUM", "HIGH"]).default("READ_ONLY"),
@@ -13087,6 +13435,20 @@ ${result.warnings.map((w) => ` ⚠ ${w}`).join(`
13087
13435
  return SpecialistSchema.parseAsync(raw);
13088
13436
  }
13089
13437
 
13438
+ // src/specialist/canonical-asset-resolver.ts
13439
+ import { existsSync as existsSync2 } from "node:fs";
13440
+ import { fileURLToPath } from "node:url";
13441
+ function resolveCanonicalAssetDir(relativePath) {
13442
+ const configPath = `config/${relativePath}`;
13443
+ let resolved = fileURLToPath(new URL(`../${configPath}`, import.meta.url));
13444
+ if (existsSync2(resolved))
13445
+ return resolved;
13446
+ resolved = fileURLToPath(new URL(`../../${configPath}`, import.meta.url));
13447
+ if (existsSync2(resolved))
13448
+ return resolved;
13449
+ return null;
13450
+ }
13451
+
13090
13452
  // src/specialist/loader.ts
13091
13453
  class SpecialistLoader {
13092
13454
  cache = new Map;
@@ -13101,11 +13463,12 @@ class SpecialistLoader {
13101
13463
  { path: join2(this.projectDir, ".specialists", "default"), scope: "default", source: "default-mirror" },
13102
13464
  { path: join2(this.projectDir, ".specialists", "default", "specialists"), scope: "default", source: "legacy" },
13103
13465
  { path: join2(this.projectDir, "config", "specialists"), scope: "package", source: "package-fallback" },
13466
+ { path: resolveCanonicalAssetDir("specialists") ?? "", scope: "package", source: "package-live" },
13104
13467
  { path: join2(this.projectDir, "specialists"), scope: "default", source: "legacy" },
13105
13468
  { path: join2(this.projectDir, ".claude", "specialists"), scope: "default", source: "legacy" },
13106
13469
  { path: join2(this.projectDir, ".agent-forge", "specialists"), scope: "default", source: "legacy" }
13107
13470
  ];
13108
- return dirs.filter((d) => existsSync2(d.path));
13471
+ return dirs.filter((d) => d.path && existsSync3(d.path));
13109
13472
  }
13110
13473
  toJson(content, isYaml) {
13111
13474
  if (!isYaml)
@@ -13114,11 +13477,11 @@ class SpecialistLoader {
13114
13477
  }
13115
13478
  resolveSpecialistPath(dirPath, specialistName) {
13116
13479
  const jsonPath = join2(dirPath, `${specialistName}.specialist.json`);
13117
- if (existsSync2(jsonPath)) {
13480
+ if (existsSync3(jsonPath)) {
13118
13481
  return { filePath: jsonPath, deprecatedYaml: false };
13119
13482
  }
13120
13483
  const yamlPath = join2(dirPath, `${specialistName}.specialist.yaml`);
13121
- if (existsSync2(yamlPath)) {
13484
+ if (existsSync3(yamlPath)) {
13122
13485
  return { filePath: yamlPath, deprecatedYaml: true };
13123
13486
  }
13124
13487
  return null;
@@ -13159,6 +13522,7 @@ class SpecialistLoader {
13159
13522
  thinking_level: spec.specialist.execution.thinking_level,
13160
13523
  skills: spec.specialist.skills?.paths ?? [],
13161
13524
  scripts: spec.specialist.skills?.scripts ?? [],
13525
+ mandatoryRuleTemplateSets: spec.specialist.mandatory_rules?.template_sets ?? [],
13162
13526
  scope: dir.scope,
13163
13527
  source: dir.source,
13164
13528
  filePath: resolved.filePath,