@hone-ai/cli 1.39.0 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,18 @@
14
14
  * - The loop never approves and never posts a verdict (`verify-patch --report` does). It never passes
15
15
  * `--trust-source`, `--skip-verify`, `--ready` or `--acknowledge-*`: an untrusted story stays paused for a
16
16
  * person (HC-GF-270), and so does one whose repository never consented to running its tests (HC-GF-064).
17
+ * - HC-GF-363: under `verification.gate: enforce` the server refuses to approve a step_4 attempt whose
18
+ * verify-patch verdict is `fail`, and the loop used to stop there (exit 2). It now sends that one case back
19
+ * to the builder: POST /orchestrate/:runId/reject at step_4, with the measured failing tests as the reason,
20
+ * at most AUTO_REJECT_LIMIT (2) times per run. It rejects ONLY when every one of these holds: the gate is
21
+ * enforce for verify-patch, the server refuses approval, the latest completed step_4 attempt's LATEST
22
+ * verify-patch record says `fail` (not timeout, unresolved, not_run, error, pass_narrowed), the run's error
23
+ * says the same, and fewer than two earlier attempts carry AUTO_REJECT_PREFIX in their reject reason. The
24
+ * count is read from the server's rows on every decision, never kept here, so a restart cannot reset it; a
25
+ * read that fails means "unknown", and unknown never rejects. The two rejects spend step_4's re-execution
26
+ * budget (`max_retries_per_step`, default 2): a later step_5 route-back can pause MAX_RETRIES_EXCEEDED.
27
+ * A run stopped at the limit is named for a person (step, attempts, count, last failure) and the loop exits 2.
28
+ * Upgrading changes one thing: a run already paused on a refused `fail` is rejected on the first tick.
17
29
  * - It stops BEFORE acting when a breaker has fired. A terminal batch ends the loop after one last emit-pr pass.
18
30
  * - It is idempotent across restarts: an attempt that already has a verify-patch verdict on the server is not
19
31
  * verified again, and emit-pr's BRANCH_CONFLICT (5) means the branch was already emitted.
@@ -29,6 +41,7 @@
29
41
  */
30
42
 
31
43
  const { classifyPollState, POLL_OUTCOMES } = require('./poll-decision');
44
+ const { routerSafe } = require('./verify-patch');
32
45
  const { formatStoryLine } = require('./story-line');
33
46
  const { isGitRev, isStoryId } = require('./run-target');
34
47
  const {
@@ -92,10 +105,26 @@ function isTerminal(batch) {
92
105
  return !!(batch && batch.status && batch.status !== 'active');
93
106
  }
94
107
 
95
- /** The verify-patch verdict the server already holds for this run's step_4 attempt, or null. */
108
+ /**
109
+ * The verify-patch verdict the server holds for this run's step_4 attempt, or null: the LATEST record for it, as the
110
+ * server reads them (verdictForAttempt takes the last by reportedAt). A record names its attempt, or carries
111
+ * `forAttempt` (the latest completed attempt when it arrived). HC-GF-363: this used to return the FIRST match, so an
112
+ * attempt that failed and then passed read as failed.
113
+ */
96
114
  function priorVerdict(run, attempt) {
97
115
  const list = Array.isArray(run && run.verifications) ? run.verifications : [];
98
- return list.find((v) => v && v.source === 'verify-patch' && v.stepKey === 'step_4' && Number(v.attempt) === attempt) || null;
116
+ const forThis = (v) => v && v.source === 'verify-patch' && v.stepKey === 'step_4'
117
+ && (v.attempt != null ? Number(v.attempt) === attempt : v.forAttempt != null && Number(v.forAttempt) === attempt);
118
+ let best = null;
119
+ let bestAt = -Infinity;
120
+ for (const v of list) {
121
+ if (!forThis(v)) continue;
122
+ const at = Date.parse(v.reportedAt);
123
+ const t = Number.isFinite(at) ? at : -Infinity;
124
+ // Later by clock wins; equal or unreadable clocks fall back to list order (the server appends).
125
+ if (best === null || t >= bestAt) { best = v; bestAt = t; }
126
+ }
127
+ return best;
99
128
  }
100
129
 
101
130
  /**
@@ -123,6 +152,88 @@ function step4Action(run) {
123
152
  return { actionable: true, attempt };
124
153
  }
125
154
 
155
+ /** Marks a step_4 reject reason as this loop's own. It starts the reason (position 0), and is counted back from rows. */
156
+ const AUTO_REJECT_PREFIX = 'auto-rejected by follow-batch: ';
157
+
158
+ /** Automatic rejects per run. Each one is a full step_4 rebuild plus a verify-patch run. */
159
+ const AUTO_REJECT_LIMIT = 2;
160
+
161
+ /** The reason's tail, from the server-held verdict detail: one line, no control characters, bounded. */
162
+ const DETAIL_IN_REASON = 1500;
163
+
164
+ /**
165
+ * HC-GF-363: is this paused run a step_4 gate whose latest attempt FAILED verification under enforce, so that the
166
+ * loop may send it back? Every condition, none inferred from another (`approvalRefusal` alone is also set for a
167
+ * timeout, an unresolved or narrowed run, which are not measured failures and are never sent back):
168
+ * 1. a human gate at step_4 (not a breaker, budget, retry-limit or hard pause: condition 6 excludes those);
169
+ * 2. verification.gate is enforce for verify-patch;
170
+ * 3. the server refuses approval (`approvalRefusal`);
171
+ * 4. the latest step_4 attempt is completed (attempt L);
172
+ * 5. the LATEST verify-patch record for L has verdict exactly `fail`;
173
+ * 6. the run's error message is the one the enforce red branch writes for a `fail`.
174
+ * The count of earlier rejects (7) is read from the server by the caller: see autoRejectDecision.
175
+ * @returns {{attempt: number, detail: string}|null}
176
+ */
177
+ function autoRejectCandidate(run) {
178
+ const c = classifyPollState(run);
179
+ if (c.outcome !== POLL_OUTCOMES.HUMAN_GATE || c.stepKey !== 'step_4') return null;
180
+ // Not a breaker, budget, retry-limit or hard pause (NOT_A_GATE): each carries its own error prefix, so the
181
+ // enforce-fail message required below already excludes them, and tests/…/batch-follow-reject.test.js drives them.
182
+ const why = String((run && run.errorMessage) || '');
183
+ if (!gateEnforced(run.config, 'verify-patch')) return null;
184
+ if (!run.approvalRefusal) return null;
185
+ const attempt = currentStep4Attempt(run);
186
+ if (!attempt) return null;
187
+ const row = run.steps.find((st) => st && st.stepKey === 'step_4' && (Number(st.attempt) || 1) === attempt);
188
+ if (!row || row.status !== 'completed') return null;
189
+ // A hard pause (files the step asked for) that a fail verdict then overwrote the error message of: HC-GF-351's
190
+ // re-serve owns it, not this reject.
191
+ const fr = row.diagnostics && row.diagnostics.failure_record;
192
+ if (fr && fr.context_requests && typeof fr.context_requests === 'object') return null;
193
+ const v = priorVerdict(run, attempt);
194
+ if (!v || v.verdict !== 'fail') return null;
195
+ if (!why.startsWith("verify verdict 'fail' at step_4")) return null;
196
+ return { attempt, detail: String(v.detail == null ? '' : v.detail) };
197
+ }
198
+
199
+ /** The attempts (1-based, of `contexts`) whose reject reason starts with the prefix. */
200
+ function autoRejectedAttempts(contexts) {
201
+ const out = [];
202
+ (Array.isArray(contexts) ? contexts : []).forEach((ctx, i) => {
203
+ if (typeof ctx === 'string' && ctx.startsWith(AUTO_REJECT_PREFIX)) out.push(i + 1);
204
+ });
205
+ return out;
206
+ }
207
+
208
+ /**
209
+ * HC-GF-363: what to do about a paused run, given each earlier step_4 attempt's `errorContext`. Pure.
210
+ * @param {object} run a fresh `GET /orchestrate/:runId`
211
+ * @param {Array<string|null>|null} contexts `errorContext` of attempts 1..L-1, in order; null when any read failed
212
+ * @returns {{kind: 'none'} | {kind: 'unknown', attempt: number, why: string}
213
+ * | {kind: 'exhausted', attempt: number, count: number, rejected: number[], note: string}
214
+ * | {kind: 'reject', attempt: number, n: number, reason: string}}
215
+ */
216
+ function autoRejectDecision(run, contexts) {
217
+ const cand = autoRejectCandidate(run);
218
+ if (!cand) return { kind: 'none' };
219
+ const L = cand.attempt;
220
+ if (!Array.isArray(contexts) || contexts.length !== L - 1) {
221
+ return { kind: 'unknown', attempt: L, why: 'the earlier attempts could not all be read, so the rejects already used are unknown' };
222
+ }
223
+ const rejected = autoRejectedAttempts(contexts);
224
+ const count = rejected.length;
225
+ const clean = routerSafe(cand.detail).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, DETAIL_IN_REASON);
226
+ if (count >= AUTO_REJECT_LIMIT) {
227
+ const budget = contexts.some((x) => /MAX_RETRIES_EXCEEDED/.test(String(x || ''))) || /MAX_RETRIES_EXCEEDED/.test(String(run.errorMessage || ''))
228
+ ? ' MAX_RETRIES_EXCEEDED was seen: the rejects use step_4\'s retry budget.' : '';
229
+ return { kind: 'exhausted', attempt: L, count, rejected,
230
+ note: `step_4 attempt ${L} failed verify-patch; ${count} automatic reject(s) used (attempt${rejected.length === 1 ? '' : 's'} ${rejected.join(', ')}); `
231
+ + `last failure: ${clean.slice(0, 200) || '(no detail)'}.${budget} Read it: hone run-report ${run.runId || '<runId>'}` };
232
+ }
233
+ return { kind: 'reject', attempt: L, n: count + 1,
234
+ reason: `${AUTO_REJECT_PREFIX}attempt ${L} failed verify-patch under verification.gate: enforce (automatic reject ${count + 1} of ${AUTO_REJECT_LIMIT}). ${clean || '(no detail)'}` };
235
+ }
236
+
126
237
  /**
127
238
  * The argv for one child verb. Exit codes are the contract; `--format json` only carries detail.
128
239
  * @param {'check-patch'|'verify-patch'|'emit-pr'} verb
@@ -178,11 +289,14 @@ function draftBranch(storyId, runId, attempt) {
178
289
  /** One line of agent- or server-written text, safe to print: control characters (a terminal escape) become spaces. */
179
290
  const oneLine = (s, max = 240) => firstLine(String(s || '')).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, max);
180
291
 
292
+ /** Polls an accepted reject may wait for the worker before a person is told. */
293
+ const REJECT_WAIT_TICKS = 20;
294
+
181
295
  /** The statuses a batch child ends in. Anything else is still moving as far as the loop can tell. */
182
296
  const TERMINAL_RUN = new Set(['completed', 'failed', 'killed', 'stalled']);
183
297
 
184
298
  /** The shape every planTick return carries, so a caller never meets an absent field. `stop` is always the caller's. */
185
- const tickPlan = (over) => ({ actions: [], notes: [], waiting: [], moving: 0, follow: [], ...over });
299
+ const tickPlan = (over) => ({ actions: [], notes: [], waiting: [], moving: 0, follow: [], rejecting: [], ...over });
186
300
 
187
301
  /**
188
302
  * Batches the server's own rows say were re-served: a killed run whose reason names the batch its story went on in.
@@ -320,7 +434,7 @@ function decideReserve({ r, d, request, runs, details, ctx, prior, planned, batc
320
434
 
321
435
  /** What the loop says when every unfinished run is waiting on a person. */
322
436
  function stuckReason(waiting) {
323
- const who = waiting.map((w) => `${w.storyId} ${w.runId} at ${w.stepKey || 'a step'} (${oneLine(w.reason, 120)})`);
437
+ const who = waiting.map((w) => `${w.storyId} ${w.runId} at ${w.stepKey || 'a step'} (${oneLine(w.reason, 400)})`);
324
438
  return `every unfinished run is waiting on you: ${who.join('; ')}`;
325
439
  }
326
440
 
@@ -354,6 +468,7 @@ function planTick(batch, details, memory, ctx = {}) {
354
468
  const notes = [];
355
469
  const waiting = [];
356
470
  let moving = 0;
471
+ const rejecting = [];
357
472
  const runs = Array.isArray(b.runs) ? b.runs : [];
358
473
  // Stories already re-served: by this process (memory), or by an earlier one (a killed run says so).
359
474
  const follow = reservedFrom(b);
@@ -362,7 +477,7 @@ function planTick(batch, details, memory, ctx = {}) {
362
477
  const planned = new Set();
363
478
  const waitOn = (r, stepKey, reason) => waiting.push({ runId: r.runId, storyId: r.storyId, stepKey, reason });
364
479
  const needsYou = (r, stepKey, reason) => {
365
- const why = oneLine(reason, 300);
480
+ const why = oneLine(reason, 400);
366
481
  notes.push(`${r.storyId} ${r.runId} needs you at ${stepKey || 'a step'}: ${why}`);
367
482
  waitOn(r, stepKey, why);
368
483
  };
@@ -421,6 +536,19 @@ function planTick(batch, details, memory, ctx = {}) {
421
536
  continue;
422
537
  }
423
538
 
539
+ // HC-GF-363: a refused `fail` is sent back, once per attempt, by the executor, which re-reads the run and the
540
+ // earlier attempts' rows before it POSTs. `memory.rejected` and `rejectStuck` only stop this tick repeating
541
+ // itself; the bound is read from the server.
542
+ const cand = autoRejectCandidate(d);
543
+ if (cand) {
544
+ const key = `${r.runId}#${cand.attempt}`;
545
+ const stuck = memory.rejectStuck && memory.rejectStuck.get(key);
546
+ if (stuck) needsYou(r, 'step_4', stuck);
547
+ else if (memory.rejected && memory.rejected.has(key)) { moving += 1; rejecting.push(key); }
548
+ else actions.push({ kind: 'reject', runId: r.runId, storyId: r.storyId, attempt: cand.attempt, run: d });
549
+ continue;
550
+ }
551
+
424
552
  const a = step4Action(d);
425
553
  // verify-patch's verdict POST is best-effort: an attempt this process already ran is not re-run every tick.
426
554
  if (a.actionable && memory.tried && memory.tried.has(`${r.runId}#${a.attempt}`)) {
@@ -443,7 +571,7 @@ function planTick(batch, details, memory, ctx = {}) {
443
571
  if (!isTerminal(b)) {
444
572
  const stuck = actions.length === 0 && moving === 0 && waiting.length > 0;
445
573
  return tickPlan({ stop: stuck ? { kind: 'stuck', exitCode: EXIT.NEEDS_YOU, reason: stuckReason(waiting) } : null,
446
- actions, notes, waiting, moving, follow });
574
+ actions, notes, waiting, moving, follow, rejecting });
447
575
  }
448
576
  // A killed run that was re-served is not unfinished: its story went on in the batch it names.
449
577
  const raw = (b.counts && ((b.counts.failed || 0) + (b.counts.killed || 0) + (b.counts.stalled || 0))) || 0;
@@ -452,7 +580,7 @@ function planTick(batch, details, memory, ctx = {}) {
452
580
  stop: unfinished
453
581
  ? { kind: 'terminal', exitCode: EXIT.NEEDS_YOU, reason: `batch ${b.status}: ${unfinished} run(s) did not complete` }
454
582
  : { kind: 'terminal', exitCode: EXIT.DONE, reason: `batch ${b.status}` },
455
- actions, notes, waiting, moving, follow,
583
+ actions, notes, waiting, moving, follow, rejecting,
456
584
  });
457
585
  }
458
586
 
@@ -514,6 +642,69 @@ async function reserve(action, io, tag) {
514
642
  return { lines, needsYou: false, batchId, state: 'served' };
515
643
  }
516
644
 
645
+ /** The HTTP status of a thrown axios error, or undefined. */
646
+ const statusOf = (e) => (e && e.response && e.response.status) || undefined;
647
+
648
+ /**
649
+ * HC-GF-363: send a refused step_4 `fail` back to the builder, if the bound allows. Re-reads the run and re-decides
650
+ * right before the POST (the plan is up to one poll old), then counts the earlier rejects from each earlier attempt's
651
+ * stored reject reason. Any read that fails means the count is unknown, and unknown never rejects.
652
+ * @returns {Promise<{lines: string[], needsYou: boolean, state: 'posted'|'moved'|'stuck'|'retry', note?: string,
653
+ * countsRead?: boolean, fatal?: string}>}
654
+ * `retry`: nothing was sent, ask again next poll (`countsRead: true` when it was a read that failed, which is
655
+ * given up on after three); `fatal`: the server answered 401, 403 or 404 where it never should
656
+ */
657
+ async function autoReject(action, io, tag) {
658
+ const where = `step_4 attempt ${action.attempt}`;
659
+ const fatal = (e, what) => ([401, 403, 404].includes(statusOf(e)) ? `the server answered ${statusOf(e)} while ${what}` : null);
660
+ if (!io.rejectRun || !io.fetchStep) {
661
+ return { lines: [`${tag} ${where} failed verification; this client cannot reject, so it is left for you`], needsYou: true, state: 'stuck', note: `${where} failed verification and was not sent back` };
662
+ }
663
+ let fresh;
664
+ try {
665
+ fresh = await io.fetchRun(action.runId);
666
+ } catch (e) {
667
+ const f = fatal(e, `re-reading run ${action.runId}`);
668
+ if (f) return { lines: [], needsYou: false, state: 'retry', fatal: f };
669
+ return { lines: [`${tag} not rejected yet: could not re-read the run (${firstLine(e && e.message)}); asking again next poll`], needsYou: false, state: 'retry' };
670
+ }
671
+ const now = autoRejectCandidate(fresh);
672
+ if (!now || now.attempt !== action.attempt) {
673
+ return { lines: [`${tag} ${where} is no longer a refused failure; nothing sent`], needsYou: false, state: 'moved' };
674
+ }
675
+ const contexts = [];
676
+ for (let k = 1; k < action.attempt; k += 1) {
677
+ try {
678
+ const row = await io.fetchStep(action.runId, 'step_4', k);
679
+ contexts.push(row && typeof row.errorContext === 'string' ? row.errorContext : null);
680
+ } catch (e) {
681
+ return { lines: [`${tag} not rejected yet: could not read step_4 attempt ${k} (${statusOf(e) || firstLine(e && e.message)}), so the rejects already used are unknown; asking again next poll`],
682
+ needsYou: false, state: 'retry', countsRead: true };
683
+ }
684
+ }
685
+ const dec = autoRejectDecision(fresh, contexts);
686
+ if (dec.kind === 'exhausted') {
687
+ return { lines: [`${tag} ${dec.note}`], needsYou: true, state: 'stuck', note: dec.note };
688
+ }
689
+ if (dec.kind !== 'reject') {
690
+ return { lines: [`${tag} ${where}: no automatic reject (${dec.why || dec.kind})`], needsYou: false, state: 'retry', countsRead: true };
691
+ }
692
+ try {
693
+ await io.rejectRun(action.runId, { stepKey: 'step_4', reason: dec.reason });
694
+ } catch (e) {
695
+ const status = statusOf(e);
696
+ const f = fatal(e, `rejecting ${where}`);
697
+ if (f) return { lines: [], needsYou: false, state: 'retry', fatal: f };
698
+ if (status === 409) return { lines: [`${tag} ${where} was not rejected: the run moved on (409); this does not count`], needsYou: false, state: 'moved' };
699
+ if (status === 400) {
700
+ const note = `${where} failed verification and the server refused the reject (400: ${oneLine(e.response.data && e.response.data.error, 120)})`;
701
+ return { lines: [`${tag} ${note}`], needsYou: true, state: 'stuck', note };
702
+ }
703
+ return { lines: [`${tag} not rejected yet: ${status || firstLine(e && e.message)}; asking again next poll`], needsYou: false, state: 'retry' };
704
+ }
705
+ return { lines: [`${tag} ${where} failed verification: rejected it (automatic reject ${dec.n} of ${AUTO_REJECT_LIMIT}): ${oneLine(now.detail, 200)}`], needsYou: false, state: 'posted' };
706
+ }
707
+
517
708
  /**
518
709
  * Run one action's child verbs, in order, and describe what happened. Nothing that executes the adopter's tests
519
710
  * (verify-patch, or emit-pr, which re-verifies) starts unless the repository's consent and the story's provenance
@@ -527,6 +718,7 @@ async function reserve(action, io, tag) {
527
718
  async function runTask(action, io, opts = {}) {
528
719
  const tag = `[follow-batch] ${action.storyId} ${action.runId}`;
529
720
  if (action.kind === 'reserve') return reserve(action, io, tag);
721
+ if (action.kind === 'reject') return autoReject(action, io, tag);
530
722
  if (action.kind === 'emit' || action.kind === 'draft') {
531
723
  // emit-pr re-runs the tests before it commits, even as a local dry run, so it needs the same consent and
532
724
  // provenance verify-patch does. Without this, a repository that never set ci.local_command gets `make ci`.
@@ -577,7 +769,8 @@ function settle(stops, memory, again = []) {
577
769
  * @param {{fetchBatch: Function, fetchRun: Function, runVerb: Function, verifyDecision: Function,
578
770
  * sleep: (ms: number) => Promise<void>, now: () => number, log: (line: string) => void,
579
771
  * trackedPaths?: () => Promise<Set<string>>|Set<string>, realPath?: (file: string) => string|null,
580
- * priorServe?: Function, writeStoriesFile?: Function, killRun?: Function}} io
772
+ * priorServe?: Function, writeStoriesFile?: Function, killRun?: Function, rejectRun?: Function,
773
+ * fetchStep?: Function}} io
581
774
  * trackedPaths, realPath, priorServe, writeStoriesFile and killRun serve a re-serve; without trackedPaths or
582
775
  * realPath every request is refused, and without priorServe nobody asks whether the story was served already.
583
776
  * @param {{pollMs?: number, deadlineMs?: number|null, maxOfflineMs?: number, openPr?: boolean,
@@ -590,7 +783,10 @@ async function followBatch(batchId, io, opts = {}) {
590
783
  // owed: runs whose emit-pr needed a person. A completed batch with one of these is not DONE (HC-GF-207's shape:
591
784
  // a failure path that exits 0). reserved: story → what became of its re-serve ({batchId, state}). trackedFails:
592
785
  // consecutive ticks on which listing the tracked files failed.
593
- const memory = { emitted: new Set(), tried: new Set(), drafted: new Set(), said: new Set(), owed: new Map(), reserved: new Map(), trackedFails: 0 };
786
+ const memory = { emitted: new Set(), tried: new Set(), drafted: new Set(), said: new Set(), owed: new Map(), reserved: new Map(), trackedFails: 0,
787
+ // HC-GF-363: only to stop a tick repeating itself. rejected: attempt key → ticks waited since the reject was accepted;
788
+ // rejectStuck: key → why a person has to act; rejectReads: key → consecutive failed reads of the earlier attempts.
789
+ rejected: new Map(), rejectStuck: new Map(), rejectReads: new Map() };
594
790
  const followed = [batchId];
595
791
  const start = io.now();
596
792
  let offlineSince = null;
@@ -663,10 +859,34 @@ async function followBatch(batchId, io, opts = {}) {
663
859
  const halted = plans.find((plan) => plan.stop && plan.stop.kind === 'breaker');
664
860
  if (halted) return { exitCode: halted.stop.exitCode, reason: halted.stop.reason };
665
861
 
862
+ // An accepted reject that the worker has not applied for REJECT_WAIT_TICKS polls is named for a person.
863
+ for (const plan of plans) {
864
+ for (const key of plan.rejecting) {
865
+ const waited = (memory.rejected.get(key) || 0) + 1;
866
+ memory.rejected.set(key, waited);
867
+ if (waited >= REJECT_WAIT_TICKS) {
868
+ memory.rejectStuck.set(key, `a reject was accepted ${waited} polls ago and the run is still paused at the same attempt; look at it with hone run-report`);
869
+ }
870
+ }
871
+ }
872
+
666
873
  let grew = false;
667
874
  for (const plan of plans) {
668
875
  for (const action of plan.actions) {
669
876
  const out = await runTask(action, io, { openPr: opts.openPr });
877
+ if (action.kind === 'reject') {
878
+ if (out.fatal) return { exitCode: EXIT.FATAL, reason: out.fatal };
879
+ const key = `${action.runId}#${action.attempt}`;
880
+ if (out.state === 'posted' || out.state === 'moved') memory.rejected.set(key, 0);
881
+ else if (out.state === 'stuck') memory.rejectStuck.set(key, out.note);
882
+ else if (out.countsRead) {
883
+ const n = (memory.rejectReads.get(key) || 0) + 1;
884
+ memory.rejectReads.set(key, n);
885
+ if (n >= 3) memory.rejectStuck.set(key, `step_4 attempt ${action.attempt} failed verification, but the rejects already used could not be read after ${n} polls; nothing was sent`);
886
+ }
887
+ for (const line of out.lines) say(line);
888
+ continue;
889
+ }
670
890
  if (action.kind === 'reserve') {
671
891
  if (!out.retry) {
672
892
  memory.reserved.set(action.storyId, { batchId: out.batchId, state: out.state });
@@ -697,4 +917,5 @@ module.exports = {
697
917
  EXIT, FORBIDDEN_FLAGS, NOT_A_GATE,
698
918
  breakerStop, isTerminal, priorVerdict, step4Action, buildArgs, buildReserveArgs, planTick, runTask, followBatch,
699
919
  currentStep4Attempt, verifiedStep4Attempt, draftBranch, reservedFrom, otherPause, stuckReason, gateEnforced, stepRank,
920
+ AUTO_REJECT_PREFIX, AUTO_REJECT_LIMIT, autoRejectCandidate, autoRejectDecision, autoRejectedAttempts,
700
921
  };
@@ -74,6 +74,60 @@ function fencedSpans(text) {
74
74
  return { blocks, open: cur };
75
75
  }
76
76
 
77
+ /**
78
+ * The inline code spans of ONE line, as ranges (HC-GF-341 rules, extracted by HC-GF-292 so there is ONE walker).
79
+ *
80
+ * CommonMark's rule, on a single line: a run of N backticks opens a span that closes at the next run of EXACTLY
81
+ * N backticks; a run with no equal-length closer on the line is literal text, masks nothing and does not stop the
82
+ * scan. OUTSIDE a span a backtick after an odd number of backslashes is escaped (it cannot open, nor be part of an
83
+ * opening run); INSIDE a span a backslash is only a character. A span never crosses a line break.
84
+ *
85
+ * LINEAR: the line is cut into backtick runs once, the runs of each length are listed in order, and an opener asks
86
+ * for the first later run of its length with a cursor that only moves forward. A backreference regex backtracks
87
+ * across every longer run and is quadratic on a line of mixed runs, and the line is agent-written text.
88
+ *
89
+ * `from`/`to` address `line`: [from, to) covers the delimiters too; the content is
90
+ * `line.slice(from + openLen, to - openLen)`.
91
+ *
92
+ * @returns {Array<{from:number, to:number, openLen:number}>} in ascending order, disjoint
93
+ */
94
+ function inlineCodeSpans(line) {
95
+ const spans = [];
96
+ if (typeof line !== 'string' || line.indexOf('`') === -1) return spans;
97
+ const starts = [];
98
+ const lens = [];
99
+ for (let i = line.indexOf('`'); i !== -1;) {
100
+ let j = i + 1;
101
+ while (j < line.length && line.charCodeAt(j) === 96) j++;
102
+ starts.push(i);
103
+ lens.push(j - i);
104
+ i = line.indexOf('`', j);
105
+ }
106
+ const runsOfLength = new Map(); // length -> { at: ascending run indices, p: cursor }
107
+ for (let k = 0; k < starts.length; k++) {
108
+ let entry = runsOfLength.get(lens[k]);
109
+ if (!entry) { entry = { at: [], p: 0 }; runsOfLength.set(lens[k], entry); }
110
+ entry.at.push(k);
111
+ }
112
+ const closerFor = (width, after) => {
113
+ const entry = runsOfLength.get(width);
114
+ if (!entry) return -1;
115
+ while (entry.p < entry.at.length && entry.at[entry.p] <= after) entry.p++;
116
+ return entry.p < entry.at.length ? entry.at[entry.p] : -1;
117
+ };
118
+ for (let k = 0; k < starts.length;) {
119
+ let slashes = 0;
120
+ for (let q = starts[k] - 1; q >= 0 && line.charCodeAt(q) === 92; q--) slashes++;
121
+ const escaped = slashes & 1; // 1: the run's first backtick is a literal one
122
+ const width = lens[k] - escaped;
123
+ const closer = width > 0 ? closerFor(width, k) : -1;
124
+ if (closer === -1) { k++; continue; }
125
+ spans.push({ from: starts[k] + escaped, to: starts[closer] + lens[closer], openLen: width });
126
+ k = closer + 1;
127
+ }
128
+ return spans;
129
+ }
130
+
77
131
  /**
78
132
  * A `file:` block as one regex: group 1 the fence, 2 the path, 3 the body.
79
133
  *
@@ -117,6 +171,7 @@ module.exports = {
117
171
  fenceWidth,
118
172
  closesFence,
119
173
  fencedSpans,
174
+ inlineCodeSpans,
120
175
  DIRECTIVE_RE,
121
176
  FILE_BLOCK_RE,
122
177
  EDIT_BLOCK_RE,