@mrciphersmith/keryx 0.2.106 → 0.2.108

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/README.md CHANGED
@@ -325,7 +325,12 @@ Grouped by what you are trying to do, not by internal module layout.
325
325
  trends.
326
326
  - **review** — managed review packages, standalone under `.metaproject/reviews/`
327
327
  or inside the flow package when attached to a flow, so review findings become
328
- durable project artifacts.
328
+ durable project artifacts. A finding points at the code it **quotes**: the
329
+ line is derived by locating that quote at the commit the round records, not
330
+ taken on the reviewer's word, and a quote that cannot be found is marked
331
+ unlocatable rather than carrying a number nobody checked. Each round also
332
+ states its price — estimated before dispatch, recorded after, and divided by
333
+ the findings that survived it.
329
334
 
330
335
  **Operate agents**
331
336
 
package/dist/cli.js CHANGED
@@ -48579,7 +48579,7 @@ import path47 from "path";
48579
48579
  // package.json
48580
48580
  var package_default = {
48581
48581
  name: "@mrciphersmith/keryx",
48582
- version: "0.2.106",
48582
+ version: "0.2.108",
48583
48583
  description: "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
48584
48584
  private: false,
48585
48585
  publishConfig: {
@@ -62741,7 +62741,7 @@ ${learningSourceLessons(selection).map((lesson) => `- ${lesson}
62741
62741
 
62742
62742
  // src/review/managed.ts
62743
62743
  init_validator();
62744
- import { mkdir as mkdir53, readFile as readFile86, readdir as readdir28, rm as rm11 } from "fs/promises";
62744
+ import { mkdir as mkdir53, readFile as readFile86, readdir as readdir28, realpath as realpath8, rm as rm11, stat as stat16 } from "fs/promises";
62745
62745
  import path153 from "path";
62746
62746
  init_fs();
62747
62747
  init_store2();
@@ -64692,6 +64692,163 @@ function renderFilterStatsLine(stats) {
64692
64692
  return `filter_stats: total=${show(stats.total)} prefilter=${show(stats.dropped_prefilter)} low_confidence=${show(stats.dropped_low_confidence)} refuted=${show(stats.dropped_refuted)} scope_b=${show(stats.dropped_scope_b)} findings_cap=${show(stats.dropped_findings_cap)} dismissed_by_round=${show(stats.dismissed_by_round)} retained=${show(stats.retained)}`;
64693
64693
  }
64694
64694
 
64695
+ // src/review/locate.ts
64696
+ var MAX_QUOTE_LINES = 200;
64697
+ var MAX_LOCATE_FILE_LINES = 50000;
64698
+ var MAX_LOCATE_FILE_BYTES = 4000000;
64699
+ function quoteLines(quote) {
64700
+ const lines = quote.replace(/\r\n/g, `
64701
+ `).split(`
64702
+ `);
64703
+ while (lines.length > 0 && lines[0].trim() === "") {
64704
+ lines.shift();
64705
+ }
64706
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") {
64707
+ lines.pop();
64708
+ }
64709
+ if (lines.length === 0) {
64710
+ return [];
64711
+ }
64712
+ const indents = lines.filter((line) => line.trim() !== "").map((line) => (line.match(/^[ \t]*/)?.[0] ?? "").length);
64713
+ const common = indents.length === 0 ? 0 : Math.min(...indents);
64714
+ return lines.map((line) => line.slice(common));
64715
+ }
64716
+ function normalise(line) {
64717
+ return line.trim().replace(/\s+/g, " ");
64718
+ }
64719
+ function deindent(lines) {
64720
+ const indents = lines.filter((line) => line.trim() !== "").map((line) => (line.match(/^[ \t]*/)?.[0] ?? "").length);
64721
+ const common = indents.length === 0 ? 0 : Math.min(...indents);
64722
+ return lines.map((line) => line.slice(common));
64723
+ }
64724
+ var MAX_HITS = 2;
64725
+ function findRuns(haystack, needle, shape) {
64726
+ const hits = [];
64727
+ if (needle.length === 0 || needle.length > haystack.length) {
64728
+ return hits;
64729
+ }
64730
+ for (let start = 0;start + needle.length <= haystack.length; start += 1) {
64731
+ const window = shape(haystack.slice(start, start + needle.length));
64732
+ if (window.every((line, offset) => line === needle[offset])) {
64733
+ hits.push(start + 1);
64734
+ if (hits.length >= MAX_HITS) {
64735
+ return hits;
64736
+ }
64737
+ }
64738
+ }
64739
+ return hits;
64740
+ }
64741
+ function ambiguity(hits, pass) {
64742
+ return {
64743
+ state: "unlocatable",
64744
+ reason: `the quote matches at least ${hits.length} places${pass}(lines ${hits.join(", ")}, and the scan stopped there); an anchor chosen among them would be a guess`
64745
+ };
64746
+ }
64747
+ function locateQuote(fileText, quote) {
64748
+ const needle = quoteLines(quote);
64749
+ if (needle.length === 0) {
64750
+ return { state: "unlocatable", reason: "the quote is empty" };
64751
+ }
64752
+ if (needle.length > MAX_QUOTE_LINES) {
64753
+ return {
64754
+ state: "unlocatable",
64755
+ reason: `the quote is ${needle.length} lines, past the ${MAX_QUOTE_LINES}-line bound; quote the few lines the finding is about`
64756
+ };
64757
+ }
64758
+ const haystack = fileText.replace(/\r\n/g, `
64759
+ `).split(`
64760
+ `);
64761
+ if (haystack.length > MAX_LOCATE_FILE_LINES) {
64762
+ return {
64763
+ state: "unlocatable",
64764
+ reason: `the file is ${haystack.length} lines, past the ${MAX_LOCATE_FILE_LINES}-line bound for locating`
64765
+ };
64766
+ }
64767
+ const exact = findRuns(haystack, deindent(needle).map((line) => line.trimEnd()), (window) => deindent(window).map((line) => line.trimEnd()));
64768
+ if (exact.length === 1) {
64769
+ return { state: "derived", method: "exact", line: exact[0] };
64770
+ }
64771
+ if (exact.length > 1) {
64772
+ return ambiguity(exact, " in the file ");
64773
+ }
64774
+ const loose = findRuns(haystack, needle.map(normalise), (window) => window.map(normalise));
64775
+ if (loose.length === 1) {
64776
+ return { state: "derived", method: "whitespace-normalised", line: loose[0] };
64777
+ }
64778
+ if (loose.length > 1) {
64779
+ return ambiguity(loose, " once whitespace is normalised ");
64780
+ }
64781
+ return { state: "unlocatable", reason: "the quote does not appear in the file" };
64782
+ }
64783
+ async function locateFinding(finding, readFile) {
64784
+ const quote = typeof finding.quote === "string" ? finding.quote : "";
64785
+ if (quote.trim() === "") {
64786
+ return;
64787
+ }
64788
+ const reported = typeof finding.line === "number" ? finding.line : null;
64789
+ const file = typeof finding.file === "string" ? finding.file : "";
64790
+ if (file === "") {
64791
+ return { state: "unlocatable", reason: "the finding quotes code but names no file", reported_line: reported };
64792
+ }
64793
+ const text = await readFile(file);
64794
+ if (text === null) {
64795
+ return { state: "unlocatable", reason: `no such file at this round's head: ${file}`, reported_line: reported };
64796
+ }
64797
+ const outcome = locateQuote(text, quote);
64798
+ return outcome.state === "derived" ? { state: "derived", method: outcome.method, line: outcome.line, reported_line: reported } : { state: "unlocatable", reason: outcome.reason, reported_line: reported };
64799
+ }
64800
+
64801
+ // src/review/repair.ts
64802
+ var REPAIRABLE = new Set(["id", "problem"]);
64803
+ function isBlank(value) {
64804
+ return typeof value !== "string" || value.trim() === "";
64805
+ }
64806
+ function mintId(index, taken) {
64807
+ let n = index + 1;
64808
+ let candidate = `F-${String(n).padStart(3, "0")}`;
64809
+ while (taken.has(candidate)) {
64810
+ n += 1;
64811
+ candidate = `F-${String(n).padStart(3, "0")}`;
64812
+ }
64813
+ return candidate;
64814
+ }
64815
+ function acceptRepair(before, after) {
64816
+ if (before.length !== after.length) {
64817
+ throw new Error(`Refusing a repair that changed the number of findings: ${before.length} in, ${after.length} out. A repair may fill fields in; it may never add or drop a finding.`);
64818
+ }
64819
+ for (const [index, finding] of after.entries()) {
64820
+ const had = before[index];
64821
+ const keys = new Set(Object.keys(finding));
64822
+ const illegal = [...keys].filter((key) => !had.has(key) && !REPAIRABLE.has(key));
64823
+ if (illegal.length > 0) {
64824
+ throw new Error(`Refusing a repair that wrote ${illegal.join(", ")} onto finding ${index + 1}: only ${[...REPAIRABLE].join(" and ")} may be filled in. Everything else is a claim the reviewer has to make.`);
64825
+ }
64826
+ const removed = [...had].filter((key) => !keys.has(key));
64827
+ if (removed.length > 0) {
64828
+ throw new Error(`Refusing a repair that removed ${removed.join(", ")} from finding ${index + 1}: a repair may fill a field in; it may never clear one.`);
64829
+ }
64830
+ }
64831
+ }
64832
+ function repairMechanicalOmissions(findings) {
64833
+ const before = findings.map((finding) => new Set(Object.keys(finding)));
64834
+ const taken = new Set(findings.map((finding) => finding.id).filter((id) => !isBlank(id)));
64835
+ const repairs = [];
64836
+ for (const [index, finding] of findings.entries()) {
64837
+ if (isBlank(finding.id)) {
64838
+ const minted = mintId(index, taken);
64839
+ taken.add(minted);
64840
+ finding.id = minted;
64841
+ repairs.push({ finding: minted, field: "id", source: `position ${index + 1} in the report` });
64842
+ }
64843
+ if (isBlank(finding.problem) && !isBlank(finding["title"])) {
64844
+ finding.problem = String(finding["title"]).trim();
64845
+ repairs.push({ finding: finding.id, field: "problem", source: "the finding's own title" });
64846
+ }
64847
+ }
64848
+ acceptRepair(before, findings);
64849
+ return repairs;
64850
+ }
64851
+
64695
64852
  // src/review/managed.ts
64696
64853
  init_review_notes();
64697
64854
  init_types3();
@@ -64761,6 +64918,9 @@ async function createManagedReviewPackage(input) {
64761
64918
  source: input.findings,
64762
64919
  reviewers
64763
64920
  });
64921
+ const repairs = repairMechanicalOmissions(reported);
64922
+ const head = await resolveTargetHead(input);
64923
+ await anchorFindings(reported, input, head);
64764
64924
  assignGlobalIds(reported, reviewId);
64765
64925
  const merged = mergeVerifications(reported, input.verifications ?? [], {
64766
64926
  mode: input.verificationMode ?? DEFAULT_VERIFICATION_MODE
@@ -64796,7 +64956,6 @@ async function createManagedReviewPackage(input) {
64796
64956
  externalRetained: scoped.external.length,
64797
64957
  ...input.refuted === undefined ? {} : { roundDismissed: roundDismissed.length }
64798
64958
  });
64799
- const head = await resolveTargetHead(input);
64800
64959
  const manifest = buildManifest2({
64801
64960
  input,
64802
64961
  target: targetWithHead(input.target, head),
@@ -64805,7 +64964,8 @@ async function createManagedReviewPackage(input) {
64805
64964
  flowMatch,
64806
64965
  coverage,
64807
64966
  at,
64808
- filterStats
64967
+ filterStats,
64968
+ repairs
64809
64969
  });
64810
64970
  const validation = await validateManagedReviewManifest(input.cwd, manifest);
64811
64971
  if (!validation.valid) {
@@ -65067,6 +65227,8 @@ function buildManifest2(args) {
65067
65227
  },
65068
65228
  coverage: args.coverage,
65069
65229
  filter_stats: args.filterStats,
65230
+ ...args.repairs.length === 0 ? {} : { repairs: args.repairs },
65231
+ ...args.input.cost === undefined ? {} : { cost: args.input.cost },
65070
65232
  ...crossFamily === undefined ? {} : { cross_family_review: crossFamily },
65071
65233
  createdAt: args.at,
65072
65234
  updatedAt: args.at
@@ -65132,6 +65294,80 @@ function triage(findings, args) {
65132
65294
  return triaged;
65133
65295
  });
65134
65296
  }
65297
+ async function anchorFindings(findings, input, head) {
65298
+ const read = input.readTreeFile ?? (head === null ? readFromWorkingTree(input) : async (relative) => {
65299
+ const contained = await containedRelativePath(input.cwd, relative);
65300
+ return contained === null ? null : readFileAtCommit(input.cwd, head, contained);
65301
+ });
65302
+ for (const finding of findings) {
65303
+ const locator = await locateFinding(finding, read);
65304
+ if (locator === undefined) {
65305
+ delete finding.locator;
65306
+ continue;
65307
+ }
65308
+ if (locator.state === "derived") {
65309
+ finding.line = locator.line;
65310
+ finding.locator = {
65311
+ state: "derived",
65312
+ method: locator.method,
65313
+ ...locator.reported_line === undefined ? {} : { reported_line: locator.reported_line }
65314
+ };
65315
+ continue;
65316
+ }
65317
+ finding.line = null;
65318
+ finding.locator = {
65319
+ state: "unlocatable",
65320
+ reason: locator.reason,
65321
+ ...locator.reported_line === undefined ? {} : { reported_line: locator.reported_line }
65322
+ };
65323
+ }
65324
+ }
65325
+ async function containedRelativePath(cwd, relative) {
65326
+ const root = await realpath8(cwd).catch(() => path153.resolve(cwd));
65327
+ const resolved = path153.resolve(root, relative);
65328
+ const real = await realpath8(resolved).catch(() => null);
65329
+ if (real === null || real !== root && !real.startsWith(`${root}${path153.sep}`)) {
65330
+ return null;
65331
+ }
65332
+ return real;
65333
+ }
65334
+ async function readFileAtCommit(cwd, commit, absolute) {
65335
+ const relative = path153.relative(await realpath8(cwd).catch(() => path153.resolve(cwd)), absolute);
65336
+ try {
65337
+ const proc = Bun.spawn(["git", "show", `${commit}:${relative}`], {
65338
+ cwd,
65339
+ stdout: "pipe",
65340
+ stderr: "ignore",
65341
+ stdin: "ignore"
65342
+ });
65343
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
65344
+ if (exitCode !== 0 || stdout.length > MAX_LOCATE_FILE_BYTES) {
65345
+ return null;
65346
+ }
65347
+ return stdout;
65348
+ } catch {
65349
+ return null;
65350
+ }
65351
+ }
65352
+ function readFromWorkingTree(input) {
65353
+ return async (relative) => {
65354
+ const root = await realpath8(input.cwd).catch(() => path153.resolve(input.cwd));
65355
+ const resolved = path153.resolve(root, relative);
65356
+ const real = await realpath8(resolved).catch(() => null);
65357
+ if (real === null || real !== root && !real.startsWith(`${root}${path153.sep}`)) {
65358
+ return null;
65359
+ }
65360
+ try {
65361
+ const info = await stat16(real);
65362
+ if (!info.isFile() || info.size > MAX_LOCATE_FILE_BYTES) {
65363
+ return null;
65364
+ }
65365
+ return await readFile86(real, "utf8");
65366
+ } catch {
65367
+ return null;
65368
+ }
65369
+ };
65370
+ }
65135
65371
  function toContractFinding(finding) {
65136
65372
  const record = {
65137
65373
  id: finding.id,
@@ -65149,6 +65385,12 @@ function toContractFinding(finding) {
65149
65385
  if (finding.line !== undefined) {
65150
65386
  record.line = finding.line;
65151
65387
  }
65388
+ if (finding.quote !== undefined) {
65389
+ record.quote = finding.quote;
65390
+ }
65391
+ if (finding.locator !== undefined) {
65392
+ record.locator = finding.locator;
65393
+ }
65152
65394
  if (finding.symbol !== undefined) {
65153
65395
  record.symbol = finding.symbol;
65154
65396
  }
@@ -65801,6 +66043,73 @@ async function loadDocpackSchema(cwd) {
65801
66043
  return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
65802
66044
  }
65803
66045
 
66046
+ // src/review/cost.ts
66047
+ function estimateTokens2(text) {
66048
+ return Math.ceil(text.length / 4);
66049
+ }
66050
+ function grouped(value) {
66051
+ return value.toLocaleString("en-US");
66052
+ }
66053
+ function renderScopeEstimate(scopedDiff, reviewers) {
66054
+ const per = estimateTokens2(scopedDiff);
66055
+ const lines = [
66056
+ `### Estimated cost`,
66057
+ "",
66058
+ `- \u2248 ${grouped(per)} prompt tokens of scoped diff per reviewer (characters \xF7 4 \u2014 an estimate, not a measurement)`
66059
+ ];
66060
+ if (reviewers > 0) {
66061
+ lines.push(`- \u2248 ${grouped(per * reviewers)} across ${reviewers} reviewer${reviewers === 1 ? "" : "s"}, before their own prompts, tools and any verifier wave`);
66062
+ } else {
66063
+ lines.push("- reviewer count not supplied, so the fan-out is not multiplied in \u2014 pass `--reviewers a,b` to see the round's total");
66064
+ }
66065
+ return lines.join(`
66066
+ `);
66067
+ }
66068
+ function costFrom(input) {
66069
+ const cost = {};
66070
+ if (typeof input.inputTokens === "number") {
66071
+ cost.input_tokens = input.inputTokens;
66072
+ }
66073
+ if (typeof input.outputTokens === "number") {
66074
+ cost.output_tokens = input.outputTokens;
66075
+ }
66076
+ if (typeof input.spentUsd === "number") {
66077
+ cost.spent_usd = input.spentUsd;
66078
+ }
66079
+ return Object.keys(cost).length === 0 ? undefined : cost;
66080
+ }
66081
+ function renderCostPerFinding(cost, retained) {
66082
+ if (cost === undefined) {
66083
+ return [
66084
+ "cost: not recorded",
66085
+ " Nothing reported what this round used. That is NOT zero: pass --tokens-in/--tokens-out",
66086
+ " or --spent at ingest so the price of a round is a number somebody can weigh."
66087
+ ];
66088
+ }
66089
+ const tokens = cost.input_tokens === undefined && cost.output_tokens === undefined ? undefined : (cost.input_tokens ?? 0) + (cost.output_tokens ?? 0);
66090
+ const lines = [];
66091
+ if (tokens !== undefined) {
66092
+ lines.push(`tokens: ${grouped(tokens)}`);
66093
+ }
66094
+ if (cost.spent_usd !== undefined) {
66095
+ lines.push(`spent: ${cost.spent_usd} USD`);
66096
+ }
66097
+ lines.push(`retained findings: ${retained}`);
66098
+ if (retained === 0) {
66099
+ lines.push(" Nothing was retained, so there is no per-finding figure \u2014 only the bill.");
66100
+ return lines;
66101
+ }
66102
+ if (tokens !== undefined) {
66103
+ const per = tokens / retained;
66104
+ lines.push(per > 0 && Math.round(per) === 0 ? "per retained finding: < 1 token" : `per retained finding: ${grouped(Math.round(per))} tokens`);
66105
+ }
66106
+ if (cost.spent_usd !== undefined) {
66107
+ const per = cost.spent_usd / retained;
66108
+ lines.push(per > 0 && Number(per.toFixed(4)) === 0 ? "per retained finding: < 0.0001 USD" : `per retained finding: ${per.toFixed(4)} USD`);
66109
+ }
66110
+ return lines;
66111
+ }
66112
+
65804
66113
  // src/review/reviewers.ts
65805
66114
  init_project_skills();
65806
66115
  import { readFile as readFile87, readdir as readdir29 } from "fs/promises";
@@ -66977,6 +67286,8 @@ var CREATE_FLAGS = [
66977
67286
  "--max-findings",
66978
67287
  "--spent",
66979
67288
  "--spend-ceiling",
67289
+ "--tokens-in",
67290
+ "--tokens-out",
66980
67291
  "--parallel",
66981
67292
  "--outstanding"
66982
67293
  ];
@@ -67017,8 +67328,12 @@ var SCOPE_FLAGS = [
67017
67328
  "--base",
67018
67329
  "--json",
67019
67330
  "--scoped-diff",
67020
- "--append"
67331
+ "--append",
67332
+ "--reviewers"
67021
67333
  ];
67334
+ function reviewerList(args) {
67335
+ return (optionValue(args, "--reviewers") ?? "").split(",").map((item) => item.trim()).filter(Boolean);
67336
+ }
67022
67337
  var FLOOR_FLAGS = ["--ref", "--base", "--diff", "--context", "--json", "--report-only"];
67023
67338
  var FLOOR_VALUE_FLAGS = ["--ref", "--base", "--diff", "--context"];
67024
67339
  function rejectValuelessOptions(args, valueFlags, usage) {
@@ -67196,7 +67511,12 @@ async function runCreate(mode, args) {
67196
67511
  maxFindingsPerReviewer: parseNonNegativeInteger(optionValue(args, "--max-findings"), "--max-findings"),
67197
67512
  spend: parseMoney(optionValue(args, "--spent"), "--spent"),
67198
67513
  spendCeiling: parseMoney(optionValue(args, "--spend-ceiling"), "--spend-ceiling"),
67199
- concurrency: parseConcurrency(args)
67514
+ concurrency: parseConcurrency(args),
67515
+ cost: costFrom({
67516
+ inputTokens: parseNonNegativeInteger(optionValue(args, "--tokens-in"), "--tokens-in"),
67517
+ outputTokens: parseNonNegativeInteger(optionValue(args, "--tokens-out"), "--tokens-out"),
67518
+ spentUsd: parseMoney(optionValue(args, "--spent"), "--spent")
67519
+ })
67200
67520
  };
67201
67521
  const result = await createManagedReviewPackage(input);
67202
67522
  console.log(`# managed review: ${result.reviewId}`);
@@ -67861,6 +68181,8 @@ async function runScope(args) {
67861
68181
  console.log(renderScopedDiff(scope));
67862
68182
  } else {
67863
68183
  console.log(renderReviewScopeMarkdown(scope));
68184
+ console.log("");
68185
+ console.log(renderScopeEstimate(renderScopedDiff(scope), reviewerList(args).length));
67864
68186
  }
67865
68187
  const append = optionValue(args, "--append");
67866
68188
  if (append !== undefined) {
@@ -68116,6 +68438,9 @@ async function runComplete3(args) {
68116
68438
  console.log(`# managed review complete: ${manifest.reviewId}`);
68117
68439
  console.log(`status: ${manifest.status}`);
68118
68440
  console.log(`dispositions recorded: ${dispositions.length}`);
68441
+ for (const line of renderCostPerFinding(manifest.cost, manifest.filter_stats?.retained ?? 0)) {
68442
+ console.log(line);
68443
+ }
68119
68444
  for (const note of reviewNotes.written) {
68120
68445
  console.log(`review-note: ${note.finding} -> ${note.path} (attested by ${note.attestation})`);
68121
68446
  }
@@ -75053,7 +75378,7 @@ init_workspace_service();
75053
75378
  init_store2();
75054
75379
  init_fs();
75055
75380
  import { createHash as createHash35, randomUUID as randomUUID17 } from "crypto";
75056
- import { appendFile as appendFile7, mkdir as mkdir58, open as open2, readFile as readFile94, rename as rename7, rm as rm12, stat as stat16, writeFile as writeFile50 } from "fs/promises";
75381
+ import { appendFile as appendFile7, mkdir as mkdir58, open as open2, readFile as readFile94, rename as rename7, rm as rm12, stat as stat17, writeFile as writeFile50 } from "fs/promises";
75057
75382
  import path172 from "path";
75058
75383
 
75059
75384
  // src/sac/policy-experiment.ts
@@ -75230,7 +75555,7 @@ var ledgerDigestChunkBytes = 64 * 1024;
75230
75555
  var isMissing = (error) => error instanceof Error && error.code === "ENOENT";
75231
75556
  var checkpointHash = (body) => createHash35("sha256").update(JSON.stringify(body), "utf8").digest("hex");
75232
75557
  async function ledgerByteLength(ledger) {
75233
- const value = await stat16(ledger, { bigint: true });
75558
+ const value = await stat17(ledger, { bigint: true });
75234
75559
  if (value.size > BigInt(Number.MAX_SAFE_INTEGER))
75235
75560
  throw new Error("invalid access receipt ledger: ledger-too-large");
75236
75561
  return Number(value.size);
@@ -76654,7 +76979,7 @@ function buildToolRegistry() {
76654
76979
  init_fs();
76655
76980
  init_contained_read();
76656
76981
  import path175 from "path";
76657
- import { lstat as lstat5, readdir as readdir34, realpath as realpath8, stat as stat17 } from "fs/promises";
76982
+ import { lstat as lstat5, readdir as readdir34, realpath as realpath9, stat as stat18 } from "fs/promises";
76658
76983
  var URI_PREFIX = "metaproject://";
76659
76984
  function mimeForPath(filePath) {
76660
76985
  if (filePath.endsWith(".json") || filePath.endsWith(".jsonl")) {
@@ -76682,10 +77007,10 @@ async function walkFiles(root, ownerRoot = root, ancestors = new Set) {
76682
77007
  const ownerInfo = await lstat5(ownerRoot).catch(() => null);
76683
77008
  if (!ownerInfo || !ownerInfo.isDirectory() || ownerInfo.isSymbolicLink())
76684
77009
  return [];
76685
- const ownerReal = await realpath8(ownerRoot).catch(() => null);
77010
+ const ownerReal = await realpath9(ownerRoot).catch(() => null);
76686
77011
  if (!ownerReal)
76687
77012
  return [];
76688
- const canonical = await realpath8(root).catch(() => null);
77013
+ const canonical = await realpath9(root).catch(() => null);
76689
77014
  if (!canonical || !isPathInside(ownerReal, canonical) || ancestors.has(canonical))
76690
77015
  return [];
76691
77016
  const nextAncestors = new Set(ancestors);
@@ -76699,8 +77024,8 @@ async function walkFiles(root, ownerRoot = root, ancestors = new Set) {
76699
77024
  }
76700
77025
  for (const entry of entries) {
76701
77026
  const full = path175.join(root, entry.name);
76702
- const info = await stat17(full).catch(() => null);
76703
- if (!info || !isPathInside(ownerReal, await realpath8(full).catch(() => "")))
77027
+ const info = await stat18(full).catch(() => null);
77028
+ if (!info || !isPathInside(ownerReal, await realpath9(full).catch(() => "")))
76704
77029
  continue;
76705
77030
  if (info.isDirectory()) {
76706
77031
  out.push(...await walkFiles(full, ownerRoot, nextAncestors));
@@ -76812,7 +77137,7 @@ async function readResource(cwd, roots, uri) {
76812
77137
  if (!resolved) {
76813
77138
  throw new Error(`Resource path is outside its root (rejected): ${uri}`);
76814
77139
  }
76815
- const info = await stat17(resolved.absolute).catch(() => null);
77140
+ const info = await stat18(resolved.absolute).catch(() => null);
76816
77141
  if (!info || !info.isFile()) {
76817
77142
  throw new Error(`Resource not found: ${uri}`);
76818
77143
  }
@@ -80137,6 +80462,36 @@ var MAX_BACKGROUND_OUTPUT_BYTES = 2 * 1024 * 1024;
80137
80462
  var DEFAULT_INITIAL_BUFFER_MS = 500;
80138
80463
  var MAX_TRACKED_JOBS = 50;
80139
80464
  var TERMINATED_OUTPUT_TAIL_BYTES = 4000;
80465
+ var TASK_OUTPUT_HEAD_BYTES = 24000;
80466
+ var ENV_SHELL_IDLE_MS = "KERYX_SHELL_IDLE_MS";
80467
+ var ENV_SHELL_TIMEOUT_MS_FALLBACK = "KERYX_SHELL_TIMEOUT_MS";
80468
+ var DEFAULT_SHELL_IDLE_MS = 120000;
80469
+ var MIN_TASK_IDLE_TIMEOUT_MS = 1000;
80470
+ var MAX_TASK_IDLE_TIMEOUT_MS = 1800000;
80471
+ function parseEnvMs(raw) {
80472
+ if (raw === undefined || raw.trim().length === 0)
80473
+ return;
80474
+ const n = Number.parseInt(raw.trim(), 10);
80475
+ if (!Number.isFinite(n) || n < 0)
80476
+ return Number.NaN;
80477
+ return n;
80478
+ }
80479
+ function resolveShellIdleMs(env = process.env) {
80480
+ const idle = parseEnvMs(env[ENV_SHELL_IDLE_MS]);
80481
+ if (idle !== undefined) {
80482
+ return Number.isNaN(idle) ? DEFAULT_SHELL_IDLE_MS : idle;
80483
+ }
80484
+ const legacy = parseEnvMs(env[ENV_SHELL_TIMEOUT_MS_FALLBACK]);
80485
+ if (legacy !== undefined) {
80486
+ return Number.isNaN(legacy) ? DEFAULT_SHELL_IDLE_MS : legacy;
80487
+ }
80488
+ return DEFAULT_SHELL_IDLE_MS;
80489
+ }
80490
+ function clampTaskIdleTimeoutMs(ms) {
80491
+ if (!Number.isFinite(ms))
80492
+ return MIN_TASK_IDLE_TIMEOUT_MS;
80493
+ return Math.min(MAX_TASK_IDLE_TIMEOUT_MS, Math.max(MIN_TASK_IDLE_TIMEOUT_MS, Math.trunc(ms)));
80494
+ }
80140
80495
  function sleep(ms) {
80141
80496
  return new Promise((resolve) => setTimeout(resolve, ms));
80142
80497
  }
@@ -80214,11 +80569,34 @@ function createJobRegistry(options) {
80214
80569
  const initialBufferMs = options?.initialBufferMs ?? DEFAULT_INITIAL_BUFFER_MS;
80215
80570
  const onEvent = options?.onEvent;
80216
80571
  const maxTrackedJobs = options?.maxTrackedJobs ?? MAX_TRACKED_JOBS;
80572
+ const idleMs = options?.idleMs ?? resolveShellIdleMs();
80217
80573
  let nextId = 0;
80218
80574
  const jobs = new Map;
80219
80575
  function runningJobs() {
80220
80576
  return [...jobs.values()].filter((j) => j.info.status === "running");
80221
80577
  }
80578
+ function backgroundRunningJobs() {
80579
+ return runningJobs().filter((j) => j.info.phase === "background");
80580
+ }
80581
+ function clearIdleTimer(job) {
80582
+ if (job.idleTimer === undefined)
80583
+ return;
80584
+ clearTimeout(job.idleTimer);
80585
+ job.idleTimer = undefined;
80586
+ }
80587
+ function armIdleTimer(job) {
80588
+ clearIdleTimer(job);
80589
+ if (job.info.status !== "running")
80590
+ return;
80591
+ const timeout = job.info.idleTimeoutMs;
80592
+ if (timeout <= 0)
80593
+ return;
80594
+ const timer = setTimeout(() => {
80595
+ requestKill(job, "idle");
80596
+ }, timeout);
80597
+ timer.unref?.();
80598
+ job.idleTimer = timer;
80599
+ }
80222
80600
  function evictOldestTerminatedIfOverCap() {
80223
80601
  if (jobs.size <= maxTrackedJobs)
80224
80602
  return;
@@ -80239,20 +80617,32 @@ function createJobRegistry(options) {
80239
80617
  }
80240
80618
  function appendOutput(job, chunk, stream) {
80241
80619
  job.outputBuffer += chunk;
80620
+ if (job.outputHead.length < TASK_OUTPUT_HEAD_BYTES) {
80621
+ job.outputHead = (job.outputHead + chunk).slice(0, TASK_OUTPUT_HEAD_BYTES);
80622
+ job.info.outputHead = job.outputHead;
80623
+ }
80624
+ armIdleTimer(job);
80242
80625
  onEvent?.({ type: "output", jobId: job.info.jobId, chunk, stream });
80243
80626
  if (job.outputBuffer.length > MAX_BACKGROUND_OUTPUT_BYTES) {
80244
80627
  const dropped = job.outputBuffer.length - MAX_BACKGROUND_OUTPUT_BYTES;
80245
80628
  job.outputBuffer = job.outputBuffer.slice(dropped);
80246
80629
  job.readCursor = Math.max(0, job.readCursor - dropped);
80247
- if (!job.killRequested) {
80248
- job.killRequested = true;
80249
- terminateJob(job, killGraceMs);
80250
- }
80630
+ requestKill(job, "output-cap");
80251
80631
  }
80252
80632
  }
80633
+ function requestKill(job, reason) {
80634
+ if (job.info.status !== "running")
80635
+ return Promise.resolve();
80636
+ if (job.killRequested)
80637
+ return job.exitedPromise;
80638
+ job.killRequested = true;
80639
+ job.pendingKillReason = reason;
80640
+ return terminateJob(job, killGraceMs);
80641
+ }
80253
80642
  async function terminateJob(job, graceMs) {
80254
80643
  if (job.info.status !== "running")
80255
80644
  return;
80645
+ clearIdleTimer(job);
80256
80646
  job.killRequested = true;
80257
80647
  job.handle.kill("SIGTERM");
80258
80648
  const exited = await Promise.race([job.exitedPromise.then(() => true), sleep(graceMs).then(() => false)]);
@@ -80261,14 +80651,17 @@ function createJobRegistry(options) {
80261
80651
  }
80262
80652
  }
80263
80653
  return {
80264
- async start(command) {
80265
- const running = runningJobs();
80266
- if (running.length >= maxConcurrent) {
80267
- const names = running.map((j) => j.info.command).join(", ");
80268
- return {
80269
- ok: false,
80270
- error: `background job limit reached (${maxConcurrent} running: ${names}); wait for one to finish or kill it with shell_job_kill first`
80271
- };
80654
+ async start(command, opts) {
80655
+ const phase = opts?.phase ?? "background";
80656
+ if (phase === "background") {
80657
+ const running = backgroundRunningJobs();
80658
+ if (running.length >= maxConcurrent) {
80659
+ const names = running.map((j) => j.info.command).join(", ");
80660
+ return {
80661
+ ok: false,
80662
+ error: `background task limit reached (${maxConcurrent} running: ${names}); wait for one to finish or kill it with shell_job_kill first`
80663
+ };
80664
+ }
80272
80665
  }
80273
80666
  let handle;
80274
80667
  try {
@@ -80279,7 +80672,7 @@ function createJobRegistry(options) {
80279
80672
  error: `command failed to start: ${cause instanceof Error ? cause.message : String(cause)}`
80280
80673
  };
80281
80674
  }
80282
- const jobId = `job-${++nextId}-${handle.pid}`;
80675
+ const jobId = `task-${++nextId}-${handle.pid}`;
80283
80676
  let resolveExited;
80284
80677
  const exitedPromise = new Promise((resolve) => {
80285
80678
  resolveExited = resolve;
@@ -80290,26 +80683,46 @@ function createJobRegistry(options) {
80290
80683
  pid: handle.pid,
80291
80684
  command,
80292
80685
  status: "running",
80686
+ phase,
80687
+ idleTimeoutMs: opts?.idleTimeoutMs ?? idleMs,
80293
80688
  startedAt: nowIso3()
80294
80689
  },
80295
80690
  handle,
80296
80691
  outputBuffer: "",
80692
+ outputHead: "",
80297
80693
  readCursor: 0,
80298
80694
  exited: false,
80299
80695
  killRequested: false,
80696
+ phaseEmitted: false,
80300
80697
  resolveExited,
80301
80698
  exitedPromise
80302
80699
  };
80700
+ if (opts?.description !== undefined)
80701
+ job.info.description = opts.description;
80303
80702
  jobs.set(jobId, job);
80304
80703
  evictOldestTerminatedIfOverCap();
80305
- onEvent?.({ type: "start", jobId, pid: handle.pid, command, startedAt: job.info.startedAt });
80704
+ onEvent?.({
80705
+ type: "start",
80706
+ jobId,
80707
+ pid: handle.pid,
80708
+ command,
80709
+ startedAt: job.info.startedAt,
80710
+ ...opts?.description !== undefined ? { description: opts.description } : {}
80711
+ });
80712
+ if (phase === "background") {
80713
+ job.phaseEmitted = true;
80714
+ onEvent?.({ type: "phase", jobId, phase: "background" });
80715
+ }
80306
80716
  handle.onOutput((chunk, stream) => appendOutput(job, chunk, stream));
80307
80717
  handle.onExit((info) => {
80308
80718
  if (job.exited)
80309
80719
  return;
80310
80720
  job.exited = true;
80311
- if (job.info.status === "running") {
80312
- job.info.status = job.killRequested ? "killed" : "exited";
80721
+ clearIdleTimer(job);
80722
+ const terminal = job.killRequested ? "killed" : info.exitCode === 0 ? "completed" : "failed";
80723
+ job.info.status = terminal;
80724
+ if (job.killRequested) {
80725
+ job.info.killReason = job.pendingKillReason ?? "model";
80313
80726
  }
80314
80727
  job.info.exitCode = info.exitCode;
80315
80728
  job.info.endedAt = nowIso3();
@@ -80317,13 +80730,15 @@ function createJobRegistry(options) {
80317
80730
  onEvent?.({
80318
80731
  type: "exit",
80319
80732
  jobId,
80320
- status: job.info.status,
80733
+ status: terminal,
80734
+ ...job.info.killReason !== undefined ? { killReason: job.info.killReason } : {},
80321
80735
  exitCode: info.exitCode,
80322
80736
  endedAt: job.info.endedAt
80323
80737
  });
80324
80738
  shrinkTerminatedOutput(job);
80325
80739
  });
80326
- if (initialBufferMs > 0) {
80740
+ armIdleTimer(job);
80741
+ if (phase === "background" && initialBufferMs > 0) {
80327
80742
  await sleep(initialBufferMs);
80328
80743
  }
80329
80744
  const output = job.outputBuffer.slice(job.readCursor);
@@ -80345,7 +80760,41 @@ function createJobRegistry(options) {
80345
80760
  job.readCursor = job.outputBuffer.length;
80346
80761
  return { ok: true, output };
80347
80762
  },
80348
- async kill(jobId) {
80763
+ async waitForExit(jobId, ms) {
80764
+ const job = jobs.get(jobId);
80765
+ if (job === undefined)
80766
+ return "unknown";
80767
+ if (job.exited || job.info.status !== "running")
80768
+ return "exited";
80769
+ let timer;
80770
+ const timedOut = await Promise.race([
80771
+ job.exitedPromise.then(() => false),
80772
+ new Promise((resolve) => {
80773
+ timer = setTimeout(() => resolve(true), ms);
80774
+ timer.unref?.();
80775
+ })
80776
+ ]);
80777
+ if (timer !== undefined)
80778
+ clearTimeout(timer);
80779
+ return timedOut ? "timeout" : "exited";
80780
+ },
80781
+ promote(jobId) {
80782
+ const job = jobs.get(jobId);
80783
+ if (job === undefined) {
80784
+ return { ok: false, error: `unknown job_id: ${jobId}` };
80785
+ }
80786
+ job.info.phase = "background";
80787
+ if (!job.phaseEmitted) {
80788
+ job.phaseEmitted = true;
80789
+ onEvent?.({ type: "phase", jobId, phase: "background" });
80790
+ }
80791
+ const others = backgroundRunningJobs().filter((j) => j.info.jobId !== jobId);
80792
+ if (job.info.status === "running" && others.length >= maxConcurrent) {
80793
+ return { ok: true, overCap: others.map((j) => j.info.command).join(", ") };
80794
+ }
80795
+ return { ok: true };
80796
+ },
80797
+ async kill(jobId, reason = "model") {
80349
80798
  const job = jobs.get(jobId);
80350
80799
  if (job === undefined) {
80351
80800
  return { ok: false, error: `unknown job_id: ${jobId}` };
@@ -80353,11 +80802,11 @@ function createJobRegistry(options) {
80353
80802
  if (job.info.status !== "running") {
80354
80803
  return { ok: false, error: `job ${jobId} is not running (status: ${job.info.status})` };
80355
80804
  }
80356
- await terminateJob(job, killGraceMs);
80805
+ await requestKill(job, reason);
80357
80806
  return { ok: true };
80358
80807
  },
80359
80808
  async sweepAll() {
80360
- await Promise.all(runningJobs().map((job) => terminateJob(job, killGraceMs)));
80809
+ await Promise.all(runningJobs().map((job) => requestKill(job, "session-exit")));
80361
80810
  }
80362
80811
  };
80363
80812
  }
@@ -80365,7 +80814,7 @@ function shellJobOutputTool(registry) {
80365
80814
  return {
80366
80815
  definition: {
80367
80816
  name: "shell_job_output",
80368
- description: "Return output produced by a background job (started via shell_exec with background:true) SINCE the " + "previous call for that job_id \u2014 never the full transcript again. Input: { job_id: string }. Poll this " + "instead of re-running shell_exec to check on a long-running job.",
80817
+ description: "Return output produced by a shell task SINCE the previous call for that id \u2014 never the full transcript " + "again. Input: { job_id: string } \u2014 pass the task_id shell_exec returned for a command that outlived its " + "yield (or a background:true job). Poll this instead of re-running shell_exec to check on a long command.",
80369
80818
  inputSchema: {
80370
80819
  type: "object",
80371
80820
  properties: { job_id: { type: "string" } },
@@ -80391,7 +80840,7 @@ function shellJobKillTool(registry) {
80391
80840
  return {
80392
80841
  definition: {
80393
80842
  name: "shell_job_kill",
80394
- description: "Kill a background job started via shell_exec with background:true (its entire process group, including " + "any descendant it backgrounded). Input: { job_id: string }. Only jobs in this session's own registry can " + "be targeted.",
80843
+ description: "Stop a running shell task \u2014 its entire process group, including any descendant it backgrounded. Input: " + "{ job_id: string } \u2014 the task_id shell_exec returned for a command still running after its yield (or a " + "background:true job). Only tasks in this session's own registry can be targeted.",
80395
80844
  inputSchema: {
80396
80845
  type: "object",
80397
80846
  properties: { job_id: { type: "string" } },
@@ -80596,6 +81045,37 @@ function resolveShellTimeoutMs(env = process.env) {
80596
81045
  }
80597
81046
  return n;
80598
81047
  }
81048
+ var DEFAULT_SHELL_YIELD_MS = 1e4;
81049
+ var ENV_SHELL_YIELD_MS = "KERYX_SHELL_YIELD_MS";
81050
+ function resolveShellYieldMs(env = process.env) {
81051
+ const raw = env[ENV_SHELL_YIELD_MS];
81052
+ if (raw === undefined || raw.trim().length === 0) {
81053
+ return DEFAULT_SHELL_YIELD_MS;
81054
+ }
81055
+ const n = Number.parseInt(raw.trim(), 10);
81056
+ if (!Number.isFinite(n) || n < 0) {
81057
+ return DEFAULT_SHELL_YIELD_MS;
81058
+ }
81059
+ return n;
81060
+ }
81061
+ function boundOutput2(text) {
81062
+ return text.length > MAX_OUTPUT_BYTES3 ? `${text.slice(0, MAX_OUTPUT_BYTES3)}
81063
+ \u2026(truncated)` : text;
81064
+ }
81065
+ function taskHandleResult(args) {
81066
+ return {
81067
+ output: JSON.stringify({
81068
+ task_id: args.taskId,
81069
+ job_id: args.taskId,
81070
+ pid: args.pid,
81071
+ status: "running",
81072
+ output: boundOutput2(args.output),
81073
+ notice: args.notice,
81074
+ ...args.overCap !== undefined ? { over_cap: args.overCap } : {}
81075
+ }),
81076
+ isError: false
81077
+ };
81078
+ }
80599
81079
  function makeCommandRunner(root) {
80600
81080
  return async (command) => {
80601
81081
  let netClose = async () => {};
@@ -80689,14 +81169,19 @@ ${notice}` : notice,
80689
81169
  }
80690
81170
  };
80691
81171
  }
80692
- function shellExecTool(root, run = makeCommandRunner(root), jobRegistry) {
81172
+ function shellExecTool(root, run = makeCommandRunner(root), jobRegistry, options) {
80693
81173
  return {
80694
81174
  definition: {
80695
81175
  name: "shell_exec",
80696
- description: "Run a shell command in the project root (e.g. `git status`, `bun test`). Requires the user's approval " + "before it runs. Input: { command: string, background?: boolean }. Combined stdout+stderr is CAPPED at " + "20,000 bytes from the start of output \u2014 do not use sed/grep/cat/awk here to locate code (they can " + "silently truncate before reaching what you need, and repeating the command returns the same truncated " + "head); use search_code or graph_symbol instead, which are built for that and stay within the cap. Set " + "background:true for a long-running command (a dev server, a watch build) \u2014 it returns immediately with " + "{job_id, pid} instead of blocking; poll shell_job_output(job_id) for new output and shell_job_kill" + "(job_id) to stop it.",
81176
+ description: "Run a shell command in the project root (e.g. `git status`, `bun test`). Requires the user's approval " + "before it runs. Input: { command: string, background?: boolean, description?: string, " + "idle_timeout_ms?: integer }. EVERY command returns within a bounded wait \u2014 a short one returns its " + "output directly, and one still running when the wait elapses keeps running as a task: you get " + "{task_id, pid, status, output} instead of its final output, and read the rest with " + "shell_job_output(task_id) and stop it with shell_job_kill(task_id). So a slow command never freezes " + "the session and never needs to be guessed at in advance. background:true only SKIPS the wait (use it " + "for a dev server or a watch build you know you will not read inline); it changes nothing else. " + "description is a short label for the task list. idle_timeout_ms is for a command that is deliberately " + "SILENT for a long time (a sleep, a slow poll) \u2014 a task that produces no output for its idle timeout " + "is killed, so raise it rather than lose the command. Combined stdout+stderr is CAPPED at 20,000 bytes " + "from the start of output \u2014 do not use sed/grep/cat/awk here to locate code (they can silently " + "truncate before reaching what you need, and repeating the command returns the same truncated head); " + "use search_code or graph_symbol instead, which are built for that and stay within the cap.",
80697
81177
  inputSchema: {
80698
81178
  type: "object",
80699
- properties: { command: { type: "string" }, background: { type: "boolean" } },
81179
+ properties: {
81180
+ command: { type: "string" },
81181
+ background: { type: "boolean" },
81182
+ description: { type: "string" },
81183
+ idle_timeout_ms: { type: "integer" }
81184
+ },
80700
81185
  required: ["command"],
80701
81186
  additionalProperties: false
80702
81187
  },
@@ -80707,20 +81192,66 @@ function shellExecTool(root, run = makeCommandRunner(root), jobRegistry) {
80707
81192
  if (command.length === 0) {
80708
81193
  return { output: "shell_exec requires a non-empty 'command'", isError: true };
80709
81194
  }
80710
- if (input.background === true) {
80711
- if (jobRegistry === undefined) {
81195
+ const background = input.background === true;
81196
+ if (jobRegistry === undefined) {
81197
+ if (background) {
80712
81198
  return { output: "shell_exec: background jobs are not available in this session", isError: true };
80713
81199
  }
80714
- const started = await jobRegistry.start(command);
80715
- if (!started.ok) {
80716
- return { output: started.error, isError: true };
81200
+ return run(command);
81201
+ }
81202
+ const startOpts = { phase: background ? "background" : "foreground" };
81203
+ if (typeof input.description === "string" && input.description.length > 0) {
81204
+ startOpts.description = input.description;
81205
+ }
81206
+ if (typeof input.idle_timeout_ms === "number") {
81207
+ startOpts.idleTimeoutMs = clampTaskIdleTimeoutMs(input.idle_timeout_ms);
81208
+ }
81209
+ const started = await jobRegistry.start(command, startOpts);
81210
+ if (!started.ok) {
81211
+ return { output: started.error, isError: true };
81212
+ }
81213
+ const taskId = started.jobId;
81214
+ if (background) {
81215
+ return taskHandleResult({
81216
+ taskId,
81217
+ pid: started.pid,
81218
+ output: started.output,
81219
+ notice: `running as a background task; read new output with shell_job_output("${taskId}") and stop it with shell_job_kill("${taskId}")`
81220
+ });
81221
+ }
81222
+ const yieldMs = options?.yieldMs ?? resolveShellYieldMs();
81223
+ const outcome = await jobRegistry.waitForExit(taskId, yieldMs);
81224
+ let collected = started.output;
81225
+ const pending = jobRegistry.readOutput(taskId);
81226
+ if (pending.ok)
81227
+ collected += pending.output;
81228
+ if (outcome === "timeout") {
81229
+ const promoted = jobRegistry.promote(taskId);
81230
+ if (!promoted.ok) {
81231
+ return { output: promoted.error, isError: true };
80717
81232
  }
80718
- return {
80719
- output: JSON.stringify({ job_id: started.jobId, pid: started.pid, output: started.output }),
80720
- isError: false
80721
- };
81233
+ return taskHandleResult({
81234
+ taskId,
81235
+ pid: started.pid,
81236
+ output: collected,
81237
+ notice: `still running after ${yieldMs}ms, so it is now a background task; read new output with shell_job_output("${taskId}") and stop it with shell_job_kill("${taskId}")`,
81238
+ ...promoted.overCap !== undefined ? { overCap: promoted.overCap } : {}
81239
+ });
80722
81240
  }
80723
- return run(command);
81241
+ const info = jobRegistry.get(taskId);
81242
+ if (outcome === "unknown" || info === undefined) {
81243
+ const so_far = boundOutput2(collected.trim());
81244
+ const lost = `shell_exec: task ${taskId} is no longer tracked, so its exit status is unknown`;
81245
+ return { output: so_far.length > 0 ? `${so_far}
81246
+ ${lost}` : lost, isError: true };
81247
+ }
81248
+ const exitCode = info.exitCode ?? 0;
81249
+ const bounded = boundOutput2((info.outputHead ?? collected).trim());
81250
+ const body = bounded.length > 0 ? bounded : `(no output; exit ${exitCode})`;
81251
+ const idleKilled = info.status === "killed" && info.killReason === "idle";
81252
+ const output = idleKilled ? `${body}
81253
+ shell_exec: no output for ${info.idleTimeoutMs}ms, so the command was killed (raise it for this command with idle_timeout_ms, or change the default with ${ENV_SHELL_IDLE_MS})` : body;
81254
+ return { output, isError: info.status !== "completed" };
80724
81255
  }
80725
81256
  };
80726
81257
  }
@@ -86652,7 +87183,7 @@ init_store3();
86652
87183
  init_proposal_lifecycle();
86653
87184
  init_workspace_service();
86654
87185
  import { randomUUID as randomUUID27 } from "crypto";
86655
- import { readdir as readdir36, stat as stat18 } from "fs/promises";
87186
+ import { readdir as readdir36, stat as stat19 } from "fs/promises";
86656
87187
  import path190 from "path";
86657
87188
 
86658
87189
  // src/sac/lifecycle-flag.ts
@@ -86912,7 +87443,7 @@ async function collectReceiptTargets(cwd, owner) {
86912
87443
  async function attributeToSession(absolutePath, sessionsNewestFirst) {
86913
87444
  let mtimeMs;
86914
87445
  try {
86915
- mtimeMs = (await stat18(absolutePath)).mtimeMs;
87446
+ mtimeMs = (await stat19(absolutePath)).mtimeMs;
86916
87447
  } catch {
86917
87448
  return;
86918
87449
  }
@@ -94130,9 +94661,14 @@ function openExternalInspector(otui, chrome, options) {
94130
94661
  var MAX_BACKGROUND_JOB_OUTPUT_CHARS = 20000;
94131
94662
  var STATUS_GLYPH4 = {
94132
94663
  running: "\u25D0",
94133
- exited: "\u25CF",
94664
+ completed: "\u25CF",
94665
+ failed: "\u25B2",
94134
94666
  killed: "\u2717"
94135
94667
  };
94668
+ function appendBounded(output, chunk) {
94669
+ const combined = output + chunk;
94670
+ return combined.length > MAX_BACKGROUND_JOB_OUTPUT_CHARS ? combined.slice(-MAX_BACKGROUND_JOB_OUTPUT_CHARS) : combined;
94671
+ }
94136
94672
  function clip7(s, max) {
94137
94673
  if (max <= 0) {
94138
94674
  return "";
@@ -94161,6 +94697,9 @@ function formatJobMeta(entry, now = Date.now()) {
94161
94697
  ["Ended", entry.endedAt ?? "\u2014"],
94162
94698
  ["Exit code", entry.exitCode !== undefined ? String(entry.exitCode) : "\u2014"]
94163
94699
  ];
94700
+ if (entry.killReason !== undefined) {
94701
+ rows.push(["Kill reason", entry.killReason]);
94702
+ }
94164
94703
  const width = rows.reduce((max, [label]) => Math.max(max, label.length), 0);
94165
94704
  return rows.map(([label, value]) => `${label.padEnd(width)} ${value}`).join(`
94166
94705
  `);
@@ -94171,36 +94710,64 @@ function formatJobOutput(entry) {
94171
94710
 
94172
94711
  class BackgroundJobStore {
94173
94712
  jobs = new Map;
94713
+ pending = new Map;
94174
94714
  listeners = new Set;
94175
94715
  apply(event) {
94176
- if (event.type === "start") {
94177
- this.jobs.set(event.jobId, {
94178
- jobId: event.jobId,
94179
- command: event.command,
94180
- pid: event.pid,
94181
- status: "running",
94182
- startedAt: event.startedAt,
94183
- output: ""
94184
- });
94185
- this.emit({ id: event.jobId, kind: "start" });
94186
- return;
94187
- }
94188
- const current = this.jobs.get(event.jobId);
94189
- if (current === undefined) {
94190
- return;
94191
- }
94192
- if (event.type === "output") {
94193
- const combined = current.output + event.chunk;
94194
- current.output = combined.length > MAX_BACKGROUND_JOB_OUTPUT_CHARS ? combined.slice(-MAX_BACKGROUND_JOB_OUTPUT_CHARS) : combined;
94195
- this.emit({ id: event.jobId, kind: "output" });
94196
- return;
94197
- }
94198
- current.status = event.status;
94199
- current.endedAt = event.endedAt;
94200
- if (event.exitCode !== undefined) {
94201
- current.exitCode = event.exitCode;
94716
+ switch (event.type) {
94717
+ case "start":
94718
+ this.pending.set(event.jobId, {
94719
+ jobId: event.jobId,
94720
+ command: event.command,
94721
+ pid: event.pid,
94722
+ status: "running",
94723
+ startedAt: event.startedAt,
94724
+ output: ""
94725
+ });
94726
+ return;
94727
+ case "phase": {
94728
+ const entry = this.pending.get(event.jobId);
94729
+ if (entry === undefined) {
94730
+ return;
94731
+ }
94732
+ this.pending.delete(event.jobId);
94733
+ this.jobs.set(event.jobId, entry);
94734
+ this.emit({ id: event.jobId, kind: "start" });
94735
+ return;
94736
+ }
94737
+ case "output": {
94738
+ const hidden = this.pending.get(event.jobId);
94739
+ if (hidden !== undefined) {
94740
+ hidden.output = appendBounded(hidden.output, event.chunk);
94741
+ return;
94742
+ }
94743
+ const current = this.jobs.get(event.jobId);
94744
+ if (current === undefined) {
94745
+ return;
94746
+ }
94747
+ current.output = appendBounded(current.output, event.chunk);
94748
+ this.emit({ id: event.jobId, kind: "output" });
94749
+ return;
94750
+ }
94751
+ case "exit": {
94752
+ if (this.pending.delete(event.jobId)) {
94753
+ return;
94754
+ }
94755
+ const current = this.jobs.get(event.jobId);
94756
+ if (current === undefined) {
94757
+ return;
94758
+ }
94759
+ current.status = event.status;
94760
+ current.endedAt = event.endedAt;
94761
+ if (event.exitCode !== undefined) {
94762
+ current.exitCode = event.exitCode;
94763
+ }
94764
+ if (event.killReason !== undefined) {
94765
+ current.killReason = event.killReason;
94766
+ }
94767
+ this.emit({ id: event.jobId, kind: "exit" });
94768
+ return;
94769
+ }
94202
94770
  }
94203
- this.emit({ id: event.jobId, kind: "exit" });
94204
94771
  }
94205
94772
  get(jobId) {
94206
94773
  return this.jobs.get(jobId);
@@ -94215,6 +94782,7 @@ class BackgroundJobStore {
94215
94782
  };
94216
94783
  }
94217
94784
  removeAll() {
94785
+ this.pending.clear();
94218
94786
  if (this.jobs.size === 0) {
94219
94787
  return;
94220
94788
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.2.106",
3
+ "version": "0.2.108",
4
4
  "description": "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -227,7 +227,7 @@ Report done only once all of these hold:
227
227
  - The measurement was taken at the worst real case — the longest translation in
228
228
  the catalog, the narrowest supported host, the state where the sibling is
229
229
  absent — not at the convenient one.
230
- - Every finding names `file:line` on the component that renders the box.
230
+ - Every finding names the `file` of the component that renders the box and `quote`s the code it is about; `line` is derived at ingest from that quote, never counted.
231
231
  - Every `blocker` and `major` carries `class_scope` with `sites` and an
232
232
  `enumeration_method` naming the search that derived the set of elements sharing
233
233
  the sizing shape.
@@ -1350,10 +1350,10 @@ Each reviewer must return a `REVIEW_RESULT` object matching `.metaproject/skills
1350
1350
 
1351
1351
  Before consolidation, validate every reviewer result:
1352
1352
  - Required status: `DONE`, `DONE_WITH_CONCERNS`, `NEEDS_CONTEXT`, or `BLOCKED`.
1353
- - Required finding fields: id, severity, file, line (nullable only for repo-wide findings), problem, impact, suggested_fix, evidence, confidence, reviewer.
1353
+ - Required finding fields: id, severity, file, `quote`, problem, impact, suggested_fix, evidence, confidence, reviewer. **`quote`, not `line`:** copy the code the finding is about verbatim out of `file` and do not count lines — `keryx review ingest` locates the quote at the round's head and derives `line` from where it matched, so a number reported here is overwritten. A model is poor at counting lines and good at repeating text it has just read; this asks only for the second. Quote enough to be unambiguous: a snippet appearing twice in the file is recorded `unlocatable` rather than anchored to the first hit. A finding about the round rather than a site carries neither.
1354
1354
  - Every blocker must include evidence and a concrete suggested fix.
1355
1355
  - Findings without evidence are downgraded to `info` or returned to the reviewer for clarification.
1356
- - Duplicate findings are merged by `dedupe_key` or by `(file, line, problem)`.
1356
+ - Duplicate findings are merged by `dedupe_key` or by `(file, quote, problem)`.
1357
1357
  - `NEEDS_CONTEXT` triggers one targeted context refill. If still unresolved, keep it as an explicit open question, not as a blocker.
1358
1358
  - If a reviewer exceeds `max_findings`, keep blockers/majors first and summarize lower severity findings.
1359
1359
 
@@ -74,12 +74,20 @@
74
74
  ]
75
75
  },
76
76
  "line": {
77
+ "description": "Do not count lines. Quote the code in `quote` instead and leave this null: `keryx review ingest` finds the quote in `file` and writes the line the match started on, overwriting anything reported here. A model is poor at counting lines and good at repeating text it has just read, and this asks it only for the second. Kept in the contract because a finding with nothing to quote — one about the round rather than a site — still has nowhere else to point.",
77
78
  "type": [
78
79
  "integer",
79
80
  "null"
80
81
  ],
81
82
  "minimum": 1
82
83
  },
84
+ "quote": {
85
+ "description": "The code this finding is about, copied verbatim out of `file`. A few lines is enough — it is matched against the file at the round's head, exactly first and then with whitespace collapsed. A quote that appears in more than one place is recorded as `unlocatable` rather than anchored to the first hit, so quote enough to be unambiguous.",
86
+ "type": [
87
+ "string",
88
+ "null"
89
+ ]
90
+ },
83
91
  "problem": {
84
92
  "type": "string"
85
93
  },
@@ -100,7 +100,40 @@
100
100
  "description": "Which repository `file` and `line` are in. Null, the default, means the repository under review. A cross-repo claim names the producer here and pins the ref it was read at in `evidence`; without this field a finding about another service's behaviour could not say where its evidence lived, so the rule that such a claim is `info` unless it has a `file:line` at a pinned ref could not be checked by anything but the reviewer's word. Declared in BOTH this schema and skills/review/review-orchestrator/reviewer-finding.schema.json: this one is additionalProperties:false, so a finding carrying `repo` is rejected here if only the reviewer-side schema knows about it."
101
101
  },
102
102
  "file": { "type": ["string", "null"] },
103
- "line": { "type": ["integer", "null"], "minimum": 1 },
103
+ "line": {
104
+ "title": "Where the finding points — DERIVED when the finding quotes code",
105
+ "description": "Reported by the reviewer only when there is nothing to quote. When `quote` is present this field is OUTPUT: `review ingest` finds the quote in `file` at the round's head and writes the line the match started on, overwriting whatever the reviewer said and recording both in `locator`. The reason is the failure it replaces — nothing compared a reported line to anything, so a number that had drifted travelled into findings.json, into the report, and into the disposition recorded against it. On a fix round the file has moved under the finding by construction, which is when the reported number is least trustworthy and most acted upon.",
106
+ "type": ["integer", "null"],
107
+ "minimum": 1
108
+ },
109
+ "quote": {
110
+ "title": "The code this finding is about, copied verbatim out of `file`",
111
+ "description": "What replaces asking the model for a line number: a model is poor at counting lines and good at repeating text it has just read. The quote is matched against `file` at the round's head — exactly first, then with whitespace collapsed, then not at all. A quote that matches in more than one place is `unlocatable` rather than anchored to the first hit, because choosing among them would be a guess wearing a line number. Absent for a finding that is about the round rather than a site.",
112
+ "type": ["string", "null"]
113
+ },
114
+ "locator": {
115
+ "title": "How `line` was arrived at",
116
+ "description": "Present exactly when the finding carries a quote. `derived` means the quote was found and `line` is where; `unlocatable` means it was not, and then `line` is null rather than a number nobody checked. `reported_line` keeps what the reviewer said, so a round can measure how far the claims drifted from the code — which is the only way to know whether deriving them repaired anything or is insurance.",
117
+ "type": "object",
118
+ "additionalProperties": false,
119
+ "required": ["state"],
120
+ "properties": {
121
+ "state": { "type": "string", "enum": ["derived", "unlocatable"] },
122
+ "method": { "type": "string", "enum": ["exact", "whitespace-normalised"] },
123
+ "reason": { "type": ["string", "null"] },
124
+ "reported_line": { "type": ["integer", "null"], "minimum": 1 }
125
+ },
126
+ "allOf": [
127
+ {
128
+ "if": { "properties": { "state": { "const": "derived" } }, "required": ["state"] },
129
+ "then": { "required": ["method"] }
130
+ },
131
+ {
132
+ "if": { "properties": { "state": { "const": "unlocatable" } }, "required": ["state"] },
133
+ "then": { "required": ["reason"] }
134
+ }
135
+ ]
136
+ },
104
137
  "symbol": { "type": ["string", "null"] },
105
138
  "problem": { "type": "string" },
106
139
  "impact": { "type": "string" },