@mrciphersmith/keryx 0.3.116 → 0.3.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +112 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1154,7 +1154,7 @@ Every managed review start, attach or ingest (and any `dispatch-check` or `blast
1154
1154
 
1155
1155
  ### Gates that keep a review report honest
1156
1156
 
1157
- `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).
1157
+ `keryx review complete` on a pull-request package (one that targets a PR, or whose `--ref` is a PR URL under any `--target`) 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).
1158
1158
 
1159
1159
  ### Review bot on pull requests
1160
1160
 
package/dist/cli.js CHANGED
@@ -23156,7 +23156,7 @@ var package_default;
23156
23156
  var init_package = __esm(() => {
23157
23157
  package_default = {
23158
23158
  name: "@mrciphersmith/keryx",
23159
- version: "0.3.116",
23159
+ version: "0.3.117",
23160
23160
  description: "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
23161
23161
  private: false,
23162
23162
  publishConfig: {
@@ -79130,7 +79130,7 @@ var init_rebuttal_citation = __esm(() => {
79130
79130
  // src/review/complete-head-gate.ts
79131
79131
  async function completeHeadGate(input) {
79132
79132
  const { target } = input.manifest;
79133
- if (target.kind !== "pr")
79133
+ if (!isPrBound(target))
79134
79134
  return { warnings: [] };
79135
79135
  let pr;
79136
79136
  try {
@@ -79155,13 +79155,14 @@ async function completeHeadGate(input) {
79155
79155
  return { warnings: unusable };
79156
79156
  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"}."`);
79157
79157
  }
79158
- var MOVE_WORD = "(?:merged|moved|advanced|superseded|closed|force-pushed)", PR_WORD = "(?:pr|pull request|head)", SAYS_SO, USABLE_SHA, sameCommit = (a, b) => {
79158
+ var MOVE_WORD = "(?:merged|moved|advanced|superseded|closed|force-pushed)", PR_WORD = "(?:pr|pull request|head)", SAYS_SO, USABLE_SHA, PR_URL2, isPrBound = (target) => target.kind === "pr" || PR_URL2.test(target.ref), sameCommit = (a, b) => {
79159
79159
  const [x, y] = [a.toLowerCase(), b.toLowerCase()];
79160
79160
  return x.startsWith(y) || y.startsWith(x);
79161
79161
  };
79162
79162
  var init_complete_head_gate = __esm(() => {
79163
79163
  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");
79164
79164
  USABLE_SHA = /^[0-9a-f]{7,40}$/i;
79165
+ PR_URL2 = /github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/;
79165
79166
  });
79166
79167
 
79167
79168
  // src/review/evidence-level.ts
@@ -80575,12 +80576,27 @@ async function reviewCompletionBlockers(packageDir) {
80575
80576
  async function completeManagedReview(cwd, ref, options = {}) {
80576
80577
  const packageDir = await resolveReviewPackagePath(cwd, ref);
80577
80578
  const manifestPath = path189.join(packageDir, "manifest.json");
80578
- const manifest = JSON.parse(await readFile99(manifestPath, "utf8"));
80579
+ const stored = JSON.parse(await readFile99(manifestPath, "utf8"));
80579
80580
  const missing = await missingArtifacts(packageDir);
80580
80581
  if (missing.length > 0) {
80581
80582
  throw new Error(`Cannot complete managed review; missing artifacts: ${missing.join(", ")}`);
80582
80583
  }
80583
80584
  const warnings = [];
80585
+ let manifest = stored;
80586
+ if (options.head !== undefined) {
80587
+ if (!isPrBound(stored.target)) {
80588
+ throw new Error(`--head applies to a pull request package; ${stored.reviewId} targets a ${stored.target.kind}.`);
80589
+ }
80590
+ const head = options.head.trim().toLowerCase();
80591
+ if (!/^[0-9a-f]{7,40}$/.test(head)) {
80592
+ throw new Error(`--head "${options.head}" is not a commit SHA. Give the commit this round ran against, 7-40 hex characters.`);
80593
+ }
80594
+ const recordedHead = stored.target.head;
80595
+ if (typeof recordedHead === "string" && recordedHead !== "" && !recordedHead.toLowerCase().startsWith(head) && !head.startsWith(recordedHead.toLowerCase())) {
80596
+ warnings.push(`--head ${head.slice(0, 7)} replaces the head recorded when the round started (${recordedHead.slice(0, 7)}); the manifest now records ${head.slice(0, 7)}`);
80597
+ }
80598
+ manifest = { ...stored, target: { ...stored.target, head } };
80599
+ }
80584
80600
  const reportText = await readFile99(path189.join(packageDir, "report.md"), "utf8");
80585
80601
  if (options.prLookup !== undefined)
80586
80602
  warnings.push(...(await completeHeadGate({ manifest, report: reportText, lookup: options.prLookup })).warnings);
@@ -99680,7 +99696,7 @@ function shellExecTool(root, run = makeCommandRunner(root), jobRegistry, options
99680
99696
  workingDirectory: root,
99681
99697
  definition: {
99682
99698
  name: "shell_exec",
99683
- 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. Output over 20,000 characters is saved in full to a " + "file: you see the head, the tail, the last lines of stderr, the line and byte counts of both streams " + "and the file's path, and read the rest with read_file or search_code on that path. Do not use " + "sed/grep/cat/awk here to locate code; use search_code or graph_symbol instead, which are built " + "for that and stay within the cap.",
99699
+ 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. The accepted range is 1000 to 1800000 (30 minutes); " + "a value outside it is clamped and the result says so. A command that stays silent for longer than 30 " + "minutes cannot be kept alive this way: make it print progress, or start it detached from the shell " + "(`setsid nohup <command> > <log> 2>&1 &`) and read its log. Output over 20,000 characters is saved in full to a " + "file: you see the head, the tail, the last lines of stderr, the line and byte counts of both streams " + "and the file's path, and read the rest with read_file or search_code on that path. Do not use " + "sed/grep/cat/awk here to locate code; use search_code or graph_symbol instead, which are built " + "for that and stay within the cap.",
99684
99700
  inputSchema: {
99685
99701
  type: "object",
99686
99702
  properties: {
@@ -99707,11 +99723,18 @@ function shellExecTool(root, run = makeCommandRunner(root), jobRegistry, options
99707
99723
  return run(command, ctx?.signal !== undefined ? { signal: ctx.signal } : {});
99708
99724
  }
99709
99725
  const startOpts = { phase: background ? "background" : "foreground" };
99726
+ let clampNote;
99727
+ const withClamp = (notice) => clampNote === undefined ? notice : `${notice}; ${clampNote}`;
99710
99728
  if (typeof input.description === "string" && input.description.length > 0) {
99711
99729
  startOpts.description = input.description;
99712
99730
  }
99713
99731
  if (typeof input.idle_timeout_ms === "number") {
99714
- startOpts.idleTimeoutMs = clampTaskIdleTimeoutMs(input.idle_timeout_ms);
99732
+ const effective = clampTaskIdleTimeoutMs(input.idle_timeout_ms);
99733
+ startOpts.idleTimeoutMs = effective;
99734
+ const requested = input.idle_timeout_ms;
99735
+ if (requested > MAX_TASK_IDLE_TIMEOUT_MS || requested < MIN_TASK_IDLE_TIMEOUT_MS || Number.isNaN(requested)) {
99736
+ clampNote = `idle_timeout_ms ${requested} was clamped to ${effective} (allowed ${MIN_TASK_IDLE_TIMEOUT_MS}-${MAX_TASK_IDLE_TIMEOUT_MS}); ` + (requested > MAX_TASK_IDLE_TIMEOUT_MS ? `a command silent for longer than ${MAX_TASK_IDLE_TIMEOUT_MS}ms is killed, so make it print progress or start it detached from the shell` : `a command silent for longer than ${effective}ms is killed`);
99737
+ }
99715
99738
  }
99716
99739
  const started = await jobRegistry.start(command, startOpts);
99717
99740
  if (!started.ok) {
@@ -99723,7 +99746,7 @@ function shellExecTool(root, run = makeCommandRunner(root), jobRegistry, options
99723
99746
  taskId,
99724
99747
  pid: started.pid,
99725
99748
  output: started.output,
99726
- notice: `running as a background task; read new output with shell_job_output("${taskId}") and stop it with shell_job_kill("${taskId}")`
99749
+ notice: withClamp(`running as a background task; read new output with shell_job_output("${taskId}") and stop it with shell_job_kill("${taskId}")`)
99727
99750
  });
99728
99751
  }
99729
99752
  const yieldMs = options?.yieldMs ?? resolveShellYieldMs();
@@ -99754,7 +99777,7 @@ function shellExecTool(root, run = makeCommandRunner(root), jobRegistry, options
99754
99777
  taskId,
99755
99778
  pid: started.pid,
99756
99779
  output: collected,
99757
- notice,
99780
+ notice: withClamp(notice),
99758
99781
  ...promoted.overCap !== undefined ? { overCap: promoted.overCap } : {}
99759
99782
  });
99760
99783
  }
@@ -99768,14 +99791,15 @@ ${lost}` : lost, isError: true };
99768
99791
  const exitCode = info.exitCode ?? 0;
99769
99792
  const idleKilled = info.status === "killed" && info.killReason === "idle";
99770
99793
  const idleNotice = idleKilled ? `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})` : undefined;
99794
+ const resultNotice = clampNote === undefined ? idleNotice : idleNotice === undefined ? `shell_exec: ${clampNote}` : `${idleNotice}; ${clampNote}`;
99771
99795
  const transcript = jobRegistry.takeTranscript?.(taskId);
99772
99796
  if (transcript !== undefined) {
99773
- return transcriptResult(transcript, info.status !== "completed", idleNotice, `(no output; exit ${exitCode})`);
99797
+ return transcriptResult(transcript, info.status !== "completed", resultNotice, `(no output; exit ${exitCode})`);
99774
99798
  }
99775
99799
  const bounded = boundOutput2((info.outputHead ?? collected).trim());
99776
99800
  const body = bounded.length > 0 ? bounded : `(no output; exit ${exitCode})`;
99777
- const output = idleNotice !== undefined ? `${body}
99778
- ${idleNotice}` : body;
99801
+ const output = resultNotice !== undefined ? `${body}
99802
+ ${resultNotice}` : body;
99779
99803
  return { output, isError: info.status !== "completed" };
99780
99804
  }
99781
99805
  };
@@ -143690,7 +143714,7 @@ function exitCodeFor(status) {
143690
143714
  return AUTONOMOUS_EXIT[status];
143691
143715
  }
143692
143716
  function parseAutonomousTarget(ref) {
143693
- const match = PR_URL2.exec(ref.trim());
143717
+ const match = PR_URL3.exec(ref.trim());
143694
143718
  if (match === null) {
143695
143719
  throw new Error(`--ref must be a pull request URL (https://github.com/<owner>/<repo>/pull/<number>), got: ${ref}`);
143696
143720
  }
@@ -143721,8 +143745,13 @@ function buildAutonomousPrompt(input) {
143721
143745
  `Open at most ${AUTONOMOUS_PROFILE.roundLimit} review rounds and dispatch at most ${AUTONOMOUS_PROFILE.waveSize} reviewers per wave. Stop opening rounds when a round finds no new blocker or major.`,
143722
143746
  `Dispatch the full reviewer set: run \`keryx review required --mode all\` and dispatch every reviewer it prints, with dispatch mode "all". Nobody can name a narrower review in a run like this, so do not use mode "diff".`,
143723
143747
  `Run the whole orchestration yourself in this session. A subagent has no shell and cannot dispatch reviewers (it answers BLOCKED nested_dispatch_unavailable), so hand a subagent only the review of one slice, never the orchestration, the ledger, the ingest or the complete.`,
143748
+ `The review skills live in this clone under \`.metaproject/skills/gdskills/review/\` (the orchestration is \`review-orchestrator/SKILL.md\` there): read them from that path and do not search the filesystem or \`$HOME/.keryx\` for them.`,
143749
+ `A guard refuses raw \`git diff\`, \`cat\`, \`sed\`, \`find\` and \`tail\` in the shell. Use \`keryx ctx diff\`, \`keryx ctx read <file>\` and \`keryx ctx run -- <command>\` instead, or end a command that must stay raw with \`# keryx:raw <why>\`.`,
143724
143750
  `The run is finished only when keryx review complete has closed the package.`
143725
143751
  ];
143752
+ if (input.head !== undefined && input.head !== null) {
143753
+ lines.push(`The pull request head is ${input.head}. Review exactly that commit, in a detached worktree at it, and pass \`--head ${input.head}\` to \`keryx review start\` (or attach) to \`keryx review ingest\` and to \`keryx review complete\`: without it keryx records the commit checked out in this directory, which is not the commit you reviewed, and the close is checked against that commit.`);
143754
+ }
143726
143755
  if (input.resumePackage !== undefined) {
143727
143756
  lines.push(`A package for this pull request request already exists: ${input.resumePackage}. Resume it and continue from where it stopped instead of starting a new one.`);
143728
143757
  }
@@ -143732,7 +143761,10 @@ function buildAutonomousPrompt(input) {
143732
143761
  function isFloorRefusal(output) {
143733
143762
  return FLOOR_REFUSAL_PATTERNS.some((pattern) => pattern.test(output));
143734
143763
  }
143735
- var AUTONOMOUS_PROFILE_ID = "review-autonomous", AUTONOMOUS_PROFILE, AUTONOMOUS_STATUSES, AUTONOMOUS_EXIT, PR_URL2, FLOOR_REFUSAL_PATTERNS;
143764
+ function isLoopGuardRefusal(output) {
143765
+ return LOOP_GUARD_REFUSAL.test(output);
143766
+ }
143767
+ var AUTONOMOUS_PROFILE_ID = "review-autonomous", AUTONOMOUS_PROFILE, AUTONOMOUS_STATUSES, AUTONOMOUS_EXIT, PR_URL3, FLOOR_REFUSAL_PATTERNS, LOOP_GUARD_REFUSAL;
143736
143768
  var init_autonomous_profile = __esm(() => {
143737
143769
  init_review_system();
143738
143770
  AUTONOMOUS_PROFILE = {
@@ -143752,17 +143784,18 @@ var init_autonomous_profile = __esm(() => {
143752
143784
  budget: 4,
143753
143785
  stalled: 5
143754
143786
  };
143755
- PR_URL2 = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)\/?$/;
143787
+ PR_URL3 = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)\/?$/;
143756
143788
  FLOOR_REFUSAL_PATTERNS = [
143757
143789
  /not approved by the user; not executed/i,
143758
143790
  new RegExp(REFUSED_EARLIER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i")
143759
143791
  ];
143792
+ LOOP_GUARD_REFUSAL = /same tool call already tried \d+\u00D7 \(hash budget\)/;
143760
143793
  });
143761
143794
 
143762
143795
  // src/review/autonomous-progress.ts
143763
143796
  import { closeSync as closeSync9, existsSync as existsSync58, openSync as openSync9, readSync as readSync5, statSync as statSync18 } from "fs";
143764
143797
  function emptyProgress() {
143765
- return { toolCalls: 0, openCalls: 0, phase: "starting", tokens: 0, usageSum: 0, rounds: 0, turnEnded: false, floorRefusals: [] };
143798
+ return { toolCalls: 0, openCalls: 0, phase: "starting", tokens: 0, usageSum: 0, rounds: 0, turnEnded: false, floorRefusals: [], endedOnLoopGuard: false };
143766
143799
  }
143767
143800
  function phaseOf(tool, rawInput) {
143768
143801
  try {
@@ -143800,6 +143833,7 @@ function foldEvent(state, event) {
143800
143833
  return {
143801
143834
  ...state,
143802
143835
  openCalls: Math.max(0, state.openCalls - 1),
143836
+ endedOnLoopGuard: event.isError === true && isLoopGuardRefusal(output),
143803
143837
  floorRefusals: refused ? [...state.floorRefusals, { phase: state.phase, output }] : state.floorRefusals
143804
143838
  };
143805
143839
  }
@@ -144111,7 +144145,7 @@ async function runLocked2(options, target, runDir) {
144111
144145
  const startedAt = new Date(startedAtMs).toISOString();
144112
144146
  const spec = {
144113
144147
  argv: buildAutonomousShellArgv({
144114
- prompt: buildAutonomousPrompt({ ref: target.ref, resumePackage }),
144148
+ prompt: buildAutonomousPrompt({ ref: target.ref, resumePackage, head }),
144115
144149
  eventsFile,
144116
144150
  provider: options.provider,
144117
144151
  model: options.model
@@ -144249,7 +144283,8 @@ async function runLocked2(options, target, runDir) {
144249
144283
  } else if (progress.floorRefusals.length > 0) {
144250
144284
  status = "floor";
144251
144285
  const first = progress.floorRefusals[0];
144252
- reason = `a floor refused ${progress.floorRefusals.length} call(s) nobody could approve (first at ${first.phase}) and the package was not closed`;
144286
+ const refused = `a floor refused ${progress.floorRefusals.length} call(s) nobody could approve (first at ${first.phase})`;
144287
+ reason = progress.endedOnLoopGuard ? `the loop guard stopped a repeated call at ${progress.phase} after ${refused} and the package was not closed` : `${refused} and the package was not closed`;
144253
144288
  } else if (found === undefined) {
144254
144289
  status = "incomplete";
144255
144290
  reason = `the shell ended (exit ${exitCode ?? 1}) before a review package was opened`;
@@ -197206,6 +197241,44 @@ function parseRunArgs(args) {
197206
197241
  json: args.includes("--json")
197207
197242
  };
197208
197243
  }
197244
+ function createHeartbeat(write, now = Date.now, minGapMs = HEARTBEAT_MIN_GAP_MS, keepAliveMs) {
197245
+ let lastKey = "";
197246
+ let lastAt = Number.NEGATIVE_INFINITY;
197247
+ let latest = { state: "running", phase: "starting", rounds: 0, tokens: 0 };
197248
+ let timer;
197249
+ let stopped = false;
197250
+ const emit = (progress) => {
197251
+ lastKey = `${progress.phase}|${progress.rounds}|${progress.tokens}`;
197252
+ lastAt = now();
197253
+ write(`heartbeat: phase ${progress.phase}, ${progress.rounds} round(s), ${progress.tokens} tokens`);
197254
+ arm();
197255
+ };
197256
+ const arm = () => {
197257
+ if (keepAliveMs === undefined || stopped)
197258
+ return;
197259
+ if (timer !== undefined)
197260
+ clearTimeout(timer);
197261
+ timer = setTimeout(() => emit(latest), keepAliveMs);
197262
+ timer.unref?.();
197263
+ };
197264
+ const beat = (progress) => {
197265
+ if (stopped || progress.state !== "running")
197266
+ return;
197267
+ latest = progress;
197268
+ const key = `${progress.phase}|${progress.rounds}|${progress.tokens}`;
197269
+ if (key === lastKey || now() - lastAt < minGapMs)
197270
+ return;
197271
+ emit(progress);
197272
+ };
197273
+ beat.stop = () => {
197274
+ stopped = true;
197275
+ if (timer !== undefined)
197276
+ clearTimeout(timer);
197277
+ timer = undefined;
197278
+ };
197279
+ arm();
197280
+ return beat;
197281
+ }
197209
197282
  async function runReviewRunCommand(args, deps = {}) {
197210
197283
  const log = deps.log ?? ((line) => console.log(line));
197211
197284
  const error = deps.error ?? ((line) => console.error(line));
@@ -197224,6 +197297,7 @@ async function runReviewRunCommand(args, deps = {}) {
197224
197297
  const target = parseAutonomousTarget(parsed.ref);
197225
197298
  const notifier = deps.notifier ?? (parsed.notify ? createTelegramNotifier({ project: path379.basename(cwd), target }) : NO_NOTIFIER);
197226
197299
  const run = deps.run ?? runAutonomousReview;
197300
+ const heartbeat = createHeartbeat(error, deps.now, undefined, deps.keepAliveMs ?? HEARTBEAT_KEEPALIVE_MS);
197227
197301
  const stop = new AbortController;
197228
197302
  const onSignal = () => stop.abort();
197229
197303
  process.on("SIGTERM", onSignal);
@@ -197234,6 +197308,7 @@ async function runReviewRunCommand(args, deps = {}) {
197234
197308
  cwd,
197235
197309
  ref: parsed.ref,
197236
197310
  signal: stop.signal,
197311
+ onProgress: heartbeat,
197237
197312
  stallMs: parsed.stallMinutes * 60000,
197238
197313
  ...parsed.budgetMinutes === undefined ? {} : { budgetMinutes: parsed.budgetMinutes },
197239
197314
  ...parsed.budgetTokens === undefined ? {} : { budgetTokens: parsed.budgetTokens },
@@ -197260,6 +197335,7 @@ async function runReviewRunCommand(args, deps = {}) {
197260
197335
  error(problem instanceof Error ? problem.message : String(problem));
197261
197336
  return 1;
197262
197337
  } finally {
197338
+ heartbeat.stop();
197263
197339
  process.off("SIGTERM", onSignal);
197264
197340
  process.off("SIGINT", onSignal);
197265
197341
  await notifier.close();
@@ -197268,7 +197344,7 @@ async function runReviewRunCommand(args, deps = {}) {
197268
197344
  async function runReviewRun(args) {
197269
197345
  process.exitCode = await runReviewRunCommand(args);
197270
197346
  }
197271
- var RUN_FLAGS, RUN_USAGE2 = "keryx review run --autonomous --target pr --ref <pull request URL> [--budget-minutes N] [--budget-tokens N] [--stall-minutes N] [--out <dir>] [--provider p] [--model m] [--no-notify] [--json]", RUN_HELP;
197347
+ var RUN_FLAGS, RUN_USAGE2 = "keryx review run --autonomous --target pr --ref <pull request URL> [--budget-minutes N] [--budget-tokens N] [--stall-minutes N] [--out <dir>] [--provider p] [--model m] [--no-notify] [--json]", RUN_HELP, HEARTBEAT_MIN_GAP_MS = 60000, HEARTBEAT_KEEPALIVE_MS = 60000;
197272
197348
  var init_review_run = __esm(() => {
197273
197349
  init_args();
197274
197350
  init_review_notify();
@@ -197294,7 +197370,11 @@ var init_review_run = __esm(() => {
197294
197370
 
197295
197371
  Runs one review of a pull request with nobody at the keyboard, in the review-autonomous profile.
197296
197372
  Exit codes: 0 complete, 2 incomplete, 3 stopped at a floor, 4 budget spent, 5 stalled, 1 refused
197297
- (bad arguments, or another run of the same pull request holds the lock).`;
197373
+ (bad arguments, or another run of the same pull request holds the lock).
197374
+ While it runs it writes a "heartbeat:" line to stderr when the phase, round count or token count
197375
+ changes (at most one a minute), and again whenever a minute passes with no line, so a wrapper with an
197376
+ idle timeout sees the run is alive. Also with --json, whose verdict is the only thing on stdout.
197377
+ A run that really stalls is still stopped by --stall-minutes (exit 5), and the lines end with it.`;
197298
197378
  });
197299
197379
 
197300
197380
  // src/review/ledger.ts
@@ -202420,6 +202500,10 @@ async function runComplete3(args, seams) {
202420
202500
  throw new Error("Usage: keryx review complete <review-id-or-path>");
202421
202501
  }
202422
202502
  const dispositions = parseDispositions(args);
202503
+ const headFlag = flagTokens(args).find((token) => token.name === "--head");
202504
+ if (headFlag !== undefined && (headFlag.value === undefined || headFlag.value.trim() === "")) {
202505
+ throw new Error("--head needs a commit SHA: --head <sha> (the commit this round ran against, 7-40 hex characters).");
202506
+ }
202423
202507
  const waiver = flagTokens(args).find((token) => token.name === "--waive-coverage");
202424
202508
  if (waiver !== undefined && (waiver.value === undefined || waiver.value.trim() === "")) {
202425
202509
  throw new Error(`--waive-coverage needs the operator's own words: --waive-coverage "<operator quote>".`);
@@ -202434,7 +202518,7 @@ async function runComplete3(args, seams) {
202434
202518
  dispositions: tally,
202435
202519
  coverageWaiver,
202436
202520
  warnings
202437
- } = await completeManagedReview(process.cwd(), ref, { dispositions, prLookup: seams?.prLookup ?? trackerPrLookup, ...waiver?.value === undefined ? {} : { waiveCoverage: waiver.value } });
202521
+ } = await completeManagedReview(process.cwd(), ref, { dispositions, prLookup: seams?.prLookup ?? trackerPrLookup, ...waiver?.value === undefined ? {} : { waiveCoverage: waiver.value }, ...headFlag?.value === undefined ? {} : { head: headFlag.value } });
202438
202522
  for (const warning of warnings)
202439
202523
  console.error(`warning: ${warning}`);
202440
202524
  if (coverageWaiver !== undefined) {
@@ -202478,7 +202562,7 @@ function parseDispositions(args) {
202478
202562
  if (token.value === undefined) {
202479
202563
  throw new Error(`${token.name} needs a value.`);
202480
202564
  }
202481
- if (token.name === "--waive-coverage") {
202565
+ if (token.name === "--waive-coverage" || token.name === "--head") {
202482
202566
  continue;
202483
202567
  }
202484
202568
  if (token.name === "--finding") {
@@ -202805,7 +202889,7 @@ ${COMMENTS_USAGE}
202805
202889
  keryx review report-parity <review-id-or-path>
202806
202890
  keryx review complete <review-id-or-path>
202807
202891
  [--finding <id> --disposition <state> --evidence <text>]...
202808
- [--waive-coverage "<operator quote>"]
202892
+ [--waive-coverage "<operator quote>"] [--head <sha>]
202809
202893
  keryx review lightweight
202810
202894
 
202811
202895
  An unrecognised option is REFUSED, not ignored. A flag that is silently dropped
@@ -202822,6 +202906,11 @@ Modes:
202822
202906
  the tree the reviewers actually read, which is deliberately preferred over a
202823
202907
  \`pr\` target's own head so that a round run against something other than what
202824
202908
  will merge FAILS the gate instead of passing it.
202909
+ \`review complete --head <sha>\` names the commit the round actually reviewed
202910
+ when the one recorded at start is wrong or missing; the head gate then checks
202911
+ that commit, and the manifest records it. The gate is not loosened: a head that
202912
+ is not the pull request's head still needs the report's first line to name it
202913
+ and say the PR moved.
202825
202914
 
202826
202915
  scope:
202827
202916
  Deterministic pre-filter, no model call. Drops generated, lockfile, snapshot,
@@ -203276,7 +203365,7 @@ var init_review = __esm(() => {
203276
203365
  "--catalog",
203277
203366
  "--json"
203278
203367
  ];
203279
- COMPLETE_FLAGS = ["--finding", "--disposition", "--evidence", "--waive-coverage"];
203368
+ COMPLETE_FLAGS = ["--finding", "--disposition", "--evidence", "--waive-coverage", "--head"];
203280
203369
  STACK_FLAGS = ["--json"];
203281
203370
  REVIEWERS_FLAGS = ["--json"];
203282
203371
  COMPARE_REFERENCE_FLAGS = ["--reference", "--package", "--input", "--json"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.3.116",
3
+ "version": "0.3.117",
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": {