@agentskit/harness 0.10.0 → 0.11.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.
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomUUID, createPrivateKey, createPublicKey, sign, verify } from 'crypto';
3
- import { readFileSync, mkdirSync, existsSync, unlinkSync, openSync, writeFileSync, closeSync, readdirSync, writeSync, appendFileSync, mkdtempSync, renameSync, rmSync, statSync } from 'fs';
4
- import { Command } from 'commander';
3
+ import { readFileSync, mkdirSync, existsSync, unlinkSync, openSync, writeFileSync, closeSync, readdirSync, statSync, writeSync, appendFileSync, mkdtempSync, renameSync, rmSync } from 'fs';
5
4
  import { resolve, dirname, relative, basename, join, extname, isAbsolute, delimiter, sep } from 'path';
5
+ import { Command } from 'commander';
6
6
  import { execFile, spawn, execFileSync } from 'child_process';
7
7
  import { promisify } from 'util';
8
8
  import { tmpdir, totalmem, release, freemem, cpus, loadavg } from 'os';
@@ -921,11 +921,34 @@ var cleanTaskArtifacts = (configPath) => cleanConfiguredArtifacts(loadConfig(con
921
921
  var index = (root, indexPath) => JSON.parse(readFileSync(resolve(root, indexPath), "utf8"));
922
922
  var text = (entry) => [entry.id, entry.type, entry.title, entry.path, entry.description, entry.body, ...Array.isArray(entry.tags) ? entry.tags : []].filter((value) => typeof value === "string").join(" ").toLowerCase();
923
923
  var sourceHash = (document) => typeof document.contentHash === "string" && document.contentHash.length > 0 ? document.contentHash : hashJson(document);
924
+ var tokenSeparator = /[^\p{L}\p{N}@/_-]+/gu;
925
+ var tokenize = (value) => value.toLowerCase().split(tokenSeparator).filter((token) => token.length >= 2);
926
+ var containsToken = (value, token) => {
927
+ if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(token)) return value.includes(token);
928
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
929
+ return new RegExp(`(?:^|[^\\p{L}\\p{N}])${escaped}(?:s|es)?(?:[^\\p{L}\\p{N}]|$)`, "u").test(value);
930
+ };
931
+ var score = (value, query) => tokenize(query).reduce((total, token) => total + (containsToken(value, token) ? token.length : 0), 0);
924
932
  var matches = (entry, query) => {
925
- const needle = query.query.trim().toLowerCase();
926
- const scopes = query.scope?.map((scope) => scope.toLowerCase()) ?? [];
933
+ const needle = query.query.trim();
934
+ if (!needle) return 0;
927
935
  const value = text(entry);
928
- return Boolean(needle && value.includes(needle) && (scopes.length === 0 || scopes.some((scope) => value.includes(scope))));
936
+ if (query.scope?.length && !query.scope.some((scope) => containsToken(value, scope.toLowerCase()))) return 0;
937
+ return score(value, needle);
938
+ };
939
+ var ownershipEntries = (document) => {
940
+ if (typeof document.lookup !== "object" || document.lookup === null || Array.isArray(document.lookup)) return [];
941
+ const ownership = document.lookup.ownership;
942
+ if (typeof ownership !== "object" || ownership === null || Array.isArray(ownership)) return [];
943
+ return Object.entries(ownership).flatMap(([id2, value]) => {
944
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
945
+ const owner = value;
946
+ const path = typeof owner["agentDoc"] === "string" ? owner["agentDoc"] : owner["path"];
947
+ if (!path) return [];
948
+ const description = typeof owner["purpose"] === "string" ? owner["purpose"] : void 0;
949
+ const body2 = [owner["purpose"], owner["group"], owner["layer"], owner["agentDoc"], owner["humanDoc"]].filter((item) => typeof item === "string").join(" ");
950
+ return [{ id: typeof owner["id"] === "string" ? owner["id"] : id2, type: "ownership", path, ...description ? { description } : {}, ...body2 ? { body: body2 } : {} }];
951
+ });
929
952
  };
930
953
  var inspectDocBridgeIndex = (root, indexPath = ".doc-bridge/index.json", now4 = Date.now()) => {
931
954
  const path = resolve(root, indexPath);
@@ -940,15 +963,30 @@ var inspectDocBridgeIndex = (root, indexPath = ".doc-bridge/index.json", now4 =
940
963
  return { present: true, path, contentHash: null, mtimeMs: null, ageHours: null, error: error instanceof Error ? error.message : String(error) };
941
964
  }
942
965
  };
943
- var createDocBridgeContextProvider = ({ root, indexPath = ".doc-bridge/index.json" }) => ({
966
+ var createDocBridgeContextProvider = ({ root, indexPath = ".doc-bridge/index.json", maxAgeHours, now: now4 = Date.now }) => ({
944
967
  id: "doc-bridge",
945
- version: "1.0.0",
968
+ version: "1.1.0",
946
969
  resolve: async (query) => {
947
970
  const started = Date.now();
971
+ const ageBudget = maxAgeHours ?? 0;
972
+ const inspection = ageBudget > 0 ? inspectDocBridgeIndex(root, indexPath, now4()) : null;
973
+ if (inspection?.error) throw new Error(`Doc Bridge index is unreadable: ${inspection.error}`);
974
+ if (inspection?.ageHours !== null && inspection?.ageHours !== void 0 && inspection.ageHours > ageBudget) {
975
+ throw new Error(`Doc Bridge index is ${inspection.ageHours.toFixed(1)}h old; refresh it before resolving context.`);
976
+ }
948
977
  const document = index(root, indexPath);
949
978
  const contentHash = sourceHash(document);
950
- const entries = Array.isArray(document.knowledge) ? document.knowledge.filter((value) => typeof value === "object" && value !== null && !Array.isArray(value)).filter((entry) => matches(entry, query)).sort((left, right) => String(left.id ?? "").localeCompare(String(right.id ?? ""))).slice(0, 8) : [];
951
- const references = entries.flatMap((entry) => typeof entry.id === "string" && typeof entry.path === "string" ? [{ id: entry.id, uri: `doc-bridge://${entry.path}`, ...typeof entry.title === "string" ? { title: entry.title } : {}, contentHash: typeof entry.contentHash === "string" ? entry.contentHash : contentHash, relevance: 1 }] : []);
979
+ const knowledge = Array.isArray(document.knowledge) ? document.knowledge.filter((value) => typeof value === "object" && value !== null && !Array.isArray(value)) : [];
980
+ const ranked = [...knowledge, ...ownershipEntries(document)].map((entry) => ({ entry, score: matches(entry, query) })).filter(({ score: entryScore }) => entryScore > 0);
981
+ const byPath = /* @__PURE__ */ new Map();
982
+ for (const candidate of ranked) {
983
+ const path = typeof candidate.entry.path === "string" ? candidate.entry.path : String(candidate.entry.id ?? "");
984
+ const current = byPath.get(path);
985
+ if (!current || candidate.score > current.score || candidate.score === current.score && candidate.entry.type === "ownership" && current.entry.type !== "ownership") byPath.set(path, candidate);
986
+ }
987
+ const entries = [...byPath.values()].sort((left, right) => right.score - left.score || String(left.entry.id ?? "").localeCompare(String(right.entry.id ?? ""))).slice(0, 8);
988
+ const maxScore = entries[0]?.score ?? 1;
989
+ const references = entries.flatMap(({ entry, score: entryScore }) => typeof entry.id === "string" && typeof entry.path === "string" ? [{ id: entry.id, uri: `doc-bridge://${entry.path}`, ...typeof entry.title === "string" ? { title: entry.title } : {}, contentHash: typeof entry.contentHash === "string" ? entry.contentHash : contentHash, relevance: entryScore / maxScore }] : []);
952
990
  const telemetry = { status: "measured", durationMs: Date.now() - started, contextReferences: references.length, contextCostTokens: Math.max(1, Math.ceil(JSON.stringify(references).length / 4)) };
953
991
  return { providerId: "doc-bridge", query, references, sourceHash: contentHash, snapshotHash: hashContextSnapshot({ providerId: "doc-bridge", query, references, sourceHash: contentHash }), resolvedAt: (/* @__PURE__ */ new Date()).toISOString(), assurance: "contract-tested", telemetry };
954
992
  }
@@ -1543,6 +1581,14 @@ var createKvMemoryAdapter = (store, options2 = {}) => {
1543
1581
  const scopeMatch = record3.scope === "global" || (record3.scope === "issue" ? Boolean(issueId && record3.source.includes(issueId)) : Boolean(project && record3.source.includes(project)));
1544
1582
  return scopeMatch && (!query || `${record3.summary} ${record3.source}`.toLowerCase().includes(query));
1545
1583
  };
1584
+ let allRecordsCache = null;
1585
+ const allRecords = async () => {
1586
+ if (allRecordsCache) return allRecordsCache;
1587
+ const ids = await store.get(indexKey);
1588
+ const records = Array.isArray(ids) ? await Promise.all(ids.filter((id2) => typeof id2 === "string").map((id2) => store.get(`agentskit-harness:memory:${id2}`))) : [];
1589
+ allRecordsCache = records.filter((record3) => Boolean(record3 && typeof record3 === "object" && record3.approved === true));
1590
+ return allRecordsCache;
1591
+ };
1546
1592
  return {
1547
1593
  id: options2.id ?? "agentskit-kv",
1548
1594
  version: options2.version ?? "1",
@@ -1554,13 +1600,13 @@ var createKvMemoryAdapter = (store, options2 = {}) => {
1554
1600
  const index2 = Array.isArray(ids) ? ids.filter((id2) => typeof id2 === "string") : [];
1555
1601
  if (!index2.includes(valid.id)) await store.set(indexKey, [...index2, valid.id].sort());
1556
1602
  await store.set(`agentskit-harness:memory:${valid.id}`, valid);
1603
+ allRecordsCache = null;
1557
1604
  writes += 1;
1558
1605
  },
1559
1606
  async recall({ query, issueId, project, sourceRevision }) {
1560
1607
  reads += 1;
1561
- const ids = await store.get(indexKey);
1562
- const records = Array.isArray(ids) ? await Promise.all(ids.filter((id2) => typeof id2 === "string").map((id2) => store.get(`agentskit-harness:memory:${id2}`))) : [];
1563
- const hits = records.filter((record3) => Boolean(record3 && typeof record3 === "object" && record3.approved === true)).filter((record3) => matches2(record3, query.trim().toLowerCase(), issueId, project)).map((record3) => ({ record: record3, relevant: true, stale: sourceRevision !== void 0 && record3.sourceRevision !== sourceRevision }));
1608
+ const records = await allRecords();
1609
+ const hits = records.filter((record3) => matches2(record3, query.trim().toLowerCase(), issueId, project)).map((record3) => ({ record: record3, relevant: true, stale: sourceRevision !== void 0 && record3.sourceRevision !== sourceRevision }));
1564
1610
  relevantHits += hits.length;
1565
1611
  staleHits += hits.filter((hit) => hit.stale).length;
1566
1612
  return hits;
@@ -2514,10 +2560,16 @@ var orcaTerminalCreate = async (runner, input, options2 = {}) => {
2514
2560
  };
2515
2561
  var parseOrcaSendReceipt = (result) => {
2516
2562
  const record3 = isRecord7(result) ? result : {};
2517
- const receipt = isRecord7(record3["receipt"]) ? record3["receipt"] : record3;
2518
- const stages = Array.isArray(receipt["stages"]) ? receipt["stages"].map((stage) => isRecord7(stage) ? str(stage["stage"], str(stage["name"])) : str(stage)).filter(Boolean) : [];
2519
- const accepted = receipt["accepted"] === false ? false : receipt["accepted"] === true || stages.includes("input_accepted") || (result === null || result === void 0 || Object.keys(record3).length === 0);
2520
- return { accepted, requestId: str(receipt["requestId"], str(record3["requestId"])) || null, stages, warnings: Array.isArray(record3["warnings"]) ? record3["warnings"].map((warning) => isRecord7(warning) ? str(warning["message"], JSON.stringify(warning)) : str(warning)) : [] };
2563
+ const send = isRecord7(record3["send"]) ? record3["send"] : null;
2564
+ const prompt = send && isRecord7(send["prompt"]) ? send["prompt"] : null;
2565
+ const receipt = isRecord7(record3["receipt"]) ? record3["receipt"] : send ?? record3;
2566
+ const rawStages = Array.isArray(receipt["stages"]) ? receipt["stages"] : prompt && Array.isArray(prompt["stages"]) ? prompt["stages"] : [];
2567
+ const stages = rawStages.map((stage) => isRecord7(stage) ? str(stage["stage"], str(stage["name"])) : str(stage)).filter(Boolean);
2568
+ const inputAccepted = stages.some((stage) => ["input_accepted", "input_queued", "prompt_accepted", "queued"].includes(stage.toLowerCase()));
2569
+ const acceptedValue = receipt["accepted"] ?? send?.["accepted"];
2570
+ const accepted = inputAccepted || acceptedValue === true || acceptedValue !== false && (result === null || result === void 0 || Object.keys(record3).length === 0);
2571
+ const warnings = Array.isArray(record3["warnings"]) ? record3["warnings"] : send && Array.isArray(send["warnings"]) ? send["warnings"] : [];
2572
+ return { accepted, requestId: str(receipt["requestId"], str(prompt?.["requestId"], str(record3["requestId"]))) || null, stages, warnings: warnings.map((warning) => isRecord7(warning) ? str(warning["message"], JSON.stringify(warning)) : str(warning)) };
2521
2573
  };
2522
2574
  var orcaTerminalSend = async (runner, input, options2 = {}) => parseOrcaSendReceipt(await orcaJson(runner, ["terminal", "send", "--terminal", input.terminal, "--text", input.text, ...input.enter === false ? [] : ["--enter"], ...input.waitSubmitSeconds ? ["--wait-submit", String(input.waitSubmitSeconds)] : []], { ...options2, timeoutMs: options2.timeoutMs ?? (input.waitSubmitSeconds ?? 0) * 1e3 + 3e4 }));
2523
2575
  var orcaTerminalWait = async (runner, input, options2 = {}) => {
@@ -2770,6 +2822,8 @@ var LoopConfigSchema = z.object({
2770
2822
  }).prefault({}),
2771
2823
  catalog: z.object({
2772
2824
  sources: z.array(z.enum(["cli", "artificial-analysis", "builtin"])).default(["cli", "builtin"]),
2825
+ /** How long a provider's CLI-discovered model list (e.g. `grok models`) is trusted before spawning the CLI again — it rarely changes between releases. */
2826
+ cliCacheHours: z.number().positive().default(6),
2773
2827
  artificialAnalysis: z.object({
2774
2828
  enabled: z.boolean().default(false),
2775
2829
  apiKeyEnv: nonEmpty2.default("ARTIFICIAL_ANALYSIS_API_KEY"),
@@ -3292,7 +3346,12 @@ var selectModel = (config, role, availability, extraCandidates = []) => {
3292
3346
  var routeAllRoles = (config, availability, extrasByRole = {}) => Object.fromEntries(MODEL_ROLES.map((role) => [role, selectModel(config, role, availability, extrasByRole[role] ?? [])]));
3293
3347
  var rankModels = (config, role, availability, extraCandidates = []) => {
3294
3348
  const byId = new Map(availability.map((item) => [item.id, item]));
3295
- const { ranked } = availableFromTiers(config, role, availability);
3349
+ const { ranked, skipped } = availableFromTiers(config, role, availability);
3350
+ if (config.models.routing.pin[role]) {
3351
+ const pinned = applyPin(config, role, availability, skipped);
3352
+ if (pinned) return [pinned, ...ranked.filter((item) => !(item.provider === pinned.provider && item.model === pinned.model))];
3353
+ if (config.models.routing.pinStrict) return [];
3354
+ }
3296
3355
  const extras = [];
3297
3356
  let extraIndex = 1e4;
3298
3357
  for (const ref of extraCandidates) {
@@ -3413,6 +3472,32 @@ ${outcome.stderr}`);
3413
3472
  }
3414
3473
  return [];
3415
3474
  };
3475
+ var cliModelsCachePath = (stateDir, provider) => join(stateDir, "catalog", `cli-${provider}.json`);
3476
+ var readCliModelsCache = (stateDir, provider) => {
3477
+ const path = cliModelsCachePath(stateDir, provider);
3478
+ if (!existsSync(path)) return null;
3479
+ try {
3480
+ const raw = readJson2(path);
3481
+ return typeof raw.fetchedAt === "string" && Array.isArray(raw.ids) ? { fetchedAt: raw.fetchedAt, ids: raw.ids } : null;
3482
+ } catch {
3483
+ return null;
3484
+ }
3485
+ };
3486
+ var writeCliModelsCache = (stateDir, provider, ids, now4 = /* @__PURE__ */ new Date()) => {
3487
+ const path = cliModelsCachePath(stateDir, provider);
3488
+ mkdirSync(dirname(path), { recursive: true });
3489
+ const tmp = `${path}.${process.pid}.tmp`;
3490
+ writeFileSync(tmp, `${JSON.stringify({ fetchedAt: now4.toISOString(), ids }, null, 2)}
3491
+ `, "utf8");
3492
+ renameSync(tmp, path);
3493
+ };
3494
+ var listCliModelsCached = async (provider, bin, runner, stateDir, cacheHours, now4 = () => /* @__PURE__ */ new Date()) => {
3495
+ const cached = readCliModelsCache(stateDir, provider);
3496
+ if (cached && now4().getTime() - Date.parse(cached.fetchedAt) <= cacheHours * 36e5) return cached.ids;
3497
+ const ids = await listCliModels(provider, bin, runner);
3498
+ if (ids.length) writeCliModelsCache(stateDir, provider, ids, now4());
3499
+ return ids.length ? ids : cached?.ids ?? [];
3500
+ };
3416
3501
  var parseArtificialAnalysisPayload = (payload) => {
3417
3502
  const root = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
3418
3503
  const data = Array.isArray(root["data"]) ? root["data"] : Array.isArray(payload) ? payload : [];
@@ -3497,7 +3582,7 @@ var resolveCatalogCandidates = async (input) => {
3497
3582
  const settings = config.models.providers[provider];
3498
3583
  if (settings) {
3499
3584
  try {
3500
- const ids = await listCliModels(provider, settings.bin, input.runner);
3585
+ const ids = input.stateDir ? await listCliModelsCached(provider, settings.bin, input.runner, input.stateDir, config.models.catalog.cliCacheHours, input.now) : await listCliModels(provider, settings.bin, input.runner);
3501
3586
  for (const id2 of ids) {
3502
3587
  const resolved = resolveAlias(provider, id2, aliases);
3503
3588
  const existing = builtin[provider]?.models.find((model) => model.id === resolved);
@@ -3529,9 +3614,9 @@ var resolveCatalogCandidates = async (input) => {
3529
3614
  const matches2 = models.models.filter((model) => model.creatorSlug === creator || model.creatorSlug.includes(creator));
3530
3615
  for (const model of matches2) {
3531
3616
  const id2 = resolveAlias(provider, model.slug, aliases);
3532
- const score = model.codingIndex ?? model.intelligenceIndex ?? 50;
3533
- const quality = score >= 80 ? "frontier" : score >= 60 ? "balanced" : "fast";
3534
- push(provider, { id: id2, quality, codingScore: score, source: "artificial-analysis", creator });
3617
+ const score2 = model.codingIndex ?? model.intelligenceIndex ?? 50;
3618
+ const quality = score2 >= 80 ? "frontier" : score2 >= 60 ? "balanced" : "fast";
3619
+ push(provider, { id: id2, quality, codingScore: score2, source: "artificial-analysis", creator });
3535
3620
  }
3536
3621
  }
3537
3622
  }
@@ -3583,6 +3668,16 @@ var markProviderExhausted = (stateDir, provider, options2) => {
3583
3668
  return entry;
3584
3669
  };
3585
3670
  var rotationStatePath = (stateDir) => join(stateDir, "queue-owner.json");
3671
+ var countRotationBlockingLeases = (loaded, leases) => leases.filter((lease) => {
3672
+ const path = join(loaded.stateDir, "issues", lease.issue, "delivery.json");
3673
+ if (!existsSync(path)) return true;
3674
+ try {
3675
+ const delivery2 = JSON.parse(readFileSync(path, "utf8"));
3676
+ return delivery2.prNumber == null && !delivery2.heldFor && !delivery2.finalOutcome;
3677
+ } catch {
3678
+ return true;
3679
+ }
3680
+ }).length;
3586
3681
  var queueOwner = (loaded) => {
3587
3682
  const { rotation } = loaded.config.linear;
3588
3683
  if (!rotation.enabled || !rotation.owners.length) return loaded.config.linear.person;
@@ -4077,10 +4172,10 @@ var planMemoryContext = async (input) => {
4077
4172
  hits = [];
4078
4173
  }
4079
4174
  const selected = selectMemoryForPrompt(hits, memory);
4080
- const beforeChars = input.references.reduce((sum, ref) => sum + JSON.stringify(ref).length, 0) + issueBudgetDefault;
4175
+ const beforeChars = input.references.reduce((sum2, ref) => sum2 + JSON.stringify(ref).length, 0) + issueBudgetDefault;
4081
4176
  const preferred = memory.preferOverDocBridge ? preferMemoryOverDocBridge(input.references, selected.hits, memory.minDocBridgeWhenMemory) : { references: input.references};
4082
4177
  const issueCharBudget = selected.hits.length && memory.shrinkIssueCharsWhenMemory ? Math.min(issueBudgetDefault, memory.issueCharsWithMemory) : issueBudgetDefault;
4083
- const afterChars = preferred.references.reduce((sum, ref) => sum + JSON.stringify(ref).length, 0) + issueCharBudget + selected.approxChars;
4178
+ const afterChars = preferred.references.reduce((sum2, ref) => sum2 + JSON.stringify(ref).length, 0) + issueCharBudget + selected.approxChars;
4084
4179
  return {
4085
4180
  hits: selected.hits,
4086
4181
  references: preferred.references,
@@ -4535,7 +4630,6 @@ var resumeIssue = (stateDir, issue) => {
4535
4630
  writeIssueFailures(stateDir, next);
4536
4631
  return next;
4537
4632
  };
4538
- var isIssuePaused = (stateDir, issue) => readIssueFailures(stateDir, issue).pausedAt !== null;
4539
4633
  var listPausedIssues = (stateDir) => {
4540
4634
  const dir = join(stateDir, "issues");
4541
4635
  if (!existsSync(dir)) return [];
@@ -4637,9 +4731,14 @@ var writeDispatchRecord = (stateDir, record3) => {
4637
4731
  writeJson2(path, record3);
4638
4732
  return path;
4639
4733
  };
4640
- var appendLoopEvent = (stateDir, event2, bus) => {
4734
+ var EVENTS_ROTATE_AT_BYTES = 10 * 1024 * 1024;
4735
+ var appendLoopEvent = (stateDir, event2, bus, now4 = () => /* @__PURE__ */ new Date()) => {
4641
4736
  const path = join(stateDir, "events.ndjson");
4642
4737
  mkdirSync(dirname(path), { recursive: true });
4738
+ try {
4739
+ if (statSync(path).size > EVENTS_ROTATE_AT_BYTES) renameSync(path, join(stateDir, `events-archive-${now4().getTime()}.ndjson`));
4740
+ } catch {
4741
+ }
4643
4742
  appendFileSync(path, `${JSON.stringify(event2)}
4644
4743
  `, "utf8");
4645
4744
  if (bus && typeof event2["type"] === "string") bus.emit(event2);
@@ -4671,7 +4770,7 @@ var gatherLoopState = async (input) => {
4671
4770
  const leases = input.ledger.active();
4672
4771
  const busy = busyIssues(queue, leases, worktrees, person);
4673
4772
  const candidates = queue.filter((issue) => !busy.has(issue.identifier) && (!input.onlyIssue || issue.identifier === input.onlyIssue));
4674
- return { person, providers, routing, worktrees, slots, queue, leases, busy, candidates };
4773
+ return { person, providers, routing, worktrees, slots, queue, leases, busy, candidates, extrasByRole };
4675
4774
  };
4676
4775
  var precheckTick = async (input) => {
4677
4776
  const loaded = input.loaded ?? loadLoopConfig(input.configPath);
@@ -4712,16 +4811,7 @@ var runTick = async (input) => {
4712
4811
  }
4713
4812
  const state = await gatherLoopState({ loaded, runner: input.runner, ledger, env: input.env, platform: input.platform, now: now4, onlyIssue: input.onlyIssue, machine: input.machine });
4714
4813
  const orchestrator = state.routing["orchestrator"] ?? { role: "orchestrator", selected: null, skipped: [] };
4715
- const orchestratorExtras = config.models.routing.mode === "catalog" ? await resolveCatalogCandidates({
4716
- config,
4717
- role: "orchestrator",
4718
- availableProviderIds: state.providers.filter((provider) => provider.available).map((provider) => provider.id),
4719
- runner: input.runner,
4720
- stateDir: loaded.stateDir,
4721
- env: input.env,
4722
- now: now4
4723
- }) : [];
4724
- const orchestratorCandidates = rankModels(config, "orchestrator", state.providers, orchestratorExtras);
4814
+ const orchestratorCandidates = rankModels(config, "orchestrator", state.providers, state.extrasByRole["orchestrator"] ?? []);
4725
4815
  const onProviderFailure = (failure) => {
4726
4816
  if (dryRun) return;
4727
4817
  const resetsAt = extractResetsAt(failure.detail, now4());
@@ -4741,7 +4831,7 @@ var runTick = async (input) => {
4741
4831
  return { ...base, status: "idle", results, notes };
4742
4832
  }
4743
4833
  if (!state.candidates.length) {
4744
- const rotation = dryRun ? { owner: state.person, advanced: false } : advanceQueueOwner(loaded, { queueEmpty: state.queue.length === 0, activeLeases: state.leases.length, now: now4() });
4834
+ const rotation = dryRun ? { owner: state.person, advanced: false } : advanceQueueOwner(loaded, { queueEmpty: state.queue.length === 0, activeLeases: countRotationBlockingLeases(loaded, state.leases), now: now4() });
4745
4835
  if (rotation.advanced) notes.push(`queue drained for ${state.person}; switched to ${rotation.owner}`);
4746
4836
  else notes.push("queue has no dispatchable candidate");
4747
4837
  return { ...base, status: "idle", results, notes };
@@ -4774,17 +4864,32 @@ The loop will not retry this issue until you remove the \`${config.resilience.pa
4774
4864
  appendLoopEvent(loaded.stateDir, { at: now4().toISOString(), type: "issue.paused", issue, kind, consecutive: failureState.consecutive, reason }, bus);
4775
4865
  await bus.runHook("onPause", { issue, kind, consecutive: failureState.consecutive, reason });
4776
4866
  };
4867
+ let pinnedSkillsOnce;
4868
+ const getPinnedSkills = () => {
4869
+ if (pinnedSkillsOnce === void 0) {
4870
+ try {
4871
+ pinnedSkillsOnce = loadPinnedSkills(loaded.root, config.brief.skills, config.brief.maxSkillChars);
4872
+ } catch (error) {
4873
+ pinnedSkillsOnce = { error };
4874
+ throw error;
4875
+ }
4876
+ }
4877
+ if ("error" in pinnedSkillsOnce) throw pinnedSkillsOnce.error;
4878
+ return pinnedSkillsOnce;
4879
+ };
4777
4880
  let dispatched = 0;
4778
4881
  for (const candidate of state.candidates) {
4779
4882
  if (dispatched >= budget) break;
4780
4883
  const setupBudgetMs = config.project.setup.command ? Number.isFinite(timeBudgetMs) ? Math.min(config.project.setup.timeoutSec * 1e3, Math.max(0, timeBudgetMs - config.contract.timeoutMs - 125e3)) : config.project.setup.timeoutSec * 1e3 : 0;
4781
- if (remainingMs() < config.contract.timeoutMs + setupBudgetMs + 12e4 && !readStoredContract(loaded.stateDir, candidate.identifier)) {
4884
+ const cachedContract = readStoredContract(loaded.stateDir, candidate.identifier);
4885
+ if (remainingMs() < config.contract.timeoutMs + setupBudgetMs + 12e4 && !cachedContract) {
4782
4886
  notes.push(`time budget: ${candidate.identifier} left for the next tick (${Math.round(remainingMs() / 1e3)}s remaining)`);
4783
4887
  continue;
4784
4888
  }
4785
- if (isIssuePaused(loaded.stateDir, candidate.identifier)) {
4889
+ const failureState = readIssueFailures(loaded.stateDir, candidate.identifier);
4890
+ if (failureState.pausedAt !== null) {
4786
4891
  if (candidate.labels.includes(config.resilience.pausedLabel)) {
4787
- results.push({ issue: candidate.identifier, outcome: "skipped", reason: `paused after ${readIssueFailures(loaded.stateDir, candidate.identifier).consecutive} consecutive failures; remove the "${config.resilience.pausedLabel}" label or run "ak-harness loop resume ${candidate.identifier}" to retry` });
4892
+ results.push({ issue: candidate.identifier, outcome: "skipped", reason: `paused after ${failureState.consecutive} consecutive failures; remove the "${config.resilience.pausedLabel}" label or run "ak-harness loop resume ${candidate.identifier}" to retry` });
4788
4893
  continue;
4789
4894
  }
4790
4895
  if (!dryRun) clearIssueFailures(loaded.stateDir, candidate.identifier);
@@ -4797,8 +4902,8 @@ The loop will not retry this issue until you remove the \`${config.resilience.pa
4797
4902
  results.push({ issue: candidate.identifier, outcome: "failed", reason: `issue fetch failed: ${message2(error)}` });
4798
4903
  continue;
4799
4904
  }
4800
- let stored = readStoredContract(loaded.stateDir, detail.identifier);
4801
- const memoryProbe = memory ? await planMemoryContext({
4905
+ let stored = cachedContract;
4906
+ const memoryPlan = memory ? await planMemoryContext({
4802
4907
  adapter: memory,
4803
4908
  config,
4804
4909
  issueId: detail.identifier,
@@ -4806,7 +4911,7 @@ The loop will not retry this issue until you remove the \`${config.resilience.pa
4806
4911
  project: config.project.name,
4807
4912
  references: []
4808
4913
  }) : null;
4809
- if (stored && !contractIsFresh(stored, detail, config.contract.reuseHours, now4(), memoryProbe?.memoryDigest)) stored = null;
4914
+ if (stored && !contractIsFresh(stored, detail, config.contract.reuseHours, now4(), memoryPlan?.memoryDigest)) stored = null;
4810
4915
  if (!stored) {
4811
4916
  if (input.skipContractGeneration) {
4812
4917
  results.push({ issue: detail.identifier, outcome: "skipped", reason: "no cached contract; generation skipped" });
@@ -4906,16 +5011,9 @@ The loop will not retry this issue until you remove the \`${config.resilience.pa
4906
5011
  }
4907
5012
  if (setupFailed) notes.push(`${detail.identifier}: setup command failed but project.setup.required is false \u2014 continuing`);
4908
5013
  }
4909
- const briefMemory = memory ? await planMemoryContext({
4910
- adapter: memory,
4911
- config,
4912
- issueId: detail.identifier,
4913
- issueTitle: detail.title,
4914
- project: config.project.name,
4915
- references: []
4916
- }) : { memoryBlock: "", issueCharBudget: config.contract.maxIssueChars, hits: [] };
5014
+ const briefMemory = memoryPlan ?? { memoryBlock: "", issueCharBudget: config.contract.maxIssueChars, hits: [] };
4917
5015
  const guidanceRefs = config.contract.maxBriefReferences > 0 && config.contract.briefScopes.length ? await resolveDocContext(loaded.root, `${detail.identifier} ${detail.title}`, config.contract.maxBriefReferences, config.contract.briefScopes) : [];
4918
- const pinnedSkills = loadPinnedSkills(loaded.root, config.brief.skills, config.brief.maxSkillChars);
5016
+ const pinnedSkills = getPinnedSkills();
4919
5017
  const brief = renderWorkerBrief({
4920
5018
  issue: detail,
4921
5019
  contract: stored,
@@ -5245,7 +5343,7 @@ var providerUnavailable = (ctx, providerId) => {
5245
5343
  return !match || !match.available;
5246
5344
  };
5247
5345
  var pickHandoffBuilder = (ctx, record3) => {
5248
- const ranked = rankModels(ctx.config, "builder", ctx.providers);
5346
+ const ranked = rankModels(ctx.config, "builder", ctx.providers, ctx.builderExtras);
5249
5347
  const different = ranked.find((candidate) => candidate.provider !== record3.provider || candidate.model !== record3.model);
5250
5348
  return different ?? null;
5251
5349
  };
@@ -5686,7 +5784,8 @@ var runDeliver = async (input) => {
5686
5784
  const availableIds = providers.filter((provider) => provider.available).map((provider) => provider.id);
5687
5785
  const catalogExtras = async (role) => config.models.routing.mode === "catalog" ? resolveCatalogCandidates({ config, role, availableProviderIds: availableIds, runner: input.runner, stateDir: loaded.stateDir, env: input.env, now: now4 }) : Promise.resolve([]);
5688
5786
  const reviewer = rankModels(config, "reviewer", providers, await catalogExtras("reviewer"))[0] ?? null;
5689
- const builder = rankModels(config, "builder", providers, await catalogExtras("builder"))[0] ?? null;
5787
+ const builderExtras = await catalogExtras("builder");
5788
+ const builder = rankModels(config, "builder", providers, builderExtras)[0] ?? null;
5690
5789
  let env = input.env ?? process.env;
5691
5790
  if (!env["GITHUB_TOKEN"] && !env["GH_TOKEN"]) {
5692
5791
  try {
@@ -5702,13 +5801,14 @@ var runDeliver = async (input) => {
5702
5801
  const { errors } = await loadLoopPlugins(loaded.root, config.plugins.modules, bus);
5703
5802
  for (const failure of errors) notes.push(`plugin ${failure.path} failed to load: ${failure.error}`);
5704
5803
  }
5705
- const ctx = { loaded, config, runner: input.runner, now: now4, dryRun, reviewer, builder, providers, env, ...input.assumeIdle === void 0 ? {} : { assumeIdle: input.assumeIdle }, notes, reviewDeadlineMs, bus };
5804
+ const ctx = { loaded, config, runner: input.runner, now: now4, dryRun, reviewer, builder, providers, env, ...input.assumeIdle === void 0 ? {} : { assumeIdle: input.assumeIdle }, notes, reviewDeadlineMs, bus, builderExtras };
5706
5805
  const ledger = createDispatchLedger(loaded.stateDir);
5707
5806
  const leases = new Map(ledger.active().map((lease) => [lease.issue, lease]));
5708
5807
  const results = [];
5709
5808
  for (const record3 of listDispatched(loaded.stateDir)) {
5710
5809
  if (input.onlyIssue && record3.issue !== input.onlyIssue) continue;
5711
5810
  let state = readDeliveryState(loaded.stateDir, record3.issue);
5811
+ if (state.finishedAt && state.finalOutcome === "merged") continue;
5712
5812
  const lease = leases.get(record3.issue);
5713
5813
  if (!state.finishedAt) {
5714
5814
  const ageMinutes = minutesBetween(now4(), record3.dispatchedAt);
@@ -5747,7 +5847,6 @@ var runDeliver = async (input) => {
5747
5847
  results.push(await handlePullRequest(ctx, record3, lease, state, pr));
5748
5848
  continue;
5749
5849
  }
5750
- if (state.finishedAt && state.finalOutcome === "merged") continue;
5751
5850
  const recordedMerge = readMergedEvent(loaded.stateDir, record3.issue);
5752
5851
  if (recordedMerge) {
5753
5852
  try {
@@ -6308,10 +6407,19 @@ ${step && total ? `${step}/${total} ` : ""}${title}`),
6308
6407
  ] }))
6309
6408
  };
6310
6409
  };
6410
+ var newestIssueMtimeMs = (stateDir, issue) => {
6411
+ const mtimes = [dispatchRecordPath(stateDir, issue), deliveryStatePath(stateDir, issue), contractPath(stateDir, issue)].map((path) => {
6412
+ try {
6413
+ return statSync(path).mtimeMs;
6414
+ } catch {
6415
+ return null;
6416
+ }
6417
+ }).filter((value) => value !== null);
6418
+ return mtimes.length ? Math.max(...mtimes) : null;
6419
+ };
6311
6420
  var HARNESS_REPO_URL = "https://github.com/AgentsKit-io/harness";
6312
6421
  var isRecord13 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6313
- var readLoopEvents = (stateDir) => {
6314
- const path = join(stateDir, "events.ndjson");
6422
+ var parseEventsFile = (path) => {
6315
6423
  if (!existsSync(path)) return [];
6316
6424
  return readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean).flatMap((line2) => {
6317
6425
  try {
@@ -6322,6 +6430,11 @@ var readLoopEvents = (stateDir) => {
6322
6430
  }
6323
6431
  });
6324
6432
  };
6433
+ var eventsArchivePattern = /^events-archive-(\d+)\.ndjson$/;
6434
+ var readLoopEvents = (stateDir, sinceMs) => {
6435
+ const archives = existsSync(stateDir) ? readdirSync(stateDir).map((name2) => name2.match(eventsArchivePattern)).filter((match) => match !== null).map((match) => ({ path: join(stateDir, match[0]), rotatedAtMs: Number(match[1]) })).filter((archive) => sinceMs === void 0 || archive.rotatedAtMs >= sinceMs).sort((a, b) => a.rotatedAtMs - b.rotatedAtMs) : [];
6436
+ return [...archives.flatMap((archive) => parseEventsFile(archive.path)), ...parseEventsFile(join(stateDir, "events.ndjson"))];
6437
+ };
6325
6438
  var parseSince = (value, now4) => {
6326
6439
  if (!value) return new Date(now4.getTime() - 7 * 864e5);
6327
6440
  const match = value.match(/^(\d+)([dhm])$/);
@@ -6368,10 +6481,11 @@ var buildSuggestions = (input) => {
6368
6481
  var buildRetroReport = async (input) => {
6369
6482
  const loaded = input.loaded ?? loadLoopConfig(input.configPath);
6370
6483
  const { config } = loaded;
6484
+ const person = queueOwner(loaded);
6371
6485
  const now4 = (input.now ?? (() => /* @__PURE__ */ new Date()))();
6372
6486
  const since = parseSince(input.since, now4);
6373
6487
  const inWindow = (at) => typeof at === "string" && Date.parse(at) >= since.getTime() && Date.parse(at) <= now4.getTime();
6374
- const events2 = readLoopEvents(loaded.stateDir).filter((event2) => inWindow(event2.at));
6488
+ const events2 = readLoopEvents(loaded.stateDir, since.getTime()).filter((event2) => inWindow(event2.at));
6375
6489
  const counts = {};
6376
6490
  for (const event2 of events2) counts[event2.type] = (counts[event2.type] ?? 0) + 1;
6377
6491
  const escalations = events2.filter((event2) => event2.type === "contract.escalated");
@@ -6393,6 +6507,8 @@ var buildRetroReport = async (input) => {
6393
6507
  if (existsSync(issuesDir)) for (const entry of readdirSync(issuesDir, { withFileTypes: true })) {
6394
6508
  if (!entry.isDirectory()) continue;
6395
6509
  const issue = entry.name;
6510
+ const newestMtime = newestIssueMtimeMs(loaded.stateDir, issue);
6511
+ if (newestMtime !== null && newestMtime < since.getTime()) continue;
6396
6512
  const dispatch = readDispatchRecord(loaded.stateDir, issue);
6397
6513
  const delivery2 = readDeliveryState(loaded.stateDir, issue);
6398
6514
  const contract = readStoredContract(loaded.stateDir, issue);
@@ -6450,7 +6566,7 @@ var buildRetroReport = async (input) => {
6450
6566
  else if (status2 === "ok") work += 1;
6451
6567
  }
6452
6568
  }
6453
- orca = { runs, idle, work, timedOut, avgDurationSec: durations.length ? Math.round(durations.reduce((sum, value) => sum + value, 0) / durations.length) : null, maxDurationSec: durations.length ? Math.round(Math.max(...durations)) : null };
6569
+ orca = { runs, idle, work, timedOut, avgDurationSec: durations.length ? Math.round(durations.reduce((sum2, value) => sum2 + value, 0) / durations.length) : null, maxDurationSec: durations.length ? Math.round(Math.max(...durations)) : null };
6454
6570
  } catch {
6455
6571
  orca = null;
6456
6572
  }
@@ -6459,9 +6575,9 @@ var buildRetroReport = async (input) => {
6459
6575
  generatedAt: now4.toISOString(),
6460
6576
  window: { since: since.toISOString(), until: now4.toISOString(), days: Number(((now4.getTime() - since.getTime()) / 864e5).toFixed(2)) },
6461
6577
  project: config.project.repo,
6462
- person: config.linear.person,
6578
+ person,
6463
6579
  counts,
6464
- escalations: { total: escalations.length, issues: [...new Set(escalations.map((event2) => String(event2.issue ?? "?")))], reasons: [...reasonCounts.entries()].map(([reason2, count2]) => ({ reason: reason2, count: count2 })).sort((left, right) => right.count - left.count) },
6580
+ escalations: { total: escalations.length, issues: [...new Set(escalations.map((event2) => String(event2.issue ?? "?")))], reasons: [...reasonCounts.entries()].map(([reason2, count3]) => ({ reason: reason2, count: count3 })).sort((left, right) => right.count - left.count) },
6465
6581
  dispatches: { total: dispatchEvents.length, failed: counts["worker.dispatch-failed"] ?? 0, byProvider },
6466
6582
  delivery: { merged: tally("merged"), blocked: tally("blocked"), stuck: tally("stuck"), abandoned: tally("abandoned"), inFlight: tally("in-flight"), fixRounds, reviewsClean, reviewsFindings, reviewsIncomplete, medianLeadTimeMin: median2(rows.map((row) => row.leadTimeMin).filter((value) => value !== null)) },
6467
6583
  providers: { cooldowns, cooldownEvents: counts["provider.cooldown"] ?? 0 },
@@ -6488,7 +6604,7 @@ var renderRetroMarkdown = (report) => {
6488
6604
  if (report.orca) lines.push(`| Orca runs (idle / work / timed out) | ${report.orca.runs} (${report.orca.idle} / ${report.orca.work} / ${report.orca.timedOut}) \xB7 avg ${report.orca.avgDurationSec ?? "\u2014"} s \xB7 max ${report.orca.maxDurationSec ?? "\u2014"} s |`);
6489
6605
  lines.push("");
6490
6606
  if (Object.keys(report.dispatches.byProvider).length) {
6491
- lines.push("## Providers", "", ...Object.entries(report.dispatches.byProvider).map(([key, count2]) => `- ${key}: ${count2} dispatch(es)`), ...report.providers.cooldowns.map((row) => `- cooldown ${row.provider} until ${row.until.slice(0, 16)}Z \u2014 ${row.reason}`), "");
6607
+ lines.push("## Providers", "", ...Object.entries(report.dispatches.byProvider).map(([key, count3]) => `- ${key}: ${count3} dispatch(es)`), ...report.providers.cooldowns.map((row) => `- cooldown ${row.provider} until ${row.until.slice(0, 16)}Z \u2014 ${row.reason}`), "");
6492
6608
  }
6493
6609
  if (report.escalations.reasons.length) {
6494
6610
  lines.push("## Problems", "", ...report.escalations.reasons.map((row) => `- ${row.count}\xD7 ${row.reason}`), ...report.issues.filter((row) => ["blocked", "stuck", "abandoned"].includes(row.outcome)).map((row) => `- ${row.issue} ${row.outcome}${row.pr ? ` (PR #${row.pr})` : ""} after ${row.fixRounds} fix round(s), ${row.nudges} nudge(s)`), "");
@@ -6669,7 +6785,7 @@ var buildDebriefReport = (input) => {
6669
6785
  }
6670
6786
  const inFlight = rows.filter((row) => !row.finalOutcome && row.phase !== "escalated");
6671
6787
  const held = rows.filter((row) => row.phase === "held" || row.phase === "held-incomplete-review" || row.heldFor);
6672
- const events2 = readLoopEvents(stateDir).filter((event2) => Date.parse(event2.at) >= since.getTime());
6788
+ const events2 = readLoopEvents(stateDir, since.getTime()).filter((event2) => Date.parse(event2.at) >= since.getTime());
6673
6789
  const recentEscalations = events2.filter((event2) => event2.type === "contract.escalated").slice(-10).map((event2) => ({
6674
6790
  issue: typeof event2.issue === "string" ? event2.issue : "?",
6675
6791
  at: event2.at,
@@ -6751,6 +6867,138 @@ var renderDebriefMarkdown = (report) => {
6751
6867
  lines.push("_Read-only. Run `ak-harness loop deliver` / `tick` to act; `loop retro` for the weekly digest._");
6752
6868
  return lines.join("\n");
6753
6869
  };
6870
+ var connectedStatuses = /* @__PURE__ */ new Set(["connected", "running", "active", "idle"]);
6871
+ var stalledPhases = /* @__PURE__ */ new Set(["waiting-for-pr", "awaiting-review", "review-incomplete", "fix-round"]);
6872
+ var heldRow = (row) => Boolean(row.heldFor) || row.phase === "held" || row.phase === "held-incomplete-review";
6873
+ var count2 = (events2, type) => events2.filter((event2) => event2.type === type).length;
6874
+ var sum = (events2, key) => events2.reduce((total, event2) => total + (typeof event2[key] === "number" && Number.isFinite(event2[key]) ? Number(event2[key]) : 0), 0);
6875
+ var uniqueIssues = (events2, types) => new Set(events2.filter((event2) => types.includes(event2.type) && typeof event2.issue === "string").map((event2) => event2.issue)).size;
6876
+ var assessObservability = (input) => {
6877
+ const anomalies = [];
6878
+ for (const issue of input.missingDeliveryIssues) anomalies.push({ id: "claim-without-delivery", severity: "action_required", issue, message: `${issue} has an active claim but no delivery.json`, evidence: { issue } });
6879
+ for (const terminal2 of input.terminals) {
6880
+ if (terminal2.worktreeId && connectedStatuses.has(terminal2.status.toLowerCase()) && terminal2.lastOutputAt === null && !terminal2.preview.trim()) anomalies.push({ id: "connected-without-output", severity: "warning", issue: null, message: `terminal ${terminal2.handle} is connected but has not emitted output`, evidence: { handle: terminal2.handle, worktreeId: terminal2.worktreeId, status: terminal2.status } });
6881
+ }
6882
+ for (const worktree of input.finalizedDirtyWorktrees) anomalies.push({ id: "finalized-dirty-worktree", severity: "action_required", issue: worktree.issue, message: `finalized worktree ${worktree.worktreeId} still has ${worktree.files} uncommitted file(s)`, evidence: { ...worktree } });
6883
+ const latestDispatch = input.events.filter((event2) => event2.type === "worker.dispatched").map((event2) => Date.parse(event2.at)).filter(Number.isFinite).sort((a, b) => b - a)[0];
6884
+ const quietForMin = latestDispatch === void 0 ? Number.POSITIVE_INFINITY : Math.max(0, Math.round((Date.parse(input.generatedAt) - latestDispatch) / 6e4));
6885
+ if (input.queueReady > 0 && input.freeSlots > 0 && quietForMin >= 15) anomalies.push({ id: "queue-ready-no-dispatch", severity: "action_required", issue: null, message: `${input.queueReady} ready issue(s) and ${input.freeSlots} free slot(s), but no dispatch in ${Number.isFinite(quietForMin) ? `${quietForMin} min` : "the observation window"}`, evidence: { queueReady: input.queueReady, freeSlots: input.freeSlots, quietForMin } });
6886
+ for (const row of input.issues) {
6887
+ if (row.heldFor || !stalledPhases.has(row.phase) || row.ageMin === null || row.ageMin < input.workerIdleTimeoutMin) continue;
6888
+ anomalies.push({ id: "stalled-delivery", severity: "action_required", issue: row.issue, message: `${row.issue} is in ${row.phase} for ${row.ageMin} min (threshold ${input.workerIdleTimeoutMin} min)`, evidence: { issue: row.issue, phase: row.phase, ageMin: row.ageMin, thresholdMin: input.workerIdleTimeoutMin } });
6889
+ }
6890
+ const events2 = {};
6891
+ for (const event2 of input.events) events2[event2.type] = (events2[event2.type] ?? 0) + 1;
6892
+ const report = {
6893
+ status: anomalies.some((item) => item.severity === "action_required") ? "action_required" : "healthy",
6894
+ generatedAt: input.generatedAt,
6895
+ project: input.project,
6896
+ person: input.person,
6897
+ windowHours: input.windowHours,
6898
+ anomalies,
6899
+ metrics: {
6900
+ queueReady: input.queueReady,
6901
+ freeSlots: input.freeSlots,
6902
+ runningWorkers: input.runningWorkers,
6903
+ maxAgents: input.maxAgents,
6904
+ activeClaims: input.activeClaims,
6905
+ inFlight: input.issues.filter((row) => !heldRow(row)).length,
6906
+ held: input.issues.filter(heldRow).length,
6907
+ merged: input.merged,
6908
+ blocked: input.blocked,
6909
+ fixRounds: input.fixRounds,
6910
+ reviewFindings: input.reviewFindings,
6911
+ reviewIncomplete: input.reviewIncomplete,
6912
+ medianLeadTimeMin: input.medianLeadTimeMin,
6913
+ providerRemainingPercent: input.providerRemainingPercent,
6914
+ machine: input.machine,
6915
+ memory: input.memory,
6916
+ cache: input.cache,
6917
+ tokens: input.tokens,
6918
+ events: events2
6919
+ }
6920
+ };
6921
+ return report;
6922
+ };
6923
+ var compactTerminal = (terminal2) => ({ handle: terminal2.handle, status: terminal2.status, worktreeId: terminal2.worktreeId, lastOutputAt: terminal2.lastOutputAt, preview: terminal2.preview });
6924
+ var dirtyFinalizedWorktrees = async (runner, worktrees) => {
6925
+ const out = [];
6926
+ for (const worktree of worktrees) {
6927
+ if (worktree.workspaceStatus.trim().toLowerCase() !== "completed" || !worktree.path) continue;
6928
+ try {
6929
+ const result = await runner.run(["git", "-C", worktree.path, "status", "--porcelain"], { timeoutMs: 1e4 });
6930
+ if (result.code === 0 && result.stdout.trim()) out.push({ worktreeId: worktree.id, issue: worktree.linkedLinearIssue, files: result.stdout.trim().split(/\r?\n/).length });
6931
+ } catch {
6932
+ }
6933
+ }
6934
+ return out;
6935
+ };
6936
+ var runObservability = async (input) => {
6937
+ const loaded = input.loaded ?? loadLoopConfig(input.configPath ?? "loop.config.yaml");
6938
+ const now4 = input.now ?? (() => /* @__PURE__ */ new Date());
6939
+ const at = now4();
6940
+ const since = parseSince(input.since ?? "24h", at);
6941
+ const [doctor, debrief, worktrees, terminals] = await Promise.all([
6942
+ runLoopDoctor({ loaded, runner: input.runner, env: input.env, platform: input.platform, now: () => at, probe: false }),
6943
+ Promise.resolve(buildDebriefReport({ loaded, since: input.since ?? "24h", now: () => at })),
6944
+ orcaWorktrees(input.runner, { bin: loaded.config.orca.bin, timeoutMs: loaded.config.orca.timeoutMs }).catch(() => []),
6945
+ orcaTerminalList(input.runner, {}, { bin: loaded.config.orca.bin, timeoutMs: loaded.config.orca.timeoutMs }).catch(() => [])
6946
+ ]);
6947
+ const events2 = readLoopEvents(loaded.stateDir).filter((event2) => Date.parse(event2.at) >= since.getTime() && Date.parse(event2.at) <= at.getTime());
6948
+ const ledger = createDispatchLedger(loaded.stateDir);
6949
+ const active = ledger.active();
6950
+ const missingDeliveryIssues = active.filter((lease) => !existsSync(deliveryStatePath(loaded.stateDir, lease.issue))).map((lease) => lease.issue);
6951
+ const records = listDispatched(loaded.stateDir);
6952
+ const completed = records.map((record3) => ({ record: record3, state: readDeliveryState(loaded.stateDir, record3.issue) })).filter(({ state }) => state.finishedAt && Date.parse(state.finishedAt) >= since.getTime());
6953
+ const leadTimes = completed.map(({ record: record3, state }) => state.finishedAt ? (Date.parse(state.finishedAt) - Date.parse(record3.dispatchedAt)) / 6e4 : null).filter((value) => value !== null && Number.isFinite(value)).sort((a, b) => a - b);
6954
+ const medianLeadTimeMin = leadTimes.length ? leadTimes.length % 2 ? leadTimes[Math.floor(leadTimes.length / 2)] : (leadTimes[leadTimes.length / 2 - 1] + leadTimes[leadTimes.length / 2]) / 2 : null;
6955
+ const providerRemainingPercent = Object.fromEntries(doctor.providers.map((provider) => [provider.id, remainingUsagePercent(provider.usage, loaded.config.models.routing.usageMetric)]));
6956
+ const cachedContracts = records.filter((record3) => existsSync(contractPath(loaded.stateDir, record3.issue))).length;
6957
+ const memoryEvents = events2.filter((event2) => event2.type === "memory.recalled");
6958
+ const tokens = { input: sum(events2, "inputTokens"), output: sum(events2, "outputTokens"), total: sum(events2, "totalTokens"), cacheRead: sum(events2, "cacheReadTokens"), cacheWrite: sum(events2, "cacheWriteTokens") };
6959
+ const machine = { cpuCount: doctor.machine.sample.cpus, load1PerCpuPercent: doctor.machine.sample.load1PerCpuPercent, memoryUsedPercent: doctor.machine.sample.memoryUsedPercent, freeRamGb: doctor.machine.freeRamGb };
6960
+ const snapshot = {
6961
+ generatedAt: at.toISOString(),
6962
+ project: doctor.config.project,
6963
+ person: doctor.config.person,
6964
+ windowHours: Math.max(1, Math.round((at.getTime() - since.getTime()) / 36e5)),
6965
+ workerIdleTimeoutMin: loaded.config.delivery.workerIdleTimeoutMin,
6966
+ queueReady: doctor.queue.count,
6967
+ freeSlots: doctor.machine.free,
6968
+ runningWorkers: doctor.workers.running,
6969
+ maxAgents: doctor.machine.maxAgents,
6970
+ activeClaims: active.length,
6971
+ missingDeliveryIssues,
6972
+ terminals: terminals.map(compactTerminal),
6973
+ finalizedDirtyWorktrees: await dirtyFinalizedWorktrees(input.runner, worktrees),
6974
+ issues: debrief.inFlight.map(({ issue, phase, ageMin, heldFor }) => ({ issue, phase, ageMin, heldFor })),
6975
+ events: events2,
6976
+ merged: uniqueIssues(events2, ["pr.merged", "worker.merged"]),
6977
+ blocked: Math.max(records.filter((record3) => readDeliveryState(loaded.stateDir, record3.issue).finalOutcome === "blocked").length, uniqueIssues(events2, ["worker.blocked"])),
6978
+ fixRounds: records.reduce((total, record3) => total + readDeliveryState(loaded.stateDir, record3.issue).fixRounds, 0),
6979
+ reviewFindings: count2(events2, "pr.reviewed") - count2(events2.filter((event2) => event2["status"] !== "findings"), "pr.reviewed"),
6980
+ reviewIncomplete: events2.filter((event2) => event2.type === "pr.reviewed" && event2["status"] === "incomplete").length,
6981
+ medianLeadTimeMin,
6982
+ providerRemainingPercent,
6983
+ machine,
6984
+ memory: { recalls: memoryEvents.length, hits: sum(memoryEvents, "hits"), approxCharsSaved: sum(memoryEvents, "approxCharsSaved") },
6985
+ cache: { cachedContracts },
6986
+ tokens
6987
+ };
6988
+ return assessObservability(snapshot);
6989
+ };
6990
+ var renderObservabilityMarkdown = (report) => {
6991
+ const m = report.metrics;
6992
+ const headroom = Object.entries(m.providerRemainingPercent).map(([provider, remaining]) => `${provider} ${remaining === null ? "?" : `${remaining}%`}`).join(", ");
6993
+ const lines = [`# Loop observability \u2014 ${report.project} \xB7 ${report.person}`, "", `_${report.status}_ \xB7 generated ${report.generatedAt.slice(0, 19)}Z \xB7 last ${report.windowHours}h`, "", "## Metrics", "", `- Queue: ${m.queueReady} ready \xB7 ${m.freeSlots} free slot(s) \xB7 ${m.runningWorkers}/${m.maxAgents} workers`, `- Delivery: ${m.inFlight} in flight \xB7 ${m.held} held \xB7 ${m.merged} merged \xB7 ${m.blocked} blocked \xB7 ${m.fixRounds} fix round(s)`, `- Reviews: ${m.reviewFindings} findings \xB7 ${m.reviewIncomplete} incomplete`, `- Machine: ${m.machine.cpuCount} CPU \xB7 ${m.machine.load1PerCpuPercent}% load \xB7 ${m.machine.memoryUsedPercent}% memory \xB7 ${m.machine.freeRamGb} GB free`, `- Providers: ${headroom || "n/a"}`, `- Memory/cache: ${m.memory.recalls} recall(s), ${m.memory.hits} hit(s), ${m.memory.approxCharsSaved} chars saved \xB7 ${m.cache.cachedContracts} cached contract(s)`, `- Tokens observed: ${m.tokens.total || m.tokens.input + m.tokens.output || "n/a"}`, ""];
6994
+ if (report.anomalies.length) {
6995
+ lines.push("## Anomalies", "");
6996
+ for (const anomaly of report.anomalies) lines.push(`- **${anomaly.severity}**${anomaly.issue ? ` \xB7 ${anomaly.issue}` : ""}: ${anomaly.message}`);
6997
+ lines.push("");
6998
+ } else lines.push("## Anomalies", "", "_None detected._", "");
6999
+ lines.push("_Read-only. Run `ak-harness loop tick` or `deliver` to act on the queue._");
7000
+ return lines.join("\n");
7001
+ };
6754
7002
 
6755
7003
  // src/loop/watch.ts
6756
7004
  var defaultSleep = (ms) => new Promise((resolve9) => setTimeout(resolve9, ms));
@@ -6887,6 +7135,32 @@ var print = (value) => {
6887
7135
  if (options().json) console.log(JSON.stringify(value));
6888
7136
  else console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
6889
7137
  };
7138
+ var acquireStageLock = (stateDir, stage) => {
7139
+ const path = join(stateDir, `.stage-${stage}.lock`);
7140
+ mkdirSync(stateDir, { recursive: true });
7141
+ try {
7142
+ const fd = openSync(path, "wx");
7143
+ writeFileSync(fd, `${JSON.stringify({ pid: process.pid, stage, at: (/* @__PURE__ */ new Date()).toISOString() })}
7144
+ `, "utf8");
7145
+ closeSync(fd);
7146
+ return () => {
7147
+ try {
7148
+ unlinkSync(path);
7149
+ } catch {
7150
+ }
7151
+ };
7152
+ } catch (error) {
7153
+ if (error.code !== "EEXIST") throw error;
7154
+ try {
7155
+ if (Date.now() - statSync(path).mtimeMs > 30 * 6e4) {
7156
+ unlinkSync(path);
7157
+ return acquireStageLock(stateDir, stage);
7158
+ }
7159
+ } catch {
7160
+ }
7161
+ return null;
7162
+ }
7163
+ };
6890
7164
  var readBenchmarkEvidence = (path) => {
6891
7165
  try {
6892
7166
  const content = readFileSync(path, "utf8");
@@ -6991,6 +7265,12 @@ loop.command("stage <stage>").description("Run one stage (tick | deliver | retro
6991
7265
  process.exitCode = 1;
6992
7266
  return;
6993
7267
  }
7268
+ const stageLock = acquireStageLock(loaded.stateDir, stage);
7269
+ if (!stageLock) {
7270
+ console.log(JSON.stringify({ status: "locked", stage, reason: "another stage run is still active" }, null, 2));
7271
+ process.exitCode = 1;
7272
+ return;
7273
+ }
6994
7274
  const budgetMs = Math.max(6e4, loaded.config.schedule.stageTimeoutSec * 1e3 - 6e4);
6995
7275
  const threshold = loaded.config.resilience.stagePauseAfterRuns;
6996
7276
  try {
@@ -7001,6 +7281,8 @@ loop.command("stage <stage>").description("Run one stage (tick | deliver | retro
7001
7281
  const reason = error instanceof Error ? error.message : String(error);
7002
7282
  const entry = stage !== "retro" ? recordStageRunResult(loaded.stateDir, trackedStage, { succeeded: false, reason }, threshold) : null;
7003
7283
  console.log(JSON.stringify({ status: "error", stage, error: reason, ...entry ? { consecutiveFailures: entry.consecutiveFailures, paused: entry.pausedAt !== null } : {} }, null, 2));
7284
+ } finally {
7285
+ stageLock();
7004
7286
  }
7005
7287
  process.exitCode = 1;
7006
7288
  });
@@ -7080,6 +7362,13 @@ loop.command("debrief").description("Human-facing explanation of what the loop i
7080
7362
  if (options().json) return print(report);
7081
7363
  console.log(renderDebriefMarkdown(report));
7082
7364
  });
7365
+ loop.command("observe").description("Read-only anomaly scan and operating metrics for the loop (queue, workers, delivery, machine, memory, cache, tokens).").option("--since <window>", "window such as 24h, 7d or an ISO date", "24h").option("--precheck", "exit 0 when an action is required, 1 when healthy (for schedulers)").action(async function(command) {
7366
+ const report = await runObservability({ configPath: loopFile(this), runner: createProcessRunner(), since: command.since });
7367
+ if (options().json) print(report);
7368
+ else console.log(renderObservabilityMarkdown(report));
7369
+ if (command.precheck) process.exitCode = report.status === "action_required" ? 0 : 1;
7370
+ else if (report.status === "action_required") process.exitCode = 2;
7371
+ });
7083
7372
  loop.command("watch").description("Watch delivery.json (+ optional live PR) for in-flight issues; prints DONE / FAILED / ACTION_REQUIRED / PROGRESS. Read-only.").option("--issue <identifier>", "restrict to one issue").option("--interval <seconds>", "poll interval", (value) => Number(value), 30).option("--once", "single snapshot then exit").option("--timeout <seconds>", "stop after N seconds (0 = until terminal)", (value) => Number(value), 0).option("--no-live-pr", "do not call gh; filesystem state only").action(async function(command) {
7084
7373
  const report = await watchDeliveries({
7085
7374
  configPath: loopFile(this),