mandrel 2.9.0 → 2.10.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.
Files changed (57) hide show
  1. package/.agents/agents/.markdownlint.json +4 -0
  2. package/.agents/agents/acceptance-critic.md +30 -5
  3. package/.agents/agents/auditor.md +36 -19
  4. package/.agents/agents/plan-critic.md +31 -5
  5. package/.agents/agents/story-worker.md +91 -100
  6. package/.agents/docs/configuration.md +16 -4
  7. package/.agents/docs/execution-reference.md +13 -0
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +131 -265
  10. package/.agents/rules/git-conventions.md +47 -83
  11. package/.agents/rules/orchestration-error-handling.md +28 -0
  12. package/.agents/schemas/agentrc.schema.json +24 -2
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +1 -1
  15. package/.agents/scripts/apply-quality-bootstrap.js +1 -1
  16. package/.agents/scripts/check-test-temp-hygiene.js +438 -0
  17. package/.agents/scripts/deliver-recover.js +23 -6
  18. package/.agents/scripts/lib/audit-suite/index.js +5 -0
  19. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
  20. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  21. package/.agents/scripts/lib/config/temp-paths.js +121 -1
  22. package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
  23. package/.agents/scripts/lib/config-settings-schema.js +1 -1
  24. package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
  25. package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
  26. package/.agents/scripts/lib/orchestration/complexity-gate.js +113 -2
  27. package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
  28. package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
  29. package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
  30. package/.agents/scripts/lib/orchestration/plan-context.js +57 -0
  31. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
  32. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
  33. package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
  34. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
  35. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
  36. package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
  37. package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
  38. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
  40. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
  41. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
  42. package/.agents/scripts/lib/templates/decomposer-prompts.js +13 -6
  43. package/.agents/scripts/lib/test-env.js +65 -0
  44. package/.agents/scripts/plan-context.js +66 -9
  45. package/.agents/scripts/plan-critics.js +115 -3
  46. package/.agents/scripts/plan-persist.js +11 -1
  47. package/.agents/scripts/plan-run-epilogue.js +1 -1
  48. package/.agents/scripts/single-story-confirm-merge.js +65 -5
  49. package/.agents/scripts/stories-wave-tick.js +1 -1
  50. package/.agents/workflows/deliver.md +86 -230
  51. package/.agents/workflows/helpers/deliver-reference.md +167 -0
  52. package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
  53. package/.agents/workflows/helpers/deliver-story.md +114 -432
  54. package/.agents/workflows/helpers/plan-reference.md +211 -0
  55. package/.agents/workflows/plan.md +107 -304
  56. package/docs/CHANGELOG.md +27 -0
  57. package/package.json +1 -1
@@ -36,6 +36,24 @@
36
36
  * Backgrounding is not a workaround here and does not need to be: an
37
37
  * interrupted poll is stateless and re-entrant by construction.
38
38
  *
39
+ * ## Async mode (Story #4698 — a designed short probe window, not an accident)
40
+ *
41
+ * `maxWaitSeconds` (default 300s) still routinely EXPIRES on a slow-CI
42
+ * consumer: the median PR-create→merge time can be minutes, so nearly every
43
+ * close burns its whole foreground slot polling and then returns `pending`
44
+ * anyway. `delivery.mergeWatch.mode: "async"` makes that async confirm a
45
+ * designed mode rather than an expiry accident. It caps the per-invocation
46
+ * wait to a short probe window (`ASYNC_PROBE_WINDOW_SECONDS`, ~60s) — long
47
+ * enough for the loop's existing checks to catch an instant merge and, via the
48
+ * imported {@link decideMergeWaitFailFast} decision (Story #4695/#4710), an
49
+ * instantly-red required check — then returns the SAME resumable `pending`
50
+ * terminal, whose `nextCommand` the worker launches in the background. Nothing
51
+ * else changes: the cumulative `maxBudgetSeconds` anchor is untouched, and
52
+ * `sync` mode (the default) is byte-compatible. An explicit `--max-wait-seconds`
53
+ * override wins over the async cap so a headless caller can still land in one
54
+ * block. The clamp lives entirely in `resolveMergeWaitConfig`; the poll loop is
55
+ * mode-agnostic.
56
+ *
39
57
  * ## The wait is not weaker than the watch it displaced
40
58
  *
41
59
  * The pre-#4543 poll read only `state` / `mergedAt`. A check that went red
@@ -72,7 +90,7 @@
72
90
  * confirm.
73
91
  */
74
92
 
75
- import { gh as defaultGh } from '../../../gh-exec.js';
93
+ import { createGh } from '../../../gh-exec.js';
76
94
  import {
77
95
  confirmStoryMerged as defaultConfirmStoryMerged,
78
96
  readPrMergeState as defaultReadPrMergeState,
@@ -86,8 +104,10 @@ import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-blo
86
104
  import {
87
105
  DEFAULT_INTERVAL_SECONDS,
88
106
  DEFAULT_MAX_BUDGET_SECONDS,
107
+ decideMergeWaitFailFast,
89
108
  deriveChecksStatus,
90
- failingChecksBlockMerge,
109
+ deriveRequiredRunEvidence,
110
+ MERGE_WAIT_GH_TIMEOUT_MS,
91
111
  } from '../../merge-poll.js';
92
112
  import { NEXT_COMMANDS } from '../../story-deliver-terminal.js';
93
113
  import {
@@ -105,6 +125,17 @@ import { runPostLandTail as defaultRunPostLandTail } from './post-land.js';
105
125
  */
106
126
  export const DEFAULT_MAX_WAIT_SECONDS = 300;
107
127
 
128
+ /**
129
+ * Async-mode per-invocation probe window (Story #4698). When
130
+ * `delivery.mergeWatch.mode` is `"async"`, `resolveMergeWaitConfig` caps the
131
+ * per-invocation wait to this many seconds so close returns the resumable
132
+ * `pending` terminal fast instead of burning the foreground host slot. Sized
133
+ * to catch an instant merge and — via the head-anchored required-check
134
+ * predicate — an instantly-red required check, while staying far inside the
135
+ * cumulative `maxBudgetSeconds` give-up bound.
136
+ */
137
+ export const ASYNC_PROBE_WINDOW_SECONDS = 60;
138
+
108
139
  /** Bounded `gh pr update-branch` attempts for a BEHIND PR. */
109
140
  export const DEFAULT_UPDATE_ATTEMPTS = 3;
110
141
 
@@ -129,6 +160,49 @@ function defaultSleep(ms) {
129
160
  return new Promise((resolve) => setTimeout(resolve, ms));
130
161
  }
131
162
 
163
+ /**
164
+ * The wait's default `gh` facade, bound to a spawn-level timeout (Story
165
+ * #4710): every subprocess the wait launches through it carries
166
+ * `MERGE_WAIT_GH_TIMEOUT_MS`, so a wedged `gh` child is killed rather than
167
+ * stranding an unattended async-mode wait forever. Callers that inject their
168
+ * own `gh` (tests, the resume CLI) are bounded by {@link withGhTimeout} at
169
+ * the call sites instead.
170
+ */
171
+ const defaultGh = createGh(undefined, { timeoutMs: MERGE_WAIT_GH_TIMEOUT_MS });
172
+
173
+ /**
174
+ * Bound an arbitrary `gh` call with a wall-clock timeout (Story #4710). The
175
+ * spawn-level `timeoutMs` on {@link defaultGh} already kills a wedged real
176
+ * subprocess, but an injected `gh` implementation (a test stub, a facade
177
+ * built without defaults) can still return a promise that never settles —
178
+ * and the merge wait must never hang on any of them. Rejection maps to the
179
+ * caller's existing error handling: the probe degrades to its conservative
180
+ * pending shape, the update-branch attempt logs and continues.
181
+ *
182
+ * A late settlement of the losing promise is explicitly absorbed so a
183
+ * post-timeout rejection cannot surface as an unhandled rejection.
184
+ *
185
+ * The timeout timer is deliberately NOT `unref`'d: when the awaited call is a
186
+ * promise that never settles (a hung stub, or a real gh child whose I/O has
187
+ * gone quiet), the timer is the ONLY handle keeping the event loop alive, so
188
+ * unref'ing it would let the process/test exit before the timeout ever fires —
189
+ * exactly the hang this guard exists to prevent. It is short-lived and always
190
+ * cleared in `finally`, so keeping it referenced costs nothing.
191
+ */
192
+ function withGhTimeout(promise, timeoutMs, label) {
193
+ let timer;
194
+ const bounded = new Promise((resolve, reject) => {
195
+ timer = setTimeout(() => {
196
+ reject(
197
+ new Error(`${label} did not return within ${timeoutMs}ms (timeout)`),
198
+ );
199
+ }, timeoutMs);
200
+ promise.then(resolve, reject);
201
+ }).finally(() => clearTimeout(timer));
202
+ promise.catch(() => {});
203
+ return bounded;
204
+ }
205
+
132
206
  /**
133
207
  * One probe per poll iteration, carrying every field the loop and the
134
208
  * terminal classifier need: merge state, the checks rollup, the merge-state
@@ -137,20 +211,31 @@ function defaultSleep(ms) {
137
211
  *
138
212
  * Returns a degraded `{ checksStatus: 'pending', error }` probe when the read
139
213
  * itself fails, preserving the conservative classification on probe errors —
140
- * a flaky API read must not be mistaken for a definitive verdict.
214
+ * a flaky API read must not be mistaken for a definitive verdict. A probe
215
+ * that exceeds `ghTimeoutMs` (Story #4710) takes the SAME degraded path: a
216
+ * hung subprocess must surface as a probe error within the bound, never
217
+ * strand the wait.
141
218
  *
142
219
  * @returns {Promise<object>}
143
220
  */
144
- export async function readPrWaitProbe({ prNumber, gh = defaultGh }) {
221
+ export async function readPrWaitProbe({
222
+ prNumber,
223
+ gh = defaultGh,
224
+ ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
225
+ }) {
145
226
  try {
146
- const view = await gh.pr.view(prNumber, [
147
- 'state',
148
- 'mergedAt',
149
- 'createdAt',
150
- 'mergeStateStatus',
151
- 'reviewDecision',
152
- 'statusCheckRollup',
153
- ]);
227
+ const view = await withGhTimeout(
228
+ gh.pr.view(prNumber, [
229
+ 'state',
230
+ 'mergedAt',
231
+ 'createdAt',
232
+ 'mergeStateStatus',
233
+ 'reviewDecision',
234
+ 'statusCheckRollup',
235
+ ]),
236
+ ghTimeoutMs,
237
+ `gh pr view ${prNumber}`,
238
+ );
154
239
  return {
155
240
  state: typeof view?.state === 'string' ? view.state : null,
156
241
  mergedAt: typeof view?.mergedAt === 'string' ? view.mergedAt : null,
@@ -164,6 +249,11 @@ export async function readPrWaitProbe({ prNumber, gh = defaultGh }) {
164
249
  ? view.reviewDecision
165
250
  : undefined,
166
251
  checksStatus: deriveChecksStatus(view?.statusCheckRollup),
252
+ // Head-anchored per-run evidence (Story #4695): distinguishes a
253
+ // genuinely red required run from the superseded / still-pending noise
254
+ // the aggregate `checksStatus` folds together. `null` when the rollup is
255
+ // absent/empty — the loop's consecutive-probe fallback owns that path.
256
+ requiredRunEvidence: deriveRequiredRunEvidence(view?.statusCheckRollup),
167
257
  };
168
258
  } catch (err) {
169
259
  return {
@@ -185,18 +275,36 @@ export async function readPrWaitProbe({ prNumber, gh = defaultGh }) {
185
275
  * raises the per-invocation bound to keep single-block semantics without
186
276
  * editing the consumer's config.
187
277
  *
278
+ * `mode` (Story #4698) selects the close-time merge posture. `async` caps the
279
+ * per-invocation wait to `ASYNC_PROBE_WINDOW_SECONDS` so close returns the
280
+ * resumable `pending` terminal fast; `sync` (the default) is unchanged. An
281
+ * explicit `maxWaitSecondsOverride` still wins over the async cap — a headless
282
+ * caller with no host ceiling opts back into single-block waiting.
283
+ *
188
284
  * @param {object} [config]
189
285
  * @param {number} [maxWaitSecondsOverride]
190
- * @returns {{ intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
286
+ * @returns {{ mode: 'sync'|'async', intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
191
287
  */
192
288
  export function resolveMergeWaitConfig(config, maxWaitSecondsOverride) {
193
289
  const mergeWatch = config?.delivery?.mergeWatch ?? {};
194
290
  const int = (value, fallback, min = 1) =>
195
291
  Number.isInteger(value) && value >= min ? value : fallback;
196
- const maxWaitSeconds = int(
292
+ const mode = mergeWatch.mode === 'async' ? 'async' : 'sync';
293
+ const configuredMaxWait = int(
197
294
  maxWaitSecondsOverride,
198
295
  int(mergeWatch.maxWaitSeconds, DEFAULT_MAX_WAIT_SECONDS),
199
296
  );
297
+ // Async mode caps the per-invocation wait to a short probe window so close
298
+ // returns `pending` fast instead of burning the foreground host slot on a
299
+ // merge that lands after the wait would have expired anyway. The window is
300
+ // long enough for the loop's existing checks to catch an instant merge and —
301
+ // via the imported `decideMergeWaitFailFast` decision — an instantly
302
+ // red required check. An explicit `--max-wait-seconds` override still wins so
303
+ // a headless caller with no host ceiling opts back into single-block waiting.
304
+ const maxWaitSeconds =
305
+ mode === 'async' && maxWaitSecondsOverride == null
306
+ ? Math.min(configuredMaxWait, ASYNC_PROBE_WINDOW_SECONDS)
307
+ : configuredMaxWait;
200
308
  // A poll interval longer than the wait bound is incoherent, and silently
201
309
  // harmful: the pending check would fire on poll 1 every time, so the wait
202
310
  // could never sleep, `polls` could never reach
@@ -210,6 +318,7 @@ export function resolveMergeWaitConfig(config, maxWaitSecondsOverride) {
210
318
  maxWaitSeconds,
211
319
  );
212
320
  return {
321
+ mode,
213
322
  intervalSeconds,
214
323
  maxWaitSeconds,
215
324
  maxBudgetSeconds: int(
@@ -429,6 +538,12 @@ async function blockOnUnlanded({
429
538
  budget,
430
539
  });
431
540
  const elapsedSeconds = budget?.elapsedSeconds ?? 0;
541
+ // Which evidence path produced a `checks-failed` verdict (Story #4695):
542
+ // `per-run` (head-anchored required-run evidence) or `consecutive-probe`
543
+ // (the evidence-unavailable fallback). Named on the emitted record so the
544
+ // `merge.unlanded` telemetry attributes the fail-fast to the path that
545
+ // fired it. Absent for every other block class.
546
+ const evidencePath = prProbe?.evidencePath;
432
547
 
433
548
  if (Number.isInteger(prNumber) && prNumber > 0) {
434
549
  try {
@@ -439,6 +554,7 @@ async function blockOnUnlanded({
439
554
  blockClass,
440
555
  reason,
441
556
  elapsedSeconds,
557
+ ...(evidencePath ? { evidencePath } : {}),
442
558
  });
443
559
  } catch (err) {
444
560
  progress?.(
@@ -509,6 +625,7 @@ async function maybeUpdateBehindPr({
509
625
  updatesUsed,
510
626
  updateAttempts,
511
627
  gh,
628
+ ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
512
629
  progress,
513
630
  }) {
514
631
  if (probe.mergeStateStatus !== 'BEHIND') return false;
@@ -520,7 +637,11 @@ async function maybeUpdateBehindPr({
520
637
  return false;
521
638
  }
522
639
  try {
523
- await (gh ?? defaultGh).pr.updateBranch(prNumber);
640
+ await withGhTimeout(
641
+ (gh ?? defaultGh).pr.updateBranch(prNumber),
642
+ ghTimeoutMs,
643
+ `gh pr update-branch ${prNumber}`,
644
+ );
524
645
  progress?.(
525
646
  'CONFIRM',
526
647
  `⏫ PR #${prNumber} was BEHIND its base — updated (attempt ${updatesUsed + 1}/${updateAttempts}).`,
@@ -650,6 +771,10 @@ async function onMergeObserved({
650
771
  * @param {(ms: number) => Promise<void>} [args.sleepFn] Test seam so the
651
772
  * suite does not actually wait.
652
773
  * @param {() => number} [args.nowMsFn] Test seam; returns epoch ms.
774
+ * @param {number} [args.ghTimeoutMs] Wall-clock bound for each `gh` call the
775
+ * wait makes (Story #4710). A framework constant
776
+ * (`MERGE_WAIT_GH_TIMEOUT_MS`), overridable only as a test seam — not
777
+ * config.
653
778
  * @returns {Promise<object>}
654
779
  */
655
780
  export async function runConfirmMergePhase({
@@ -676,6 +801,7 @@ export async function runConfirmMergePhase({
676
801
  runPostLandTailFn = defaultRunPostLandTail,
677
802
  sleepFn = defaultSleep,
678
803
  nowMsFn = Date.now,
804
+ ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
679
805
  }) {
680
806
  // The arm itself never succeeded (gh failure, unparseable PR number, or a
681
807
  // deliberate disablement) — there is no "armed but unconfirmed" PR to
@@ -699,22 +825,38 @@ export async function runConfirmMergePhase({
699
825
  });
700
826
  }
701
827
 
702
- const { intervalSeconds, maxWaitSeconds, maxBudgetSeconds, updateAttempts } =
703
- resolveMergeWaitConfig(config, maxWaitSecondsOverride);
828
+ const {
829
+ mode,
830
+ intervalSeconds,
831
+ maxWaitSeconds,
832
+ maxBudgetSeconds,
833
+ updateAttempts,
834
+ } = resolveMergeWaitConfig(config, maxWaitSecondsOverride);
704
835
  const intervalMs = intervalSeconds * 1000;
705
836
  const startedAtMs = nowMsFn();
706
837
  let anchorMs = startedAtMs;
707
838
  let updatesUsed = 0;
708
839
  let polls = 0;
840
+ // Consecutive failing check probes observed WITHOUT per-run evidence
841
+ // (Story #4695). The evidence-unavailable fallback: a single failing rollup
842
+ // snapshot never fail-fasts — two consecutive failing probes at least one
843
+ // poll interval apart are required. Reset on any non-failing (or genuinely
844
+ // evidenced) probe.
845
+ let consecutiveRequiredFailSnapshots = 0;
709
846
 
710
847
  progress?.(
711
848
  'CONFIRM',
712
849
  `⏳ Close-and-land: polling PR #${prNumber} for merge confirmation ` +
713
- `(wait=${maxWaitSeconds}s this invocation, cumulative budget=${maxBudgetSeconds}s)...`,
850
+ `(mode=${mode}, wait=${maxWaitSeconds}s this invocation, ` +
851
+ `cumulative budget=${maxBudgetSeconds}s)...`,
714
852
  );
715
853
 
716
854
  while (true) {
717
- const probe = await readPrWaitProbeFn({ prNumber, gh: injectedGh });
855
+ const probe = await readPrWaitProbeFn({
856
+ prNumber,
857
+ gh: injectedGh,
858
+ ghTimeoutMs,
859
+ });
718
860
  polls += 1;
719
861
 
720
862
  // Anchor the cumulative budget at the PR's creation the first time we
@@ -755,6 +897,12 @@ export async function runConfirmMergePhase({
755
897
  });
756
898
  }
757
899
 
900
+ // Everything below funnels into ONE terminal exit (Story #4710): each
901
+ // definitive condition fills `unlanded` and the single call site at the
902
+ // bottom classifies, emits, and blocks — the fail-fast tree used to
903
+ // duplicate that block twice inline.
904
+ let unlanded = null;
905
+
758
906
  if (probe.state === 'CLOSED') {
759
907
  // Closed without merging — a definitive terminal, not a "still
760
908
  // pending" condition the budget should keep waiting on. checksStatus
@@ -763,10 +911,7 @@ export async function runConfirmMergePhase({
763
911
  // pending", which would misclassify this definitive case as
764
912
  // checks-pending-timeout instead of reaching the api-race-other
765
913
  // reason built from prProbe.error.
766
- return blockOnUnlanded({
767
- storyId,
768
- prNumber,
769
- prUrl,
914
+ unlanded = {
770
915
  prProbe: {
771
916
  checksStatus: 'closed',
772
917
  error: 'PR closed without merging (state=CLOSED)',
@@ -775,72 +920,78 @@ export async function runConfirmMergePhase({
775
920
  exhausted: true,
776
921
  elapsedSeconds: Math.round(waitedMs / 1000),
777
922
  },
778
- provider,
779
- progress,
780
- classifyMergeBlockFn,
781
- emitMergeUnlandedFn,
923
+ };
924
+ } else {
925
+ // Fail fast on a GENUINELY red REQUIRED check — head-anchored (Story
926
+ // #4695), decided by the extracted `decideMergeWaitFailFast` (Story
927
+ // #4710): per-run evidence decides on a single probe; without evidence
928
+ // two consecutive failing probes are required. No remaining budget
929
+ // turns a failed check green, and waiting it out is what made the
930
+ // pre-#4543 wait report the operator's red test run as a
931
+ // branch-protection block.
932
+ const decision = decideMergeWaitFailFast({
933
+ probe,
934
+ consecutiveRequiredFailSnapshots,
782
935
  });
936
+ consecutiveRequiredFailSnapshots =
937
+ decision.consecutiveRequiredFailSnapshots;
938
+ if (decision.failFast) {
939
+ progress?.(
940
+ 'CONFIRM',
941
+ decision.evidencePath === 'per-run'
942
+ ? `🛑 PR #${prNumber}: a required check concluded failure with none in flight — failing fast (evidence=per-run).`
943
+ : `🛑 PR #${prNumber}: two consecutive failing check probes without per-run evidence — failing fast (evidence=consecutive-probe).`,
944
+ );
945
+ unlanded = {
946
+ prProbe: decision.prProbe,
947
+ budget: {
948
+ exhausted: false,
949
+ elapsedSeconds: Math.round(waitedMs / 1000),
950
+ },
951
+ };
952
+ }
783
953
  }
784
954
 
785
- // Fail fast on a red REQUIRED check. No remaining budget turns a failed
786
- // check green, and waiting it out is what made the pre-#4543 wait report
787
- // the operator's red test run as a branch-protection block.
788
- //
789
- // Gated on `failingChecksBlockMerge`, not on the raw rollup: a red
790
- // OPTIONAL check does not stop native auto-merge, so failing fast on it
791
- // would block the Story while the PR lands anyway.
792
- if (failingChecksBlockMerge(probe)) {
793
- progress?.(
794
- 'CONFIRM',
795
- `🛑 PR #${prNumber}: a required check went red — failing fast rather than burning the budget.`,
796
- );
797
- return blockOnUnlanded({
798
- storyId,
799
- prNumber,
800
- prUrl,
801
- prProbe: probe,
802
- budget: {
803
- exhausted: false,
804
- elapsedSeconds: Math.round(waitedMs / 1000),
805
- },
806
- provider,
807
- progress,
808
- classifyMergeBlockFn,
809
- emitMergeUnlandedFn,
810
- });
811
- }
955
+ if (!unlanded) {
956
+ if (
957
+ await maybeUpdateBehindPr({
958
+ probe,
959
+ prNumber,
960
+ updatesUsed,
961
+ updateAttempts,
962
+ gh: injectedGh,
963
+ ghTimeoutMs,
964
+ progress,
965
+ })
966
+ ) {
967
+ updatesUsed += 1;
968
+ }
812
969
 
813
- if (
814
- await maybeUpdateBehindPr({
815
- probe,
816
- prNumber,
817
- updatesUsed,
818
- updateAttempts,
819
- gh: injectedGh,
820
- progress,
821
- })
822
- ) {
823
- updatesUsed += 1;
970
+ // Cumulative budget exhausted → the genuine give-up. Classify from the
971
+ // probe we already hold. Gated behind the poll floor so an
972
+ // already-over-budget PR (anchored at a createdAt older than the budget
973
+ // — a resume the next day, or a long-open PR) still gets a real poll
974
+ // cycle instead of being blocked before this invocation waited at all.
975
+ if (
976
+ polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK &&
977
+ cumulativeMs + intervalMs > maxBudgetSeconds * 1000
978
+ ) {
979
+ unlanded = {
980
+ prProbe: probe,
981
+ budget: {
982
+ exhausted: true,
983
+ elapsedSeconds: Math.round(cumulativeMs / 1000),
984
+ },
985
+ };
986
+ }
824
987
  }
825
988
 
826
- // Cumulative budget exhausted → the genuine give-up. Classify from the
827
- // probe we already hold. Gated behind the poll floor so an
828
- // already-over-budget PR (anchored at a createdAt older than the budget —
829
- // a resume the next day, or a long-open PR) still gets a real poll cycle
830
- // instead of being blocked before this invocation waited at all.
831
- if (
832
- polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK &&
833
- cumulativeMs + intervalMs > maxBudgetSeconds * 1000
834
- ) {
989
+ if (unlanded) {
835
990
  return blockOnUnlanded({
836
991
  storyId,
837
992
  prNumber,
838
993
  prUrl,
839
- prProbe: probe,
840
- budget: {
841
- exhausted: true,
842
- elapsedSeconds: Math.round(cumulativeMs / 1000),
843
- },
994
+ ...unlanded,
844
995
  provider,
845
996
  progress,
846
997
  classifyMergeBlockFn,
@@ -13,10 +13,17 @@
13
13
  */
14
14
 
15
15
  import {
16
+ evaluateLensDiffFloor,
17
+ resolveLensDiffFloor,
16
18
  runAuditSuite,
17
19
  selectLocalLenses,
18
20
  } from '../../../audit-suite/index.js';
21
+ import { resolveConfig } from '../../../config-resolver.js';
19
22
  import { gitSpawn } from '../../../git-utils.js';
23
+ import {
24
+ emitRuntimeFriction,
25
+ RUNTIME_FRICTION_CATEGORIES,
26
+ } from '../../../observability/runtime-friction.js';
20
27
  import { computeChangeSet } from '../../change-set.js';
21
28
 
22
29
  /**
@@ -180,10 +187,21 @@ function resolveLensChangeSet({
180
187
  * this the default review provider dropped the materialized envelope, so the
181
188
  * pass was a progress log line with no reader.
182
189
  *
190
+ * Story #4699 — the **lens diff-floor**. When the caller supplies a known
191
+ * `changedLineCount` and the diff sits strictly below the configured floor
192
+ * (`delivery.review.lensDiffFloor`, default 40) with zero sensitive-path
193
+ * hits, the pass records the matched roster but skips materialization
194
+ * entirely (`skipped: true` with the lenses retained and a `floorSkip`
195
+ * verdict) — the maker-blind code-review pillar and every hard gate are
196
+ * untouched. An unknown line count, a disabled floor, or a sensitive-path
197
+ * hit all fail open to the full materialization.
198
+ *
183
199
  * @param {{
184
200
  * baseRef: string,
185
201
  * headRef: string,
186
202
  * changedFiles?: string[]|null,
203
+ * changedLineCount?: number|null,
204
+ * lensDiffFloor?: number,
187
205
  * storyId?: number|string|null,
188
206
  * artifactPrefix?: string,
189
207
  * progress: (tag: string, msg: string) => void,
@@ -191,11 +209,15 @@ function resolveLensChangeSet({
191
209
  * gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
192
210
  * selectLocalLensesFn?: typeof selectLocalLenses,
193
211
  * runAuditSuiteFn?: typeof runAuditSuite,
212
+ * resolveConfigFn?: typeof resolveConfig,
213
+ * evaluateLensDiffFloorFn?: typeof evaluateLensDiffFloor,
214
+ * emitToolDegradationFn?: typeof emitRuntimeFriction,
194
215
  * }} args
195
216
  * @returns {Promise<{
196
217
  * depth: 'light',
197
218
  * lenses: string[],
198
219
  * skipped: boolean,
220
+ * floorSkip: object|null,
199
221
  * materialized: object|null,
200
222
  * artifactPaths: string[],
201
223
  * }>}
@@ -204,6 +226,8 @@ export async function runLocalLensReview({
204
226
  baseRef,
205
227
  headRef,
206
228
  changedFiles: injectedChangedFiles,
229
+ changedLineCount = null,
230
+ lensDiffFloor,
207
231
  storyId,
208
232
  artifactPrefix,
209
233
  progress,
@@ -211,11 +235,15 @@ export async function runLocalLensReview({
211
235
  gitSpawnFn = gitSpawn,
212
236
  selectLocalLensesFn = selectLocalLenses,
213
237
  runAuditSuiteFn = runAuditSuite,
238
+ resolveConfigFn = resolveConfig,
239
+ evaluateLensDiffFloorFn = evaluateLensDiffFloor,
240
+ emitToolDegradationFn = emitRuntimeFriction,
214
241
  }) {
215
242
  const empty = {
216
243
  depth: STORY_SCOPE_LENS_DEPTH,
217
244
  lenses: [],
218
245
  skipped: true,
246
+ floorSkip: null,
219
247
  materialized: null,
220
248
  artifactPaths: [],
221
249
  };
@@ -234,6 +262,34 @@ export async function runLocalLensReview({
234
262
  );
235
263
  return empty;
236
264
  }
265
+
266
+ // Lens diff-floor (Story #4699). Deliberately evaluated AFTER lens
267
+ // selection so a floor-skip still records WHICH lenses it skipped —
268
+ // the findings-yield ledger needs the roster either way.
269
+ const floorVerdict = evaluateLensDiffFloorFn({
270
+ changedFiles,
271
+ changedLineCount,
272
+ floor:
273
+ typeof lensDiffFloor === 'number'
274
+ ? lensDiffFloor
275
+ : resolveLensDiffFloor(safeResolveConfig(resolveConfigFn)),
276
+ });
277
+ if (floorVerdict.skip) {
278
+ progress(
279
+ progressTag,
280
+ `Lens diff-floor: ${floorVerdict.changedLineCount} changed line(s) < ` +
281
+ `floor ${floorVerdict.floor} with zero sensitive-path hits — ` +
282
+ `skipping materialization of ${lenses.join(', ')}.`,
283
+ );
284
+ return {
285
+ depth: STORY_SCOPE_LENS_DEPTH,
286
+ lenses,
287
+ skipped: true,
288
+ floorSkip: floorVerdict,
289
+ materialized: null,
290
+ artifactPaths: [],
291
+ };
292
+ }
237
293
  // Scope the artifact filenames to this Story so concurrent closes on a
238
294
  // shared audit output dir cannot clobber each other's prompts.
239
295
  const effectivePrefix =
@@ -256,16 +312,48 @@ export async function runLocalLensReview({
256
312
  depth: STORY_SCOPE_LENS_DEPTH,
257
313
  lenses,
258
314
  skipped: false,
315
+ floorSkip: floorVerdict,
259
316
  materialized,
260
317
  artifactPaths,
261
318
  };
262
319
  } catch (err) {
263
320
  // The lens pass is advisory: a git or materialization failure must not
264
- // fail the close. Log and degrade to a skipped envelope.
321
+ // fail the close. Log, route the tool-execution degradation to friction
322
+ // telemetry (Story #4699 — degradations are operational signals, not
323
+ // findings), and degrade to a skipped envelope.
265
324
  progress(
266
325
  progressTag,
267
326
  `⚠️ local lens pass failed (continuing without it): ${err?.message ?? err}`,
268
327
  );
328
+ try {
329
+ await emitToolDegradationFn({
330
+ storyId,
331
+ category: RUNTIME_FRICTION_CATEGORIES.TOOL_DEGRADED,
332
+ tool: 'local-lens-review',
333
+ details: {
334
+ surface: 'lens-materialization',
335
+ reason: String(err?.message ?? err).slice(0, 500),
336
+ },
337
+ });
338
+ } catch {
339
+ // Observability must never fail the close (best-effort contract).
340
+ }
269
341
  return empty;
270
342
  }
271
343
  }
344
+
345
+ /**
346
+ * Resolve config for the floor read without letting a resolver failure
347
+ * fail the (advisory) lens pass. Module-local: a degraded config simply
348
+ * yields the framework-default floor.
349
+ *
350
+ * @param {typeof resolveConfig} resolveConfigFn
351
+ * @returns {object|undefined}
352
+ */
353
+ function safeResolveConfig(resolveConfigFn) {
354
+ try {
355
+ return resolveConfigFn();
356
+ } catch {
357
+ return undefined;
358
+ }
359
+ }