@mrciphersmith/keryx 0.3.113 → 0.3.114

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
@@ -1148,6 +1148,10 @@ package. See
1148
1148
 
1149
1149
  Every managed review start, attach or ingest (and any `dispatch-check` or `blast-radius` given `--package`) refreshes the graph, the wiki and the memory index for the PR head and writes `layers.json`, with **no model call**; `wiki enrich` is the only model step and is never automatic (`keryx review layers refresh --package <dir> [--write-wiki]`). Blast-radius files beyond the 40-file cap are reviewed in chunks (`keryx review chunks estimate|next|record|merge`; the ceiling is 25 passes of 40 files, `reviewChunks.maxPasses` in `routing.config.json`), `keryx review coverage` counts what is covered, omitted by name, and uncovered, and `keryx review complete` refuses while a file is uncovered unless you pass `--waive-coverage "<your words>"`. `/reviews` and `/review layers` in the shell show it.
1150
1150
 
1151
+ ### Gates that keep a review report honest
1152
+
1153
+ `keryx review complete` on a pull-request package refuses a merged PR or a moved head until the first line of `report.md` names the reviewed SHA and says so, and refuses a report with no `Evidence level:` line (`static only — did not run: <what>` or `ran — <what>`; a package ingested before the field existed only warns). `keryx review ingest` reads each finding's quote at its `path` at the reviewed SHA and refuses one that does not resolve there, adds a `citation not found` line to a rebuttal that cites a rule or document missing from the tree (the finding is never deleted), and a finding can close as `narrowed` only with a "what remains" text. See [review with a record](docs/docs/guides/review-with-a-record.md).
1154
+
1151
1155
  ### Review bot on pull requests
1152
1156
 
1153
1157
  A GitHub Action reviews each same-repository pull request, has a second model
package/dist/cli.js CHANGED
@@ -23150,7 +23150,7 @@ var package_default;
23150
23150
  var init_package = __esm(() => {
23151
23151
  package_default = {
23152
23152
  name: "@mrciphersmith/keryx",
23153
- version: "0.3.113",
23153
+ version: "0.3.114",
23154
23154
  description: "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
23155
23155
  private: false,
23156
23156
  publishConfig: {
@@ -68818,10 +68818,12 @@ function researchErrors(value, findings) {
68818
68818
  ids.add(id);
68819
68819
  if (!nonempty(o.source) || !nonempty(o.question))
68820
68820
  errors.push(`${id}: source and question are required`);
68821
- if (!["finding", "refuted", "out-of-scope"].includes(String(o.status)))
68821
+ if (!["finding", "refuted", "out-of-scope", "narrowed"].includes(String(o.status)))
68822
68822
  errors.push(`${id}: unresolved research (${String(o.status)})`);
68823
68823
  if (!nonempty(o.evidence) || !nonempty(o.reason))
68824
68824
  errors.push(`${id}: closure needs evidence and reason`);
68825
+ if (o.status === "narrowed" && !nonempty(o.remains))
68826
+ errors.push(`${id}: narrowed needs a "what remains" text`);
68825
68827
  if (o.status === "finding") {
68826
68828
  const matches = findings.filter((f) => f && typeof f === "object" && (f.id === o.finding || f.global_id === o.finding));
68827
68829
  if (!nonempty(o.finding) || matches.length !== 1)
@@ -78919,6 +78921,127 @@ function arbiterSession() {
78919
78921
  }
78920
78922
  var session;
78921
78923
 
78924
+ // src/review/rebuttal-citation.ts
78925
+ function citedPaths(text) {
78926
+ const found = new Set;
78927
+ for (const match of text.replace(URL2, " ").matchAll(CITED)) {
78928
+ const cited = match[1];
78929
+ if (cited.includes("/") || ROOT_DOCUMENT.test(cited))
78930
+ found.add(cited);
78931
+ }
78932
+ return [...found];
78933
+ }
78934
+ async function flagMissingCitations(findings, exists) {
78935
+ let added = 0;
78936
+ for (const finding of findings) {
78937
+ const disposition = finding.disposition;
78938
+ if (disposition === undefined || typeof disposition.evidence !== "string")
78939
+ continue;
78940
+ const missing = [];
78941
+ for (const cited of citedPaths(disposition.evidence)) {
78942
+ if (disposition.evidence.includes(marker(cited)))
78943
+ continue;
78944
+ if (!await exists(cited))
78945
+ missing.push(cited);
78946
+ }
78947
+ if (missing.length === 0)
78948
+ continue;
78949
+ disposition.evidence = `${disposition.evidence}
78950
+ ${missing.map(marker).join(`
78951
+ `)}`;
78952
+ added += missing.length;
78953
+ }
78954
+ return added;
78955
+ }
78956
+ var URL2, CITED, ROOT_DOCUMENT, marker = (cited) => `citation not found: ${cited}`;
78957
+ var init_rebuttal_citation = __esm(() => {
78958
+ URL2 = /\bhttps?:\/\/\S+/gi;
78959
+ CITED = /(?<![\w./-])((?:\.\.?\/)*(?:[\w.-]+\/)*[\w.-]+\.(?:mdc|mdx|md|txt|json|ya?ml|toml))(?![\w-])/g;
78960
+ ROOT_DOCUMENT = /^[A-Z][A-Z0-9_-]*\.(?:md|mdx|txt)$/;
78961
+ });
78962
+
78963
+ // src/review/complete-head-gate.ts
78964
+ async function completeHeadGate(input) {
78965
+ const { target } = input.manifest;
78966
+ if (target.kind !== "pr")
78967
+ return { warnings: [] };
78968
+ let pr;
78969
+ try {
78970
+ pr = await input.lookup(target.ref);
78971
+ } catch (error) {
78972
+ return { warnings: [`could not read the PR state for ${target.ref} (${error instanceof Error ? error.message : String(error)}); the head and merge check was skipped`] };
78973
+ }
78974
+ if (pr === null || !pr.exists)
78975
+ return { warnings: [`could not read the PR state for ${target.ref}; the head and merge check was skipped`] };
78976
+ const recorded = target.head ?? null;
78977
+ const reviewed = recorded !== null && USABLE_SHA.test(recorded) ? recorded : null;
78978
+ const unusable = recorded !== null && reviewed === null ? [`the reviewed head recorded for ${target.ref} ("${recorded}") is not a commit SHA; the moved-head check was skipped`] : [];
78979
+ const merged = typeof pr.state === "string" && pr.state.toUpperCase() === "MERGED";
78980
+ const prHead = typeof pr.headSha === "string" && pr.headSha !== "" ? pr.headSha : null;
78981
+ const moved = reviewed !== null && prHead !== null && !sameCommit(reviewed, prHead);
78982
+ if (!merged && !moved)
78983
+ return { warnings: unusable };
78984
+ const what = [merged ? "the PR is merged" : undefined, moved ? `the PR head moved (reviewed ${reviewed.slice(0, 7)}, now ${prHead.slice(0, 7)})` : undefined].filter(Boolean).join(" and ");
78985
+ const first = input.report.split(/\r?\n/).find((line) => line.trim() !== "" && !/^\s*<!--.*-->\s*$/.test(line)) ?? "";
78986
+ const names = reviewed === null ? /\b[0-9a-f]{7,40}\b/i.test(first) : first.toLowerCase().includes(reviewed.slice(0, 7).toLowerCase());
78987
+ if (names && SAYS_SO.test(first))
78988
+ return { warnings: unusable };
78989
+ throw new Error(`Cannot complete managed review; ${what}. The report's first line must name the reviewed SHA${reviewed === null ? "" : ` (${reviewed.slice(0, 7)})`} and say the PR moved, for example: "Reviewed ${(reviewed ?? "<sha>").slice(0, 7)} \u2014 the PR has since ${merged ? "merged" : "moved on"}."`);
78990
+ }
78991
+ var MOVE_WORD = "(?:merged|moved|advanced|superseded|closed|force-pushed)", PR_WORD = "(?:pr|pull request|head)", SAYS_SO, USABLE_SHA, sameCommit = (a, b) => {
78992
+ const [x, y] = [a.toLowerCase(), b.toLowerCase()];
78993
+ return x.startsWith(y) || y.startsWith(x);
78994
+ };
78995
+ var init_complete_head_gate = __esm(() => {
78996
+ SAYS_SO = new RegExp(`\\b${PR_WORD}\\b.{0,40}\\b${MOVE_WORD}\\b|\\b${MOVE_WORD}\\b.{0,40}\\b${PR_WORD}\\b`, "i");
78997
+ USABLE_SHA = /^[0-9a-f]{7,40}$/i;
78998
+ });
78999
+
79000
+ // src/review/evidence-level.ts
79001
+ function itemsOf(text) {
79002
+ return text.split(/[,;]/).map((item) => item.trim()).filter((item) => item.length > 0 && !PLACEHOLDER.test(item));
79003
+ }
79004
+ function withoutFences(report) {
79005
+ let fence = null;
79006
+ const kept = [];
79007
+ for (const line of report.split(/\r?\n/)) {
79008
+ const marker = /^\s*(```+|~~~+)/.exec(line)?.[1];
79009
+ if (marker !== undefined) {
79010
+ if (fence === null)
79011
+ fence = marker[0];
79012
+ else if (marker[0] === fence)
79013
+ fence = null;
79014
+ continue;
79015
+ }
79016
+ if (fence === null)
79017
+ kept.push(line);
79018
+ }
79019
+ return kept;
79020
+ }
79021
+ function parseEvidenceLevel(report) {
79022
+ const line = withoutFences(report).map((l) => LINE.exec(l)).find((m) => m !== null);
79023
+ if (!line)
79024
+ return { ok: false, reason: "the report has no `Evidence level:` line" };
79025
+ const value = line[1].replace(/(?:\*\*|__)\s*$/, "").trim();
79026
+ const staticOnly = /^static[ -]only\b/i.exec(value);
79027
+ if (staticOnly) {
79028
+ const items = itemsOf(value.slice(staticOnly[0].length).replace(SEPARATORS, "").replace(/^(?:did not run|not run|did not)\s*:?\s*/i, ""));
79029
+ return items.length > 0 ? { ok: true, level: "static-only", items } : { ok: false, reason: "`static only` must list what did not run" };
79030
+ }
79031
+ const ran = /^ran\b/i.exec(value);
79032
+ if (ran) {
79033
+ const items = itemsOf(value.slice(ran[0].length).replace(SEPARATORS, ""));
79034
+ return items.length > 0 ? { ok: true, level: "ran", items } : { ok: false, reason: "`ran` must list what ran" };
79035
+ }
79036
+ return { ok: false, reason: "the evidence level must start with `static only` or `ran`" };
79037
+ }
79038
+ var REPORT_FIELD_EVIDENCE_LEVEL = "evidence-level", LINE, SEPARATORS, PLACEHOLDER;
79039
+ var init_evidence_level = __esm(() => {
79040
+ LINE = /^\s*(?:[-*+]\s+)?(?:\*\*|__)?evidence level\s*:?\s*(?:\*\*|__)?\s*:?\s*(.*)$/i;
79041
+ SEPARATORS = /^[\s:\u2014\u2013-]+/;
79042
+ PLACEHOLDER = /^(?:none|nothing|n\/a|na|nil|-|\u2014|\u2013)\.?$/i;
79043
+ });
79044
+
78922
79045
  // src/review/verification.ts
78923
79046
  function verificationClaims(source) {
78924
79047
  if (source === undefined) {
@@ -79868,7 +79991,7 @@ var init_repair = __esm(() => {
79868
79991
  });
79869
79992
 
79870
79993
  // src/review/managed.ts
79871
- import { mkdir as mkdir52, readFile as readFile98, readdir as readdir33, realpath as realpath18, rm as rm13, stat as stat27 } from "fs/promises";
79994
+ import { lstat as lstat12, mkdir as mkdir52, readFile as readFile98, readdir as readdir33, realpath as realpath18, rm as rm13, stat as stat27 } from "fs/promises";
79872
79995
  import path188 from "path";
79873
79996
  function reviewsRoot(cwd) {
79874
79997
  return path188.join(cwd, ".metaproject", "reviews");
@@ -79937,6 +80060,7 @@ async function createManagedReviewPackage(input) {
79937
80060
  const repairs = repairMechanicalOmissions(reported);
79938
80061
  const head = await resolveTargetHead(input);
79939
80062
  await anchorFindings(reported, input, head);
80063
+ refuseUnresolvedLocators(reported, input, head);
79940
80064
  assignGlobalIds(reported, reviewId);
79941
80065
  const merged = mergeVerifications(reported, input.verifications ?? [], {
79942
80066
  mode: input.verificationMode ?? DEFAULT_VERIFICATION_MODE
@@ -79955,6 +80079,7 @@ async function createManagedReviewPackage(input) {
79955
80079
  ...roundDismissed
79956
80080
  ];
79957
80081
  assignGlobalIds(findings, reviewId);
80082
+ await flagMissingCitations(findings, (cited) => citationExists(input.cwd, head, cited));
79958
80083
  const carried = input.scope === undefined ? await readPreFilterScopeBlock(packageDir) : undefined;
79959
80084
  const concurrency = concurrencyPlan(input, coverage);
79960
80085
  const caps = {
@@ -80150,6 +80275,18 @@ async function completeManagedReview(cwd, ref, options = {}) {
80150
80275
  if (missing.length > 0) {
80151
80276
  throw new Error(`Cannot complete managed review; missing artifacts: ${missing.join(", ")}`);
80152
80277
  }
80278
+ const warnings = [];
80279
+ const reportText = await readFile98(path188.join(packageDir, "report.md"), "utf8");
80280
+ if (options.prLookup !== undefined)
80281
+ warnings.push(...(await completeHeadGate({ manifest, report: reportText, lookup: options.prLookup })).warnings);
80282
+ const evidence = parseEvidenceLevel(reportText);
80283
+ if (!evidence.ok) {
80284
+ const problem = `the report's evidence level is missing or invalid: ${evidence.reason}`;
80285
+ if (manifest.report_fields?.includes(REPORT_FIELD_EVIDENCE_LEVEL)) {
80286
+ throw new Error(`Cannot complete managed review; ${problem}. Add a line \`Evidence level: static only \u2014 did not run: <what>\` or \`Evidence level: ran \u2014 <what>\` to report.md`);
80287
+ }
80288
+ warnings.push(`${problem} (this package predates the field, so it is a warning)`);
80289
+ }
80153
80290
  const researchFindings = JSON.parse(await readFile98(path188.join(packageDir, "findings.json"), "utf8"));
80154
80291
  const researchPath = path188.join(packageDir, "research.json");
80155
80292
  const researchBefore = options.waiveCoverage === undefined ? undefined : await readFile98(researchPath, "utf8").catch(() => {
@@ -80193,7 +80330,7 @@ async function completeManagedReview(cwd, ref, options = {}) {
80193
80330
  packagePath: path188.relative(cwd, packageDir),
80194
80331
  ...options.now === undefined ? {} : { now: options.now }
80195
80332
  });
80196
- return { manifest: updated, reviewNotes, dispositions: tallyDispositions(recorded), ...coverageWaiver === undefined ? {} : { coverageWaiver } };
80333
+ return { manifest: updated, reviewNotes, dispositions: tallyDispositions(recorded), warnings, ...coverageWaiver === undefined ? {} : { coverageWaiver } };
80197
80334
  }
80198
80335
  async function validateManagedReviewManifest(cwd, manifest) {
80199
80336
  const errors = [];
@@ -80283,6 +80420,7 @@ function buildManifest(args) {
80283
80420
  coverage: args.coverage,
80284
80421
  filter_stats: args.filterStats,
80285
80422
  ...args.repairs.length === 0 ? {} : { repairs: args.repairs },
80423
+ report_fields: [REPORT_FIELD_EVIDENCE_LEVEL],
80286
80424
  ...args.input.cost === undefined ? {} : { cost: args.input.cost },
80287
80425
  ...crossFamily === undefined ? {} : { cross_family_review: crossFamily },
80288
80426
  ...args.input.automatic === true ? { automatic: true } : {},
@@ -80358,7 +80496,7 @@ function triage(findings, args) {
80358
80496
  }
80359
80497
  async function anchorFindings(findings, input, head) {
80360
80498
  const read = input.readTreeFile ?? (head === null ? readFromWorkingTree(input) : async (relative) => {
80361
- const contained = await containedRelativePath(input.cwd, relative);
80499
+ const contained = lexicalRelativePath(relative);
80362
80500
  return contained === null ? null : readFileAtCommit(input.cwd, head, contained);
80363
80501
  });
80364
80502
  for (const finding of findings) {
@@ -80384,17 +80522,42 @@ async function anchorFindings(findings, input, head) {
80384
80522
  };
80385
80523
  }
80386
80524
  }
80387
- async function containedRelativePath(cwd, relative) {
80388
- const root = await realpath18(cwd).catch(() => path188.resolve(cwd));
80389
- const resolved = path188.resolve(root, relative);
80390
- const real = await realpath18(resolved).catch(() => null);
80391
- if (real === null || real !== root && !real.startsWith(`${root}${path188.sep}`)) {
80525
+ function refuseUnresolvedLocators(findings, input, head) {
80526
+ if (head === null || input.readTreeFile !== undefined) {
80527
+ return;
80528
+ }
80529
+ const unresolved = findings.filter((finding) => finding.source !== "external" && finding.disposition === undefined && finding.locator?.state === "unlocatable");
80530
+ if (unresolved.length === 0) {
80531
+ return;
80532
+ }
80533
+ const lines = unresolved.map((finding) => {
80534
+ const locator = finding.locator?.state === "unlocatable" ? finding.locator : undefined;
80535
+ const line = locator?.reported_line ?? null;
80536
+ return ` - ${finding.id ?? finding.summary} (${finding.file}${line === null ? "" : `:${line}`}): ${locator?.reason ?? ""}`;
80537
+ });
80538
+ throw new Error(`Cannot ingest review; ${unresolved.length} finding(s) cite a quote that does not resolve at the reviewed SHA ${head.slice(0, 12)}:
80539
+ ${lines.join(`
80540
+ `)}
80541
+ Causes: the quote is not in the file at that commit, appears in it more than once, or the file or the commit is not in this checkout (check \`git cat-file -e ${head.slice(0, 12)}\`). Re-quote one distinct line from \`git show ${head.slice(0, 7)}:<path>\` or dismiss the finding with evidence.`);
80542
+ }
80543
+ async function citationExists(cwd, head, cited) {
80544
+ const relative = lexicalRelativePath(cited);
80545
+ if (relative === null) {
80546
+ return false;
80547
+ }
80548
+ if (head !== null && await readFileAtCommit(cwd, head, relative) !== null) {
80549
+ return true;
80550
+ }
80551
+ return lstat12(path188.resolve(cwd, relative)).then(() => true, () => false);
80552
+ }
80553
+ function lexicalRelativePath(relative) {
80554
+ const normalised = path188.posix.normalize(relative.replace(/\\/g, "/"));
80555
+ if (normalised === "." || normalised === ".." || normalised.startsWith("../") || path188.posix.isAbsolute(normalised)) {
80392
80556
  return null;
80393
80557
  }
80394
- return real;
80558
+ return normalised;
80395
80559
  }
80396
- async function readFileAtCommit(cwd, commit, absolute) {
80397
- const relative = path188.relative(await realpath18(cwd).catch(() => path188.resolve(cwd)), absolute);
80560
+ async function readFileAtCommit(cwd, commit, relative) {
80398
80561
  try {
80399
80562
  const proc = Bun.spawn(["git", "show", `${commit}:${relative}`], {
80400
80563
  cwd,
@@ -81091,6 +81254,9 @@ async function loadDocpackSchema(cwd) {
81091
81254
  var REQUIRED_ARTIFACTS, BLAST_RADIUS_ARTIFACT = "blast-radius.json", EMBEDDED_FINDINGS_FENCE, SEVERITY_PREFIX, NOT_RECORDED = (field) => `not recorded: derived from a markdown review report, which carried no ${field} field`, LABEL_LINE, HEADING_MARKER, FINDING_IDENTIFIER, TITLE_SEPARATOR, REFERENCE_PUNCTUATION, PRE_FILTER_SCOPE_BLOCK, BLAST_RADIUS_RECORD_SOURCE, MAX_SAME_DAY_ROUNDS = 99;
81092
81255
  var init_managed = __esm(() => {
81093
81256
  init_coverage_complete();
81257
+ init_rebuttal_citation();
81258
+ init_complete_head_gate();
81259
+ init_evidence_level();
81094
81260
  init_research();
81095
81261
  init_validator();
81096
81262
  init_contracts();
@@ -88980,7 +89146,7 @@ function sha256Hex5(bytes) {
88980
89146
  var init_checksum = () => {};
88981
89147
 
88982
89148
  // src/bundle/external.ts
88983
- import { mkdir as mkdir54, lstat as lstat12, readFile as readFile108, readdir as readdir40, rename as rename11, stat as stat29, unlink, writeFile as writeFile33 } from "fs/promises";
89149
+ import { mkdir as mkdir54, lstat as lstat13, readFile as readFile108, readdir as readdir40, rename as rename11, stat as stat29, unlink, writeFile as writeFile33 } from "fs/promises";
88984
89150
  import { createHmac as createHmac4, randomBytes as randomBytes10, timingSafeEqual as timingSafeEqual2 } from "crypto";
88985
89151
  import path215 from "path";
88986
89152
  function emptyRegistry() {
@@ -89024,7 +89190,7 @@ async function loadIntegrityKey(env, homeDir) {
89024
89190
  const keyPath = integrityKeyPathFor(env, homeDir);
89025
89191
  let st;
89026
89192
  try {
89027
- st = await lstat12(keyPath);
89193
+ st = await lstat13(keyPath);
89028
89194
  } catch (err) {
89029
89195
  if (err?.code === "ENOENT")
89030
89196
  return { ok: false, reason: "missing" };
@@ -89464,7 +89630,7 @@ var init_external = __esm(() => {
89464
89630
 
89465
89631
  // src/gdskills/governance/scout.ts
89466
89632
  import { existsSync as existsSync32, readFileSync as readFileSync19, statSync as statSync8 } from "fs";
89467
- import { lstat as lstat13, mkdtemp as mkdtemp2, readdir as readdir41, readFile as readFile109 } from "fs/promises";
89633
+ import { lstat as lstat14, mkdtemp as mkdtemp2, readdir as readdir41, readFile as readFile109 } from "fs/promises";
89468
89634
  import { tmpdir as tmpdir3 } from "os";
89469
89635
  import path216 from "path";
89470
89636
  function stemLite(token) {
@@ -89690,7 +89856,7 @@ async function scoutImports(query, opts = {}) {
89690
89856
  async function collectSkillDirectorySnapshot(dir, limits = DEFAULT_SNAPSHOT_VETTING_LIMITS) {
89691
89857
  let rootStat;
89692
89858
  try {
89693
- rootStat = await lstat13(dir);
89859
+ rootStat = await lstat14(dir);
89694
89860
  } catch (error) {
89695
89861
  return { ok: false, reason: "unreadable-dir", message: `cannot read ${dir}: ${error instanceof Error ? error.message : String(error)}` };
89696
89862
  }
@@ -89722,7 +89888,7 @@ async function collectSkillDirectorySnapshot(dir, limits = DEFAULT_SNAPSHOT_VETT
89722
89888
  const rel = relPrefix.length > 0 ? `${relPrefix}/${entry.name}` : entry.name;
89723
89889
  let entryStat;
89724
89890
  try {
89725
- entryStat = await lstat13(abs);
89891
+ entryStat = await lstat14(abs);
89726
89892
  } catch (error) {
89727
89893
  return { ok: false, reason: "unreadable-file", message: `cannot stat ${rel}: ${error instanceof Error ? error.message : String(error)}` };
89728
89894
  }
@@ -94092,7 +94258,7 @@ var init_slate_lifecycle = __esm(() => {
94092
94258
 
94093
94259
  // src/harness/tool/builtin/slate-memory-tools.ts
94094
94260
  import { createReadStream } from "fs";
94095
- import { lstat as lstat14, realpath as realpath19 } from "fs/promises";
94261
+ import { lstat as lstat15, realpath as realpath19 } from "fs/promises";
94096
94262
  import path223 from "path";
94097
94263
  import { createInterface } from "readline";
94098
94264
  function failure3(message) {
@@ -94110,7 +94276,7 @@ async function confinedFile(sessionDir, candidate, within) {
94110
94276
  try {
94111
94277
  const root = await realpath19(within === undefined ? sessionDir : path223.join(sessionDir, within));
94112
94278
  const sessionReal = await realpath19(sessionDir);
94113
- const info = await lstat14(path223.resolve(candidate));
94279
+ const info = await lstat15(path223.resolve(candidate));
94114
94280
  if (info.isSymbolicLink() || !info.isFile())
94115
94281
  return null;
94116
94282
  const real = await realpath19(path223.resolve(candidate));
@@ -99580,7 +99746,7 @@ async function resolveGitHooksRoot(projectRoot) {
99580
99746
  var init_git_hooks = () => {};
99581
99747
 
99582
99748
  // src/lib/managed-git-hook.ts
99583
- import { chmod as chmod9, lstat as lstat15, mkdir as mkdir57, readFile as readFile112, realpath as realpath20, writeFile as writeFile35 } from "fs/promises";
99749
+ import { chmod as chmod9, lstat as lstat16, mkdir as mkdir57, readFile as readFile112, realpath as realpath20, writeFile as writeFile35 } from "fs/promises";
99584
99750
  import path230 from "path";
99585
99751
  function escapeRegExp6(value) {
99586
99752
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -99611,13 +99777,13 @@ async function resolveContainedHookPath(projectRoot, hookName) {
99611
99777
  const commonDirReal = await realpath20(path230.dirname(hooksRoot)).catch(() => path230.dirname(hooksRoot));
99612
99778
  const projectRootReal = await realpath20(projectRoot).catch(() => projectRoot);
99613
99779
  const acceptedContainers = [commonDirReal, projectRootReal];
99614
- const hooksRootStat = await lstat15(hooksRoot).catch(() => null);
99780
+ const hooksRootStat = await lstat16(hooksRoot).catch(() => null);
99615
99781
  const hooksRootReal = await realpath20(hooksRoot).catch(() => hooksRoot);
99616
99782
  if (hooksRootStat?.isSymbolicLink()) {
99617
99783
  await assertResolvesInside(hooksRoot, acceptedContainers, "the git hooks directory");
99618
99784
  }
99619
99785
  const hookPath = path230.join(hooksRoot, hookName);
99620
- const hookStat = await lstat15(hookPath).catch(() => null);
99786
+ const hookStat = await lstat16(hookPath).catch(() => null);
99621
99787
  if (hookStat?.isSymbolicLink()) {
99622
99788
  await assertResolvesInside(hookPath, [hooksRootReal, projectRootReal], "the git hook file");
99623
99789
  }
@@ -107924,7 +108090,7 @@ var init_registry3 = __esm(() => {
107924
108090
  // src/harness/tool/builtin/spawn-subagent-tool.ts
107925
108091
  import { createHash as createHash74, randomUUID as randomUUID28 } from "crypto";
107926
108092
  import { execFile as execFile7 } from "child_process";
107927
- import { lstat as lstat16, mkdir as mkdir60, mkdtemp as mkdtemp3, readFile as readFile114, realpath as realpath21, rm as rm16, stat as stat31, writeFile as writeFile38 } from "fs/promises";
108093
+ import { lstat as lstat17, mkdir as mkdir60, mkdtemp as mkdtemp3, readFile as readFile114, realpath as realpath21, rm as rm16, stat as stat31, writeFile as writeFile38 } from "fs/promises";
107928
108094
  import { tmpdir as tmpdir7 } from "os";
107929
108095
  import path243 from "path";
107930
108096
  import { promisify as promisify5 } from "util";
@@ -108232,7 +108398,7 @@ async function verifyWorktreeCandidate(candidate, commonDir, worktreesDir) {
108232
108398
  try {
108233
108399
  const realCandidate = await realpath21(candidate);
108234
108400
  const dotGit = path243.join(realCandidate, ".git");
108235
- const dotGitStat = await lstat16(dotGit);
108401
+ const dotGitStat = await lstat17(dotGit);
108236
108402
  if (dotGitStat.isDirectory()) {
108237
108403
  return await realpath21(dotGit) === commonDir ? realCandidate : undefined;
108238
108404
  }
@@ -126701,6 +126867,12 @@ function layersLockPath(packageDir) {
126701
126867
  function packageMemoryDir(packageDir) {
126702
126868
  return path275.join(packageDir, LAYERS_DIR, MEMORY_DIR);
126703
126869
  }
126870
+ function failedRecently(existing, now) {
126871
+ if (existing.graph.status !== "failed")
126872
+ return false;
126873
+ const age = now.getTime() - Date.parse(existing.graph.failedAt ?? existing.refreshedAt);
126874
+ return age >= 0 && age < FAILED_RETRY_WINDOW_MS;
126875
+ }
126704
126876
  function layersRefreshInFlight(packageDir) {
126705
126877
  return isLockHeld(layersLockPath(packageDir), LOCK_STALE_MS2);
126706
126878
  }
@@ -126842,20 +127014,35 @@ async function sweepStaleWorktrees(deps, projectRoot) {
126842
127014
  await rm19(worktree, { recursive: true, force: true }).catch(() => {});
126843
127015
  }
126844
127016
  }
126845
- async function withDetachedWorktree(deps, input, rev, cleanupExtra, use) {
126846
- const worktree = await mkdtemp6(path275.join(tmpdir12(), WORKTREE_PREFIX));
126847
- const cleanup = async () => {
126848
- await cleanupExtra().catch(() => {});
126849
- await deps.git(input.projectRoot, ["worktree", "remove", "--force", "--end-of-options", worktree]);
126850
- await rm19(worktree, { recursive: true, force: true }).catch(() => {});
126851
- };
127017
+ async function withSignalCleanup(packageDir, body) {
127018
+ const cleanups = new Set;
126852
127019
  const onSignal = (signal) => {
126853
127020
  for (const name of SIGNALS)
126854
127021
  process.off(name, onSignal);
126855
- cleanup().then(() => rm19(layersLockPath(input.packageDir), { recursive: true, force: true })).catch(() => {}).finally(() => process.kill(process.pid, signal));
127022
+ const settled = Promise.all([...cleanups].map((cleanup) => cleanup().catch(() => {})));
127023
+ const bound = new Promise((resolve) => setTimeout(resolve, SIGNAL_CLEANUP_MAX_MS).unref());
127024
+ Promise.race([settled, bound]).then(() => rm19(layersLockPath(packageDir), { recursive: true, force: true })).catch(() => {}).finally(() => process.kill(process.pid, signal));
126856
127025
  };
126857
127026
  for (const name of SIGNALS)
126858
127027
  process.on(name, onSignal);
127028
+ try {
127029
+ return await body((cleanup) => {
127030
+ cleanups.add(cleanup);
127031
+ return () => void cleanups.delete(cleanup);
127032
+ });
127033
+ } finally {
127034
+ for (const name of SIGNALS)
127035
+ process.off(name, onSignal);
127036
+ }
127037
+ }
127038
+ async function withDetachedWorktree(deps, input, rev, cleanupExtra, register, use) {
127039
+ const worktree = await mkdtemp6(path275.join(tmpdir12(), WORKTREE_PREFIX));
127040
+ const cleanup = async () => {
127041
+ await cleanupExtra().catch(() => {});
127042
+ await deps.git(input.projectRoot, ["worktree", "remove", "--force", "--end-of-options", worktree]);
127043
+ await rm19(worktree, { recursive: true, force: true }).catch(() => {});
127044
+ };
127045
+ const unregister = register(cleanup);
126859
127046
  try {
126860
127047
  const add = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "worktree", "add", "--detach", "--force", "--end-of-options", worktree, rev];
126861
127048
  let added = await deps.git(input.projectRoot, add);
@@ -126867,19 +127054,22 @@ async function withDetachedWorktree(deps, input, rev, cleanupExtra, use) {
126867
127054
  throw new Error(`could not check out ${rev} to build its graph`);
126868
127055
  return await use(worktree, await deps.git(worktree, ["rev-parse", "HEAD"]));
126869
127056
  } finally {
126870
- for (const name of SIGNALS)
126871
- process.off(name, onSignal);
126872
- await cleanup();
127057
+ try {
127058
+ await cleanup();
127059
+ } finally {
127060
+ unregister();
127061
+ }
126873
127062
  }
126874
127063
  }
126875
- async function buildPackageGraph(deps, input, prHead) {
127064
+ async function buildPackageGraph(deps, input, prHead, register) {
126876
127065
  const failed = (error) => ({
126877
127066
  status: "failed",
126878
127067
  commit: null,
126879
127068
  builtAt: null,
126880
127069
  nodes: 0,
126881
127070
  edges: 0,
126882
- error
127071
+ error,
127072
+ failedAt: deps.now().toISOString()
126883
127073
  });
126884
127074
  if (prHead === null)
126885
127075
  return failed("no commit to build the graph at");
@@ -126887,7 +127077,7 @@ async function buildPackageGraph(deps, input, prHead) {
126887
127077
  const staging = `${dataDir}.${randomUUID35()}`;
126888
127078
  const dropStaging = () => rm19(staging, { recursive: true, force: true }).catch(() => {});
126889
127079
  try {
126890
- return await withDetachedWorktree(deps, input, prHead, dropStaging, async (worktree, builtFrom) => {
127080
+ return await withDetachedWorktree(deps, input, prHead, dropStaging, register, async (worktree, builtFrom) => {
126891
127081
  if (builtFrom !== prHead) {
126892
127082
  return failed(`graph would be built at ${builtFrom ?? "an unknown commit"}, not the PR head ${prHead}`);
126893
127083
  }
@@ -127005,7 +127195,7 @@ async function refreshLayers(input) {
127005
127195
  base = fromTarget ?? `${prHead}~1`;
127006
127196
  }
127007
127197
  await writeSelfIgnoringDir(path275.join(input.packageDir, LAYERS_DIR));
127008
- return withFileLock(layersLockPath(input.packageDir), async () => {
127198
+ return withFileLock(layersLockPath(input.packageDir), () => withSignalCleanup(input.packageDir, async (register) => {
127009
127199
  await recoverGraphDirectory(input.packageDir);
127010
127200
  await sweepStaleWorktrees(deps, input.projectRoot);
127011
127201
  const existing = await readLayers(input.packageDir);
@@ -127013,8 +127203,10 @@ async function refreshLayers(input) {
127013
127203
  let graph;
127014
127204
  if (input.force !== true && sameHead && await layersCurrent(input.packageDir, existing, prHead)) {
127015
127205
  graph = existing.graph;
127206
+ } else if (input.force !== true && sameHead && failedRecently(existing, deps.now())) {
127207
+ graph = existing.graph;
127016
127208
  } else {
127017
- graph = await buildPackageGraph(deps, input, prHead);
127209
+ graph = await buildPackageGraph(deps, input, prHead, register);
127018
127210
  if (graph.status === "failed" && sameHead && await layersCurrent(input.packageDir, existing, prHead)) {
127019
127211
  graph = existing.graph;
127020
127212
  }
@@ -127032,10 +127224,10 @@ async function refreshLayers(input) {
127032
127224
  wiki,
127033
127225
  memory
127034
127226
  };
127035
- await writeFileAtomic(layersFilePath(input.packageDir), `${JSON.stringify(layers, null, 2)}
127227
+ await deps.writeLayers(layersFilePath(input.packageDir), `${JSON.stringify(layers, null, 2)}
127036
127228
  `);
127037
127229
  return layers;
127038
- }, { timeoutMs: LOCK_WAIT_MS, staleMs: LOCK_STALE_MS2 });
127230
+ }), { timeoutMs: LOCK_WAIT_MS, staleMs: LOCK_STALE_MS2 });
127039
127231
  }
127040
127232
  async function loadPackageGraph(packageDir, prHead) {
127041
127233
  if (await layersRefreshInFlight(packageDir)) {
@@ -127045,7 +127237,8 @@ async function loadPackageGraph(packageDir, prHead) {
127045
127237
  if (layers === null)
127046
127238
  throw new PackageGraphError(`no ${LAYERS_FILE} in ${packageDir}; run \`keryx review layers refresh --package ${shellWord(packageDir)}\``);
127047
127239
  if (layers.graph.status !== "ok") {
127048
- throw new PackageGraphError(`the package graph is ${layers.graph.status}${layers.graph.error ? `: ${layers.graph.error}` : ""}`);
127240
+ const retry = layers.graph.status === "failed" ? `; a failed build is not retried for ${FAILED_RETRY_WINDOW_MS / 60000} minutes at the same head, run \`keryx review layers refresh --package ${shellWord(packageDir)} --force\` to retry now` : "";
127241
+ throw new PackageGraphError(`the package graph is ${layers.graph.status}${layers.graph.error ? `: ${layers.graph.error}` : ""}${retry}`);
127049
127242
  }
127050
127243
  if (!await graphIntact(packageDir, layers)) {
127051
127244
  throw new PackageGraphError(`the package graph directory is missing or does not match ${LAYERS_FILE}; run \`keryx review layers refresh --package ${shellWord(packageDir)}\``);
@@ -127057,7 +127250,7 @@ async function loadPackageGraph(packageDir, prHead) {
127057
127250
  const dataDir = packageGraphDir(packageDir);
127058
127251
  return { graph: await loadGraph(packageDir, dataDir), dataDir, layers };
127059
127252
  }
127060
- var execFileAsync9, LAYERS_FILE = "layers.json", LAYERS_VERSION = 1, LAYERS_DIR = "layers", GRAPH_DIR = "graph", GRAPH_BUILD_FILE = "build.json", MEMORY_DIR = "memory", LOCK_NAME = "refresh.lock", MANIFEST_FILE2 = "manifest.json", LOCK_STALE_MS2 = 60000, LOCK_WAIT_MS, WORKTREE_PREFIX = "keryx-layers-", WORKTREE_SWEEP_AGE_MS, SIGNALS, WIKI_PATH_PREFIX = ".metaproject/wiki", MEMORY_PATH_PREFIX = ".metaproject/memory", defaultLayerDeps, COMMIT_ID, PackageGraphError;
127253
+ var execFileAsync9, LAYERS_FILE = "layers.json", LAYERS_VERSION = 1, LAYERS_DIR = "layers", GRAPH_DIR = "graph", GRAPH_BUILD_FILE = "build.json", MEMORY_DIR = "memory", LOCK_NAME = "refresh.lock", MANIFEST_FILE2 = "manifest.json", LOCK_STALE_MS2 = 60000, LOCK_WAIT_MS, WORKTREE_PREFIX = "keryx-layers-", WORKTREE_SWEEP_AGE_MS, FAILED_RETRY_WINDOW_MS, SIGNALS, SIGNAL_CLEANUP_MAX_MS = 5000, WIKI_PATH_PREFIX = ".metaproject/wiki", MEMORY_PATH_PREFIX = ".metaproject/memory", defaultLayerDeps, COMMIT_ID, PackageGraphError;
127061
127254
  var init_layers = __esm(() => {
127062
127255
  init_build();
127063
127256
  init_fs();
@@ -127071,6 +127264,7 @@ var init_layers = __esm(() => {
127071
127264
  execFileAsync9 = promisify9(execFile11);
127072
127265
  LOCK_WAIT_MS = 16 * 60000;
127073
127266
  WORKTREE_SWEEP_AGE_MS = 30 * 60000;
127267
+ FAILED_RETRY_WINDOW_MS = 5 * 60000;
127074
127268
  SIGNALS = ["SIGINT", "SIGTERM"];
127075
127269
  defaultLayerDeps = {
127076
127270
  async git(cwd, args) {
@@ -127090,6 +127284,7 @@ var init_layers = __esm(() => {
127090
127284
  async indexMemory(cwd, indexDir) {
127091
127285
  return createMemoryService().index({ cwd, embeddings: false, indexDir });
127092
127286
  },
127287
+ writeLayers: writeFileAtomic,
127093
127288
  now: () => new Date
127094
127289
  };
127095
127290
  COMMIT_ID = /^[0-9a-f]{7,64}$/i;
@@ -127119,7 +127314,7 @@ function layersSummaryLine(layers) {
127119
127314
  function layersDetailLines(layers) {
127120
127315
  const lines = [];
127121
127316
  const graph = layers.graph;
127122
- lines.push(graph.status === "ok" ? `graph: ok @${shortSha(graph.commit)} = ${graphHeadState(layers)} (PR head ${shortSha(layers.prHead)}), ${graph.nodes} nodes, ${graph.edges} edges, built ${graph.builtAt ?? "?"}` : `graph: ${graph.status}${graph.error ? ` \u2014 ${graph.error}` : ""}`);
127317
+ lines.push(graph.status === "ok" ? `graph: ok @${shortSha(graph.commit)} = ${graphHeadState(layers)} (PR head ${shortSha(layers.prHead)}), ${graph.nodes} nodes, ${graph.edges} edges, built ${graph.builtAt ?? "?"}` : `graph: ${graph.status}${graph.error ? ` \u2014 ${graph.error}` : ""}${graph.status === "failed" ? " (not retried automatically for a few minutes at the same head; refresh --force retries now)" : ""}`);
127123
127318
  const wiki = layers.wiki;
127124
127319
  lines.push(`wiki: ${wiki.status} @${shortSha(wiki.commit)}, divergence ${divergenceText(wiki.divergence)}, ${wiki.pagesRefreshed} page(s) ${wiki.written ? "refreshed" : "would refresh (dry run)"}, ` + `${wiki.pagesStaleProse === null ? "stale prose unknown" : `${wiki.pagesStaleProse} page(s) with stale prose`}${wiki.error ? ` \u2014 ${wiki.error}` : ""}`);
127125
127320
  const memory = layers.memory;
@@ -127458,7 +127653,7 @@ var init_review_panel = __esm(() => {
127458
127653
  // src/tui/review-layers-actions.ts
127459
127654
  import { execFile as execFile12 } from "child_process";
127460
127655
  function layersRefreshArgv(entry, packageDir) {
127461
- return [...SAFE_BUN_SPAWN_ARGS, entry, "review", "layers", "refresh", "--package", packageDir, "--json"];
127656
+ return [...SAFE_BUN_SPAWN_ARGS, entry, "review", "layers", "refresh", "--package", packageDir, "--force", "--json"];
127462
127657
  }
127463
127658
  function killLiveChildren() {
127464
127659
  for (const child of liveChildren)
@@ -127474,8 +127669,14 @@ function makeLayersRefreshRunner(entry, timeoutMs = REFRESH_TIMEOUT_MS) {
127474
127669
  exitHookInstalled = true;
127475
127670
  process.once("exit", killLiveChildren);
127476
127671
  }
127672
+ const startedAt = Date.now();
127477
127673
  const child = execFile12(process.execPath, layersRefreshArgv(entry, packageDir), { cwd: projectRoot, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
127478
127674
  liveChildren.delete(child);
127675
+ if (error?.killed === true || /maxBuffer/i.test(error?.message ?? "")) {
127676
+ const timedOut = error?.killed === true && Date.now() - startedAt >= timeoutMs - 100;
127677
+ reject(new Error(timedOut ? `the layers refresh did not finish within ${Math.round(timeoutMs / 1000)}s and was stopped` : "the layers refresh printed more output than the limit allows and was stopped"));
127678
+ return;
127679
+ }
127479
127680
  try {
127480
127681
  resolve(JSON.parse(stdout));
127481
127682
  } catch {
@@ -137684,7 +137885,7 @@ function protectJsonIds(text) {
137684
137885
  const head = text.trim();
137685
137886
  if (head.length === 0 || head[0] !== "[" && head[0] !== "{")
137686
137887
  return;
137687
- if (text.includes(PLACEHOLDER))
137888
+ if (text.includes(PLACEHOLDER2))
137688
137889
  return;
137689
137890
  try {
137690
137891
  JSON.parse(head);
@@ -137694,7 +137895,7 @@ function protectJsonIds(text) {
137694
137895
  const hidden = [];
137695
137896
  const hide = (value) => {
137696
137897
  hidden.push(value);
137697
- return `${PLACEHOLDER}${letters(hidden.length - 1)}x`;
137898
+ return `${PLACEHOLDER2}${letters(hidden.length - 1)}x`;
137698
137899
  };
137699
137900
  let out = "";
137700
137901
  let i = 0;
@@ -137724,13 +137925,13 @@ function protectJsonIds(text) {
137724
137925
  }
137725
137926
  return {
137726
137927
  text: out,
137727
- restore: (scrubbed) => scrubbed.replace(new RegExp(`${PLACEHOLDER}([a-j]+)x`, "g"), (whole, index) => {
137928
+ restore: (scrubbed) => scrubbed.replace(new RegExp(`${PLACEHOLDER2}([a-j]+)x`, "g"), (whole, index) => {
137728
137929
  const at = Number(index.replace(/[a-j]/g, (c) => String(c.charCodeAt(0) - 97)));
137729
137930
  return hidden[at] ?? whole;
137730
137931
  })
137731
137932
  };
137732
137933
  }
137733
- var PLACEHOLDER = "kxjsonid", GITHUB_URL_PREFIX, GITHUB_URL_REST, SENSITIVE_KEY, NUMBER2;
137934
+ var PLACEHOLDER2 = "kxjsonid", GITHUB_URL_PREFIX, GITHUB_URL_REST, SENSITIVE_KEY, NUMBER2;
137734
137935
  var init_granted_json_ids = __esm(() => {
137735
137936
  GITHUB_URL_PREFIX = /^https:\/\/(?:api\.)?github\.com\/(?:repos\/)?[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\//;
137736
137937
  GITHUB_URL_REST = /^(?:actions\/runs|pull|issues)\/\d+(?:\/[A-Za-z0-9_-]+)*(?:#[A-Za-z_-]+-?\d+)?$/;
@@ -181120,7 +181321,7 @@ var init_retired_rules = __esm(() => {
181120
181321
  });
181121
181322
 
181122
181323
  // src/gdskills/guarded-fs-ops.ts
181123
- import { cp as cp2, lstat as lstat17, readFile as readFile171, readdir as readdir59, realpath as realpath25, unlink as unlink3 } from "fs/promises";
181324
+ import { cp as cp2, lstat as lstat18, readFile as readFile171, readdir as readdir59, realpath as realpath25, unlink as unlink3 } from "fs/promises";
181124
181325
  import { existsSync as existsSync63 } from "fs";
181125
181326
  import { createHash as createHash84 } from "crypto";
181126
181327
  import path340 from "path";
@@ -181211,7 +181412,7 @@ async function removeStaleRuntimeBuilds(skillsRoot, fsOps = { unlink: unlink3 })
181211
181412
  continue;
181212
181413
  const installedPath = path340.join(installedDir, entryName);
181213
181414
  try {
181214
- const stats = await lstat17(installedPath);
181415
+ const stats = await lstat18(installedPath);
181215
181416
  if (!stats.isFile()) {
181216
181417
  outcomes.push({ path: installedPath, action: "kept-not-regular-file" });
181217
181418
  continue;
@@ -181230,7 +181431,7 @@ async function removeStaleRuntimeBuilds(skillsRoot, fsOps = { unlink: unlink3 })
181230
181431
  }
181231
181432
  async function resolveSweepableDir(dir) {
181232
181433
  try {
181233
- const stats = await lstat17(dir);
181434
+ const stats = await lstat18(dir);
181234
181435
  if (stats.isSymbolicLink() || !stats.isDirectory()) {
181235
181436
  return {
181236
181437
  kind: "blocked",
@@ -181305,7 +181506,7 @@ async function removeUnmodifiedRetiredRules(rulesTarget, fsOps = { unlink: unlin
181305
181506
  const installedPath = path340.join(rulesTarget, retired.fileName);
181306
181507
  let stage = "lstat";
181307
181508
  try {
181308
- const stats = await lstat17(installedPath);
181509
+ const stats = await lstat18(installedPath);
181309
181510
  if (!stats.isFile()) {
181310
181511
  outcomes.push({ fileName: retired.fileName, action: "kept-not-regular-file" });
181311
181512
  continue;
@@ -181376,7 +181577,7 @@ var init_guarded_fs_ops = __esm(() => {
181376
181577
  });
181377
181578
 
181378
181579
  // src/gdskills/install.ts
181379
- import { lstat as lstat18, readFile as readFile172, readlink as readlink2 } from "fs/promises";
181580
+ import { lstat as lstat19, readFile as readFile172, readlink as readlink2 } from "fs/promises";
181380
181581
  import { existsSync as existsSync64 } from "fs";
181381
181582
  import path341 from "path";
181382
181583
  import { fileURLToPath as fileURLToPath6 } from "url";
@@ -181489,7 +181690,7 @@ async function installBundledSharedSkills(skillsRoot, metaprojectRoot) {
181489
181690
  async function checkInstallDestination(destination, displayPath) {
181490
181691
  let stats;
181491
181692
  try {
181492
- stats = await lstat18(destination);
181693
+ stats = await lstat19(destination);
181493
181694
  } catch (error) {
181494
181695
  if (isErrnoException(error) && error.code === "ENOENT") {
181495
181696
  return { usable: true };
@@ -181572,7 +181773,7 @@ async function installContracts(contractsRoot, metaprojectRoot) {
181572
181773
  async function checkInstallFile(destination, displayPath) {
181573
181774
  let stats;
181574
181775
  try {
181575
- stats = await lstat18(destination);
181776
+ stats = await lstat19(destination);
181576
181777
  } catch (error) {
181577
181778
  if (isMissingPathError(error)) {
181578
181779
  return { usable: true };
@@ -189768,7 +189969,7 @@ var init_plan = __esm(() => {
189768
189969
  });
189769
189970
 
189770
189971
  // src/gdskills/manifest/state.ts
189771
- import { lstat as lstat19, readFile as readFile182 } from "fs/promises";
189972
+ import { lstat as lstat20, readFile as readFile182 } from "fs/promises";
189772
189973
  import path363 from "path";
189773
189974
  function isValidInstallState(value) {
189774
189975
  return validateAgainstSchemaObject(INSTALL_STATE_SCHEMA, value).valid;
@@ -189811,7 +190012,7 @@ async function hasSymlinkSegment(repoRoot, posixRel) {
189811
190012
  current = path363.join(current, segment);
189812
190013
  let stat;
189813
190014
  try {
189814
- stat = await lstat19(current);
190015
+ stat = await lstat20(current);
189815
190016
  } catch {
189816
190017
  return false;
189817
190018
  }
@@ -190390,7 +190591,7 @@ var init_sync2 = __esm(() => {
190390
190591
  });
190391
190592
 
190392
190593
  // src/gdskills/import-skills.ts
190393
- import { lstat as lstat20, readdir as readdir64, readFile as readFile185, stat as stat41 } from "fs/promises";
190594
+ import { lstat as lstat21, readdir as readdir64, readFile as readFile185, stat as stat41 } from "fs/promises";
190394
190595
  import { homedir as homedir15 } from "os";
190395
190596
  import path368 from "path";
190396
190597
  function emptyOnlyError(label) {
@@ -191401,7 +191602,7 @@ async function decideReferencedRules(options, sources, refusedPackages) {
191401
191602
  async function listTemplateFiles(sourcePath) {
191402
191603
  const root = path368.join(path368.dirname(sourcePath), TEMPLATES_DIR);
191403
191604
  try {
191404
- if (!(await lstat20(root)).isDirectory())
191605
+ if (!(await lstat21(root)).isDirectory())
191405
191606
  return [];
191406
191607
  } catch {
191407
191608
  return [];
@@ -191823,7 +192024,7 @@ var init_import_skills = __esm(() => {
191823
192024
  });
191824
192025
 
191825
192026
  // src/gdskills/remove-skill.ts
191826
- import { lstat as lstat21, readdir as readdir65, readFile as readFile186, realpath as realpath26 } from "fs/promises";
192027
+ import { lstat as lstat22, readdir as readdir65, readFile as readFile186, realpath as realpath26 } from "fs/promises";
191827
192028
  import path369 from "path";
191828
192029
  async function removeProjectSkill(projectRoot, options) {
191829
192030
  if (!await pathExists(path369.join(projectRoot, ".metaproject"))) {
@@ -192018,7 +192219,7 @@ function normalizeSkillKey(input) {
192018
192219
  }
192019
192220
  async function lstatOrMissing(target) {
192020
192221
  try {
192021
- return await lstat21(target);
192222
+ return await lstat22(target);
192022
192223
  } catch (error) {
192023
192224
  if (isMissingPathError(error))
192024
192225
  return;
@@ -192083,7 +192284,7 @@ async function locateRewritable(root, relativePath) {
192083
192284
  if (absolute === root || !isPathInside(root, absolute)) {
192084
192285
  throw new Error(`${LABEL}: ${relativePath} resolves to ${absolute}, outside the project. Refusing to rewrite a file the project does not own. ${UNCHANGED}`);
192085
192286
  }
192086
- if (!(await lstat21(absolute)).isFile()) {
192287
+ if (!(await lstat22(absolute)).isFile()) {
192087
192288
  throw new Error(`${LABEL}: ${relativePath} is not a regular file. ${UNCHANGED}`);
192088
192289
  }
192089
192290
  const resolvedPath = toPosix(path369.relative(root, absolute));
@@ -197025,7 +197226,7 @@ function buildLedger(input) {
197025
197226
  continue;
197026
197227
  }
197027
197228
  obligations.push({ ...base, status: "unresolved" });
197028
- gaps.push({ detail: `${id}: raw finding is not in the canonical findings and has no closure`, next: `add a disposition {"id":"${id}","status":"finding|refuted|out-of-scope","evidence":\u2026,"reason":\u2026} to --dispositions` });
197229
+ gaps.push({ detail: `${id}: raw finding is not in the canonical findings and has no closure`, next: `add a disposition {"id":"${id}","status":"finding|refuted|out-of-scope|narrowed","evidence":\u2026,"reason":\u2026,"remains":\u2026 (narrowed only)} to --dispositions` });
197029
197230
  }
197030
197231
  }
197031
197232
  for (const given of input.dispositions) {
@@ -197053,7 +197254,9 @@ function closeWith(given, canonical) {
197053
197254
  return;
197054
197255
  if (given.status === "finding" && !(text4(given.finding) && canonical.filter((f) => f.id === given.finding || f.global_id === given.finding).length === 1))
197055
197256
  return;
197056
- return { status: given.status, evidence: given.evidence, reason: given.reason, ...given.status === "finding" ? { finding: given.finding } : {} };
197257
+ if (given.status === "narrowed" && !text4(given.remains))
197258
+ return;
197259
+ return { status: given.status, evidence: given.evidence, reason: given.reason, ...given.status === "finding" ? { finding: given.finding } : {}, ...given.status === "narrowed" ? { remains: given.remains } : {} };
197057
197260
  }
197058
197261
  async function ledgerBlockers(ledger, canonical, options) {
197059
197262
  const dispatch = dispatchErrors(ledger.dispatch);
@@ -197069,7 +197272,7 @@ var init_ledger2 = __esm(() => {
197069
197272
  init_slice();
197070
197273
  init_waves();
197071
197274
  FINISHED = new Set(["DONE", "DONE_WITH_CONCERNS"]);
197072
- CLOSED = new Set(["finding", "refuted", "out-of-scope"]);
197275
+ CLOSED = new Set(["finding", "refuted", "out-of-scope", "narrowed"]);
197073
197276
  OBJECT_START = /\{\s*["}]/y;
197074
197277
  RESULT_KEYS = new Set(["status", "reviewer", "findings"]);
197075
197278
  KEY_COLON = /\s*:/y;
@@ -197101,8 +197304,8 @@ needs_context and every slice its path gate requires covered by its dispatched p
197101
197304
  complete run (source-only, executionRequired false); anything else \u2014 NEEDS_CONTEXT, BLOCKED,
197102
197305
  INCOMPLETE, missing slices, no result \u2014 stays incomplete or not-run, never declared complete.
197103
197306
  Each raw finding becomes an obligation, closed only by a matching canonical finding or a
197104
- valid --dispositions entry ({id, status finding|refuted|out-of-scope, evidence, reason,
197105
- finding?}; default: the obligations already in research.json). scopeReviewed and rawReconciled
197307
+ valid --dispositions entry ({id, status finding|refuted|out-of-scope|narrowed, evidence, reason,
197308
+ finding?, remains? \u2014 required for narrowed: what of the finding still stands}; default: the obligations already in research.json). scopeReviewed and rawReconciled
197106
197309
  are true only when established.
197107
197310
 
197108
197311
  Slices are dispatched in waves bounded by the manifest's wave budget; a reviewer that covered
@@ -197348,24 +197551,36 @@ import path380 from "path";
197348
197551
  function sizeOf(block) {
197349
197552
  return Buffer.byteLength(JSON.stringify(block), "utf8");
197350
197553
  }
197554
+ function reasonOf(error) {
197555
+ const first = (error instanceof Error ? error.message : String(error)).split(`
197556
+ `)[0] ?? "unknown error";
197557
+ return first.length > MAX_REASON_CHARS ? `${first.slice(0, MAX_REASON_CHARS)}\u2026` : first;
197558
+ }
197351
197559
  function unique2(values) {
197352
197560
  return [...new Set(values)].sort();
197353
197561
  }
197354
197562
  function affectedFor(graph, files, depth) {
197355
197563
  const own = new Set(files);
197356
197564
  const found = new Set;
197565
+ const failures = [];
197357
197566
  for (const file of files) {
197358
197567
  let result;
197359
197568
  try {
197360
197569
  result = computeAffected(graph, file, { depth });
197361
- } catch {
197570
+ } catch (error) {
197571
+ failures.push(`${file}: ${reasonOf(error)}`);
197362
197572
  continue;
197363
197573
  }
197364
197574
  for (const dependent of result.dependents)
197365
197575
  if (!own.has(dependent))
197366
197576
  found.add(dependent);
197367
197577
  }
197368
- return [...found].sort();
197578
+ const named = failures.slice(0, MAX_NAMED_FAILURES).join("; ");
197579
+ const more = failures.length > MAX_NAMED_FAILURES ? ` (and ${failures.length - MAX_NAMED_FAILURES} more)` : "";
197580
+ return {
197581
+ files: [...found].sort(),
197582
+ unavailable: failures.length === 0 ? null : `not computed for ${failures.length} of ${files.length} file(s) \u2014 ${named}${more}`
197583
+ };
197369
197584
  }
197370
197585
  function symbolsFor(graph, files) {
197371
197586
  const symbols = graph.symbols ?? [];
@@ -197415,29 +197630,34 @@ function relatedTestsFor(graph, files, affected) {
197415
197630
  found.add(dependent);
197416
197631
  return [...found].sort();
197417
197632
  }
197418
- async function wikiFor(projectRoot, graph, files) {
197633
+ async function wikiFor(projectRoot, graph, files, readPage = (absolutePath) => readFile196(absolutePath, "utf8")) {
197419
197634
  let pages;
197420
197635
  try {
197421
197636
  pages = await collectPages(projectRoot);
197422
- } catch {
197423
- return [];
197637
+ } catch (error) {
197638
+ return { items: [], unavailable: `the wiki could not be read \u2014 ${reasonOf(error)}` };
197424
197639
  }
197425
197640
  const knownPaths = new Set(graph.nodes.map((node) => node.path));
197426
197641
  const keyFilesIndex = computeModuleKeyFiles(graph);
197427
197642
  const wanted = new Set(files);
197428
197643
  const out = [];
197644
+ const unreadable = [];
197429
197645
  for (const page of pages) {
197430
197646
  let content;
197431
197647
  try {
197432
- content = await readFile196(page.absolutePath, "utf8");
197648
+ content = await readPage(page.absolutePath);
197433
197649
  } catch {
197650
+ unreadable.push(page.relativePath);
197434
197651
  continue;
197435
197652
  }
197436
197653
  const set = resolveDescribeSet({ page, content, knownPaths, keyFilesIndex });
197437
197654
  if (set.paths.some((candidate) => wanted.has(candidate)))
197438
197655
  out.push({ page: page.relativePath, title: page.title });
197439
197656
  }
197440
- return out.sort((a, b) => a.page.localeCompare(b.page));
197657
+ return {
197658
+ items: out.sort((a, b) => a.page.localeCompare(b.page)),
197659
+ unavailable: unreadable.length === 0 ? null : `${unreadable.length} of ${pages.length} wiki page(s) could not be read \u2014 ${unreadable.slice(0, MAX_NAMED_FAILURES).join(", ")}`
197660
+ };
197441
197661
  }
197442
197662
  function modulesOf(file) {
197443
197663
  const parts = file.split("/").filter(Boolean);
@@ -197451,8 +197671,8 @@ async function memoryFor(projectRoot, files) {
197451
197671
  let entries;
197452
197672
  try {
197453
197673
  entries = await collectEntries(projectRoot);
197454
- } catch {
197455
- return [];
197674
+ } catch (error) {
197675
+ return { items: [], unavailable: `the memory could not be read \u2014 ${reasonOf(error)}` };
197456
197676
  }
197457
197677
  const modules = new Set(files.flatMap(modulesOf));
197458
197678
  const wanted = new Set(files);
@@ -197466,7 +197686,7 @@ async function memoryFor(projectRoot, files) {
197466
197686
  if (byModule || byFile)
197467
197687
  out.push({ path: entry.relativePath, title: entry.title, status: entry.status, module: module ?? "" });
197468
197688
  }
197469
- return out.sort((a, b) => a.path.localeCompare(b.path));
197689
+ return { items: out.sort((a, b) => a.path.localeCompare(b.path)), unavailable: null };
197470
197690
  }
197471
197691
  function fetchCommand(section, files, packageDir, projectRoot) {
197472
197692
  const first = files[0] ?? "<file>";
@@ -197496,18 +197716,28 @@ async function buildLayersBlock(input) {
197496
197716
  const { graph, layers } = await loadPackageGraph(input.packageDir);
197497
197717
  const base = async (depth) => {
197498
197718
  const affected = affectedFor(graph, files, depth);
197719
+ const wiki = await wikiFor(input.projectRoot, graph, files, input.readPage);
197720
+ const memory = await memoryFor(input.projectRoot, files);
197721
+ const unavailable = [];
197722
+ if (affected.unavailable !== null)
197723
+ unavailable.push({ section: "affected", reason: affected.unavailable });
197724
+ if (wiki.unavailable !== null)
197725
+ unavailable.push({ section: "wiki", reason: wiki.unavailable });
197726
+ if (memory.unavailable !== null)
197727
+ unavailable.push({ section: "memory", reason: memory.unavailable });
197499
197728
  return {
197500
197729
  version: LAYERS_BLOCK_VERSION,
197501
197730
  files,
197502
197731
  prHead: layers.prHead,
197503
197732
  graphCommit: layers.graphCommit,
197504
197733
  maxBytes,
197505
- affected: { depth, files: affected },
197734
+ affected: { depth, files: affected.files },
197506
197735
  symbols: symbolsFor(graph, files),
197507
- relatedTests: relatedTestsFor(graph, files, affected),
197508
- wiki: await wikiFor(input.projectRoot, graph, files),
197509
- memory: await memoryFor(input.projectRoot, files),
197510
- truncated: []
197736
+ relatedTests: relatedTestsFor(graph, files, affected.files),
197737
+ wiki: wiki.items,
197738
+ memory: memory.items,
197739
+ truncated: [],
197740
+ unavailable
197511
197741
  };
197512
197742
  };
197513
197743
  let block = await base(1);
@@ -197577,11 +197807,13 @@ function layersBlockLines(block) {
197577
197807
  ` wiki pages: ${block.wiki.length}`,
197578
197808
  ` memory entries: ${block.memory.length}`
197579
197809
  ];
197810
+ for (const gap of block.unavailable)
197811
+ lines.push(` ${gap.section}: unavailable (${gap.reason})`);
197580
197812
  for (const cut of block.truncated)
197581
197813
  lines.push(` truncated ${cut.section}: ${cut.shown}/${cut.total} \u2014 ${cut.command}`);
197582
197814
  return lines;
197583
197815
  }
197584
- var LAYERS_BLOCK_VERSION = 1, DEFAULT_LAYERS_BLOCK_MAX_BYTES = 8192, MAX_SYMBOLS_PER_FILE = 6, MAX_NEIGHBOURS_PER_SYMBOL = 4, MAX_BLOCK_FILES = 100, LAYERS_BLOCK_SECTIONS, CONTROL_CHARACTER;
197816
+ var LAYERS_BLOCK_VERSION = 1, DEFAULT_LAYERS_BLOCK_MAX_BYTES = 8192, MAX_SYMBOLS_PER_FILE = 6, MAX_NEIGHBOURS_PER_SYMBOL = 4, MAX_REASON_CHARS = 160, MAX_BLOCK_FILES = 100, LAYERS_BLOCK_SECTIONS, MAX_NAMED_FAILURES = 3, CONTROL_CHARACTER;
197585
197817
  var init_layers_payload = __esm(() => {
197586
197818
  init_affected();
197587
197819
  init_shell_quote();
@@ -197602,7 +197834,7 @@ function printLayersHelp() {
197602
197834
 
197603
197835
  keryx review layers refresh --package <dir> [--head <sha>] [--base <ref>] [--write-wiki] [--force] [--json]
197604
197836
  keryx review layers status --package <dir> [--json]
197605
- keryx review layers block --package <dir> --files <a,b,...> [--max-bytes <n>] [--json]
197837
+ keryx review layers block --package <dir> --files <a,b,...> [--max-bytes <n>] [--force] [--json]
197606
197838
 
197607
197839
  \`block\` prints the bounded \`layers\` block a reviewer payload carries for those files: files
197608
197840
  the graph says are affected (depth 1, then 2 while under the cap), symbol neighbours, related
@@ -197618,7 +197850,8 @@ The graph is built at the PR head into <dir>/layers/graph; the tracked graph art
197618
197850
  the working tree are not touched. The wiki and the memory are read from the project's
197619
197851
  working copy, and layers.json marks how far that is from the PR head. The wiki refresh is a
197620
197852
  dry run unless --write-wiki is given. A graph that is already built at this head and intact is
197621
- kept (the wiki and the memory are still refreshed); --force rebuilds it.`);
197853
+ kept (the wiki and the memory are still refreshed); --force rebuilds it. A graph that failed at
197854
+ this head is not rebuilt for 5 minutes after the failure; --force or a new head rebuilds it.`);
197622
197855
  }
197623
197856
  function wantsHelp2(args) {
197624
197857
  return args.includes("--help") || args.includes("-h");
@@ -197649,7 +197882,8 @@ async function ensurePackageLayers(packageDir, options = {}) {
197649
197882
  projectRoot,
197650
197883
  packageDir,
197651
197884
  ...head === undefined ? {} : { prHead: head },
197652
- ...options.base === undefined ? {} : { base: options.base }
197885
+ ...options.base === undefined ? {} : { base: options.base },
197886
+ ...options.force === true ? { force: true } : {}
197653
197887
  });
197654
197888
  }
197655
197889
  function print(layers, json) {
@@ -197708,7 +197942,7 @@ async function runLayers(args, deps) {
197708
197942
  if (maxBytes !== undefined && (!Number.isInteger(maxBytes) || maxBytes < 256))
197709
197943
  throw new Error("--max-bytes must be an integer of at least 256; a block with every list empty needs more than that, and the refusal names how much.");
197710
197944
  const projectRoot = resolveProjectRoot2(process.cwd());
197711
- await ensurePackageLayers(packageDir, { projectRoot });
197945
+ await ensurePackageLayers(packageDir, { projectRoot, force: rest.includes("--force") });
197712
197946
  const block = await buildLayersBlock({ projectRoot, packageDir, files, ...maxBytes === undefined ? {} : { maxBytes } });
197713
197947
  console.log(rest.includes("--json") ? JSON.stringify(block, null, 2) : layersBlockLines(block).join(`
197714
197948
  `));
@@ -197727,7 +197961,7 @@ var init_review_layers = __esm(() => {
197727
197961
  init_layers_payload();
197728
197962
  LAYERS_REFRESH_FLAGS = ["--package", "--head", "--base", "--write-wiki", "--force", "--json", "--help", "-h"];
197729
197963
  LAYERS_STATUS_FLAGS = ["--package", "--json", "--help", "-h"];
197730
- LAYERS_BLOCK_FLAGS = ["--package", "--files", "--max-bytes", "--json", "--help", "-h"];
197964
+ LAYERS_BLOCK_FLAGS = ["--package", "--files", "--max-bytes", "--force", "--json", "--help", "-h"];
197731
197965
  });
197732
197966
 
197733
197967
  // src/commands/review-chunks.ts
@@ -199687,6 +199921,11 @@ function rejectUnknownFlags(args, allowed, usage) {
199687
199921
  throw new Error(`Unknown option${unknown.length > 1 ? "s" : ""} for \`keryx review ${usage}\`: ${[...new Set(unknown)].join(", ")}. Accepted: ${allowed.join(", ")}. Refused rather than ignored \u2014 a flag that is silently dropped writes nothing and reports success.`);
199688
199922
  }
199689
199923
  }
199924
+ async function trackerPrLookup(ref) {
199925
+ if (!await githubAdapter.detect())
199926
+ return null;
199927
+ return githubAdapter.prStatus(ref);
199928
+ }
199690
199929
  async function reviewCommand(args, seams) {
199691
199930
  const command = args[0];
199692
199931
  if (!command || command === "--help" || command === "-h") {
@@ -201667,8 +201906,11 @@ async function runComplete3(args, seams) {
201667
201906
  manifest,
201668
201907
  reviewNotes,
201669
201908
  dispositions: tally,
201670
- coverageWaiver
201671
- } = await completeManagedReview(process.cwd(), ref, { dispositions, ...waiver?.value === undefined ? {} : { waiveCoverage: waiver.value } });
201909
+ coverageWaiver,
201910
+ warnings
201911
+ } = await completeManagedReview(process.cwd(), ref, { dispositions, prLookup: seams?.prLookup ?? trackerPrLookup, ...waiver?.value === undefined ? {} : { waiveCoverage: waiver.value } });
201912
+ for (const warning of warnings)
201913
+ console.error(`warning: ${warning}`);
201672
201914
  if (coverageWaiver !== undefined) {
201673
201915
  const shown = coverageWaiver.files.slice(0, FIRST_FILES).join(", ");
201674
201916
  console.log(`coverage waived by the operator (${coverageWaiver.time}): "${coverageWaiver.operatorQuote}"; ${coverageWaiver.files.length} file(s) not reviewed: ${shown}${coverageWaiver.files.length > FIRST_FILES ? ", \u2026" : ""}`);
@@ -201927,7 +202169,7 @@ Usage:
201927
202169
  [--package <review-dir>] [--json | --brief] [--out <file>]
201928
202170
  keryx review layers refresh --package <review-dir> [--head <sha>] [--base <ref>] [--write-wiki] [--force] [--json]
201929
202171
  keryx review layers status --package <review-dir> [--json]
201930
- keryx review layers block --package <review-dir> --files a,b [--max-bytes <n>] [--json]
202172
+ keryx review layers block --package <review-dir> --files a,b [--max-bytes <n>] [--force] [--json]
201931
202173
  Graph, wiki and memory for one review; no model is called. An intact graph at
201932
202174
  the same head is reused (--force rebuilds); a second refresh waits up to 16 min.
201933
202175
  keryx review chunks plan|estimate|next|record|merge --package <review-dir>
@@ -208130,7 +208372,7 @@ var init_tools3 = __esm(() => {
208130
208372
 
208131
208373
  // src/mcp/resources.ts
208132
208374
  import path396 from "path";
208133
- import { lstat as lstat22, readdir as readdir76, realpath as realpath28, stat as stat44 } from "fs/promises";
208375
+ import { lstat as lstat23, readdir as readdir76, realpath as realpath28, stat as stat44 } from "fs/promises";
208134
208376
  function mimeForPath(filePath) {
208135
208377
  if (filePath.endsWith(".json") || filePath.endsWith(".jsonl")) {
208136
208378
  return "application/json";
@@ -208153,7 +208395,7 @@ async function walkFiles(root, ownerRoot = root, ancestors = new Set) {
208153
208395
  if (!await pathExists(root)) {
208154
208396
  return [];
208155
208397
  }
208156
- const ownerInfo = await lstat22(ownerRoot).catch(() => null);
208398
+ const ownerInfo = await lstat23(ownerRoot).catch(() => null);
208157
208399
  if (!ownerInfo || !ownerInfo.isDirectory() || ownerInfo.isSymbolicLink())
208158
208400
  return [];
208159
208401
  const ownerReal = await realpath28(ownerRoot).catch(() => null);
@@ -224400,7 +224642,7 @@ function describeAvailability(entry, availability) {
224400
224642
  const range = verdict.state === "in-range" ? "within the recorded range" : verdict.state === "below-min" ? `below the recorded minimum ${verdict.min} \u2014 parsing may drift` : verdict.state === "above-max" ? `above the recorded maximum ${verdict.max} \u2014 parsing may drift` : "version banner not recognised";
224401
224643
  return `installed, ${version} (${range}); login not verified \u2014 keryx cannot know`;
224402
224644
  }
224403
- function marker(availability) {
224645
+ function marker2(availability) {
224404
224646
  if (availability.state === "available")
224405
224647
  return style.cyan("\u25CF");
224406
224648
  if (availability.state === "binary-missing")
@@ -224440,7 +224682,7 @@ function renderExternalAgents(rows, capability) {
224440
224682
  lines.push(capability.available ? "capability: enabled" : `capability: unavailable \u2014 ${capability.reason ?? "no reason given"}`);
224441
224683
  lines.push("");
224442
224684
  for (const row of rows) {
224443
- lines.push(` ${marker(row.availability)} ${row.entry.id} ${row.entry.label}`);
224685
+ lines.push(` ${marker2(row.availability)} ${row.entry.id} ${row.entry.label}`);
224444
224686
  lines.push(` ${describeAvailability(row.entry, row.availability)}`);
224445
224687
  lines.push(` transport: ${transportOf(row.entry)} sandbox: ${row.entry.sandboxModes.join(", ")} streaming: ${row.entry.streamingInput} ` + `resumable: ${row.entry.resumable} reports cost: ${row.entry.reportsCost}`);
224446
224688
  }
@@ -230794,7 +231036,7 @@ var init_types13 = __esm(() => {
230794
231036
  });
230795
231037
 
230796
231038
  // src/bundle/paths.ts
230797
- import { lstat as lstat23, readFile as readFile214 } from "fs/promises";
231039
+ import { lstat as lstat24, readFile as readFile214 } from "fs/promises";
230798
231040
  import path423 from "path";
230799
231041
  function scopeRoot(scope, ctx) {
230800
231042
  if (scope === "user") {
@@ -230988,7 +231230,7 @@ async function resolveContainedPathForWrite(root, relPath) {
230988
231230
  async function refuseSymlinkChain(root, relPath) {
230989
231231
  const rootResolved = path423.resolve(root);
230990
231232
  try {
230991
- const rootStat = await lstat23(rootResolved);
231233
+ const rootStat = await lstat24(rootResolved);
230992
231234
  if (rootStat.isSymbolicLink()) {
230993
231235
  return {
230994
231236
  ok: false,
@@ -231001,7 +231243,7 @@ async function refuseSymlinkChain(root, relPath) {
231001
231243
  for (const segment of segments) {
231002
231244
  current = path423.join(current, segment);
231003
231245
  try {
231004
- const stat = await lstat23(current);
231246
+ const stat = await lstat24(current);
231005
231247
  if (stat.isSymbolicLink()) {
231006
231248
  return {
231007
231249
  ok: false,
@@ -231051,7 +231293,7 @@ var init_paths5 = __esm(() => {
231051
231293
  });
231052
231294
 
231053
231295
  // src/bundle/archive.ts
231054
- import { readdir as readdir79, readFile as readFile215, lstat as lstat24 } from "fs/promises";
231296
+ import { readdir as readdir79, readFile as readFile215, lstat as lstat25 } from "fs/promises";
231055
231297
  import path424 from "path";
231056
231298
  import { gzipSync, gunzipSync } from "zlib";
231057
231299
  function resolveLimits(limits) {
@@ -231259,7 +231501,7 @@ async function openBundle(bundlePath, limits) {
231259
231501
  const resolved = resolveLimits(limits);
231260
231502
  let stat;
231261
231503
  try {
231262
- stat = await lstat24(bundlePath);
231504
+ stat = await lstat25(bundlePath);
231263
231505
  } catch {
231264
231506
  return { ok: false, refusal: { reason: BUNDLE_REFUSAL.notABundle, message: `no such path: ${bundlePath}` } };
231265
231507
  }
@@ -231273,7 +231515,7 @@ async function openBundle(bundlePath, limits) {
231273
231515
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
231274
231516
  const abs = path424.join(dir, entry.name);
231275
231517
  const rel = relPrefix.length > 0 ? `${relPrefix}/${entry.name}` : entry.name;
231276
- const entryStat = await lstat24(abs);
231518
+ const entryStat = await lstat25(abs);
231277
231519
  if (entryStat.isSymbolicLink()) {
231278
231520
  return { reason: BUNDLE_REFUSAL.symlinkRefused, path: rel, message: `${rel} is a symlink; bundles may not contain symlinks` };
231279
231521
  }
@@ -232148,7 +232390,7 @@ var init_learned_pattern_schema = __esm(() => {
232148
232390
 
232149
232391
  // src/bundle/export.ts
232150
232392
  import { execFileSync as execFileSync3 } from "child_process";
232151
- import { mkdir as mkdir89, readdir as readdir80, readFile as readFile216, writeFile as writeFile63, lstat as lstat25 } from "fs/promises";
232393
+ import { mkdir as mkdir89, readdir as readdir80, readFile as readFile216, writeFile as writeFile63, lstat as lstat26 } from "fs/promises";
232152
232394
  import { existsSync as existsSync78 } from "fs";
232153
232395
  import path425 from "path";
232154
232396
  function globToRegExp6(glob) {
@@ -232235,7 +232477,7 @@ async function walkFiles2(dir, relPrefix, out, skipped) {
232235
232477
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
232236
232478
  const abs = path425.join(dir, entry.name);
232237
232479
  const rel = relPrefix.length > 0 ? `${relPrefix}/${entry.name}` : entry.name;
232238
- const st = await lstat25(abs);
232480
+ const st = await lstat26(abs);
232239
232481
  if (st.isSymbolicLink()) {
232240
232482
  skipped.push({ path: rel, reason: "symlink" });
232241
232483
  continue;
@@ -232283,7 +232525,7 @@ async function collectAgents(root, skipped) {
232283
232525
  const candidates = [];
232284
232526
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
232285
232527
  const abs = path425.join(base, entry.name);
232286
- const st = await lstat25(abs);
232528
+ const st = await lstat26(abs);
232287
232529
  if (st.isSymbolicLink()) {
232288
232530
  skipped.push({ path: `agents/${entry.name}`, reason: "symlink" });
232289
232531
  continue;
@@ -232308,7 +232550,7 @@ async function collectMemory(root, skipped) {
232308
232550
  }
232309
232551
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
232310
232552
  const abs = path425.join(base, entry.name);
232311
- const st = await lstat25(abs);
232553
+ const st = await lstat26(abs);
232312
232554
  if (st.isSymbolicLink()) {
232313
232555
  skipped.push({ path: `memory/${folder}/${entry.name}`, reason: "symlink" });
232314
232556
  continue;
@@ -232324,7 +232566,7 @@ async function collectHookConfig(root) {
232324
232566
  const abs = path425.join(root, "hooks.json");
232325
232567
  if (!existsSync78(abs))
232326
232568
  return [];
232327
- const st = await lstat25(abs);
232569
+ const st = await lstat26(abs);
232328
232570
  if (st.isSymbolicLink() || !st.isFile())
232329
232571
  return [];
232330
232572
  return [{ bundlePath: "hooks.json", kind: "hook-config", absolutePath: abs }];
@@ -232705,7 +232947,7 @@ var init_applied_state = __esm(() => {
232705
232947
  });
232706
232948
 
232707
232949
  // src/lib/private-dir.ts
232708
- import { lstat as lstat26, mkdir as mkdir90, open as open12, readFile as readFile218 } from "fs/promises";
232950
+ import { lstat as lstat27, mkdir as mkdir90, open as open12, readFile as readFile218 } from "fs/promises";
232709
232951
  import path427 from "path";
232710
232952
  async function checkPrivateDirGitignore(dir, root) {
232711
232953
  if (root !== undefined) {
@@ -232724,7 +232966,7 @@ async function checkPrivateDirGitignore(dir, root) {
232724
232966
  }
232725
232967
  let dirStats;
232726
232968
  try {
232727
- dirStats = await lstat26(dir);
232969
+ dirStats = await lstat27(dir);
232728
232970
  } catch (error) {
232729
232971
  if (isNotFound(error)) {
232730
232972
  return { ok: true, action: "create" };
@@ -232745,7 +232987,7 @@ async function checkPrivateDirGitignore(dir, root) {
232745
232987
  const gitignorePath = path427.join(dir, ".gitignore");
232746
232988
  let stats;
232747
232989
  try {
232748
- stats = await lstat26(gitignorePath);
232990
+ stats = await lstat27(gitignorePath);
232749
232991
  } catch (error) {
232750
232992
  if (isNotFound(error)) {
232751
232993
  return { ok: true, action: "create" };
package/dist/core.js CHANGED
@@ -28739,10 +28739,12 @@ function researchErrors(value, findings) {
28739
28739
  ids.add(id);
28740
28740
  if (!nonempty(o.source) || !nonempty(o.question))
28741
28741
  errors.push(`${id}: source and question are required`);
28742
- if (!["finding", "refuted", "out-of-scope"].includes(String(o.status)))
28742
+ if (!["finding", "refuted", "out-of-scope", "narrowed"].includes(String(o.status)))
28743
28743
  errors.push(`${id}: unresolved research (${String(o.status)})`);
28744
28744
  if (!nonempty(o.evidence) || !nonempty(o.reason))
28745
28745
  errors.push(`${id}: closure needs evidence and reason`);
28746
+ if (o.status === "narrowed" && !nonempty(o.remains))
28747
+ errors.push(`${id}: narrowed needs a "what remains" text`);
28746
28748
  if (o.status === "finding") {
28747
28749
  const matches = findings.filter((f) => f && typeof f === "object" && (f.id === o.finding || f.global_id === o.finding));
28748
28750
  if (!nonempty(o.finding) || matches.length !== 1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.3.113",
3
+ "version": "0.3.114",
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": {
@@ -207,8 +207,7 @@ keryx review complete <review-id-or-path>
207
207
  [--finding <id> --disposition <state> --evidence <ref>]...
208
208
  ```
209
209
 
210
- **An unrecognised option is refused, not ignored** — a misspelled flag exits
211
- non-zero rather than printing `status: closed` and writing nothing at all.
210
+ **An unrecognised option is refused, not ignored** — a misspelled flag exits non-zero rather than printing `status: closed` and writing nothing at all.
212
211
 
213
212
  `--verifications` takes what `review-verifier` returned. `--scope` takes the
214
213
  whole `--json` output of `keryx review scope`, so the package records what the
@@ -234,9 +233,7 @@ Two channels, and both work:
234
233
  it: it is copied into the package it screened and removed from the slot, so a
235
234
  later round cannot be screened against a set nobody recomputed.
236
235
 
237
- Do **not** write the record inside the package directory before the ingest. A
238
- round that names no `--review-id` has not allocated that directory yet, and
239
- creating it makes the ingest take the next free name instead.
236
+ Do **not** write the record inside the package directory before the ingest. A round that names no `--review-id` has not allocated that directory yet, and creating it makes the ingest take the next free name instead.
240
237
 
241
238
  `--refuted` takes the findings this round **raised and then dismissed**, in the
242
239
  same finding shape, each carrying `disposition: {state, evidence}` with one of
@@ -252,9 +249,12 @@ Everything except `unknown` must cite where the outcome is written down — the
252
249
  commit, the test, the decision. A recorded state and its citation cannot be
253
250
  overwritten by a later close; record a correction as a new round.
254
251
 
255
- **Closing a fix round without dispositions leaves every finding reading
256
- `unknown`.** That is not "the reviewers were right"; it is "nobody wrote down
257
- what happened", and it is the single reason the precision figure cannot be read.
252
+ `review complete` on a PR-bound package refuses a merged PR or a moved head unless `report.md`'s first line names the reviewed SHA and says so; a package with no PR binding is unaffected.
253
+ `review complete` refuses a report with no `Evidence level:` line (`static only — did not run: <what>` or `ran — <what>`); a package ingested before the field existed only warns.
254
+ `review ingest` refuses an internal finding whose `quote` is not at its `path` at the reviewed SHA, and adds a `citation not found` line, never a deletion, to a rebuttal citing a rule or document that is not in the tree.
255
+ A finding may close as `narrowed` (research obligation `"status":"narrowed","remains":"<what remains>"`); it counts as closed only with that text, and the report keeps it under "Refuted and narrowed".
256
+
257
+ **Closing a fix round without dispositions leaves every finding reading `unknown`.** That is not "the reviewers were right"; it is "nobody wrote down what happened", and it is the single reason the precision figure cannot be read.
258
258
 
259
259
  Managed modes:
260
260
 
@@ -259,6 +259,19 @@
259
259
  },
260
260
  "additionalProperties": false
261
261
  }
262
+ },
263
+ "unavailable": {
264
+ "type": "array",
265
+ "description": "Sections that could not be read, with the reason. An empty section that is not listed here was read and held nothing.",
266
+ "items": {
267
+ "type": "object",
268
+ "required": ["section", "reason"],
269
+ "properties": {
270
+ "section": { "enum": ["affected", "wiki", "memory"] },
271
+ "reason": { "type": "string" }
272
+ },
273
+ "additionalProperties": false
274
+ }
262
275
  }
263
276
  },
264
277
  "additionalProperties": false
@@ -34,8 +34,10 @@ what a reader must act on; everything else is folded.
34
34
 
35
35
  ```markdown
36
36
  <!-- slot:pre-verdict -->
37
+ <only when the PR merged or its head moved: "Reviewed `<sha>` — the PR has since merged / moved on to `<new sha>`." as the very first line>
37
38
  **Changes requested — 1 Blocker, 2 Major, 4 Minor, 1 question.** <what holds, and where the risk is — one sentence.>
38
39
  Reviewed `<head>` against `<base>` · round N · <files> files, +<add>/−<del> · CI <green | red: check names>.
40
+ Evidence level: <static only — did not run: tests, build | ran — the commands that ran>
39
41
  <!-- slot:after-verdict -->
40
42
 
41
43
  ### Prior findings (fix rounds only)
@@ -43,6 +45,7 @@ Reviewed `<head>` against `<base>` · round N · <files> files, +<add>/−<del>
43
45
  ### Major
44
46
  ### Minor
45
47
  ### Questions
48
+ ### Refuted and narrowed (only when a finding was dismissed or narrowed)
46
49
 
47
50
  <details><summary><b>Pre-existing (not blocking)</b></summary> … </details>
48
51
  <details><summary><b>Scope</b></summary> … </details> (only when a pr-process note exists)
@@ -68,6 +71,25 @@ author answer, rendered under Questions, not as a finding.
68
71
  The second line is the context line: head, base, round, size, CI. If head moved
69
72
  during the round, add `· unchecked: <commits>`.
70
73
 
74
+ The third line is the **evidence level** and is required: `Evidence level: static
75
+ only — did not run: <what>` when nothing was executed, or `Evidence level: ran —
76
+ <what ran>`. `keryx review complete` refuses a report without it.
77
+
78
+ When the PR was merged or its head moved after the review, the report's first line
79
+ says so and names the reviewed SHA, or `keryx review complete` refuses.
80
+
81
+ ### Refuted and narrowed
82
+
83
+ One line per finding that did not stand as reported, kept with its evidence so a
84
+ reader sees what was claimed and why it was set aside:
85
+
86
+ ```markdown
87
+ - **F-007. Dismissed** — <the author's rebuttal, quoted>. <citation not found: …> where the cited rule is not in the tree.
88
+ - **F-009. Narrowed** — <what the finding claimed>. **Remains:** <what is still open>. Evidence: <what was run>.
89
+ ```
90
+
91
+ A narrowed finding counts as closed only with its "Remains" text.
92
+
71
93
  ## One finding
72
94
 
73
95
  Blocker and Major as a paragraph, visible: