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
@@ -30,11 +30,85 @@ import {
30
30
  normalizeSupersedes,
31
31
  } from './supersede-ops.js';
32
32
 
33
- // Story #4540 removed PLAN_RUN_LABEL_PREFIX / normalizePlanRunId /
34
- // planRunLabel from here. They minted an opaque random-hex label per N>1
35
- // plan that nothing ever deleted, and their only external consumer was the
36
- // (now deleted) `--run` resolver. Sibling order survives in the
37
- // `blocked by #N` body footers this module already writes.
33
+ /**
34
+ * Label prefix grouping the Stories one plan-persist run authored.
35
+ *
36
+ * Reintroduced (Story #4692) after Story #4540 retired it: #4540 was right
37
+ * that batch identity is the wrong axis for *ordering delivery across runs*
38
+ * (`/deliver` takes ids and resolves the graph from live state — that stays),
39
+ * but it is the correct axis for *grouping the Stories one plan run created*
40
+ * so a cohort is filterable and traceable in the GitHub UI. The label is
41
+ * metadata only; nothing in persist or delivery reads it as a
42
+ * delivery-resolution input.
43
+ */
44
+ export const PLAN_RUN_LABEL_PREFIX = 'plan-run::';
45
+
46
+ /** Stable color for the cohort grouping label (`ensureLabels`). */
47
+ const PLAN_RUN_LABEL_COLOR = '#C5DEF5';
48
+
49
+ /** Stable color for the `route::lite` ceremony-route marker (Story #4707). */
50
+ const LITE_ROUTE_LABEL_COLOR = '#D4C5F9';
51
+
52
+ /** Length of the derived plan-run id (hex chars). */
53
+ const PLAN_RUN_ID_LENGTH = 8;
54
+
55
+ /**
56
+ * Normalize a caller-supplied plan-run token. Kept shared so human-readable
57
+ * ids map to one canonical label shape.
58
+ *
59
+ * @param {string} id
60
+ * @returns {string}
61
+ */
62
+ export function normalizePlanRunId(id) {
63
+ const token = String(id ?? '')
64
+ .trim()
65
+ .toLowerCase()
66
+ .replace(/^plan-run::/, '')
67
+ .replace(/[^a-z0-9._-]+/g, '-');
68
+ if (!token) {
69
+ throw new Error('plan-run id requires a non-empty planRunId');
70
+ }
71
+ return token;
72
+ }
73
+
74
+ /**
75
+ * Build a `plan-run::<id>` label from an explicit id.
76
+ *
77
+ * Unlike the pre-#4540 shape, this never mints a random token — a random id
78
+ * would split a resumed persist's cohort across two labels. Derive the id
79
+ * from the authored artifacts via {@link derivePlanRunId} instead.
80
+ *
81
+ * @param {string} id
82
+ * @returns {string}
83
+ */
84
+ export function planRunLabel(id) {
85
+ return `${PLAN_RUN_LABEL_PREFIX}${normalizePlanRunId(id)}`;
86
+ }
87
+
88
+ /**
89
+ * Derive the deterministic plan-run id for a cohort of assembled Stories.
90
+ *
91
+ * Hashes the **sorted** set of per-Story plan fingerprints (the same
92
+ * content identities the resumable-create contract adopts on), so the id is
93
+ * a pure function of the authored artifacts: the same `stories.json` yields
94
+ * the same `plan-run::<id>` on every run — a persist resumed after a
95
+ * mid-run failure applies the identical label to the newly-created
96
+ * remainder that the already-created (adopted) Stories carry — while a
97
+ * different plan derives a different label. Sorting makes the id
98
+ * independent of creation order.
99
+ *
100
+ * @param {string[]} fingerprints Per-Story plan fingerprints.
101
+ * @returns {string} Hex id, {@link PLAN_RUN_ID_LENGTH} chars.
102
+ */
103
+ export function derivePlanRunId(fingerprints) {
104
+ const sorted = (Array.isArray(fingerprints) ? fingerprints : [])
105
+ .map(String)
106
+ .sort();
107
+ return createHash('sha256')
108
+ .update(sorted.join(' '))
109
+ .digest('hex')
110
+ .slice(0, PLAN_RUN_ID_LENGTH);
111
+ }
38
112
 
39
113
  /**
40
114
  * Marker prefix for the per-Story plan fingerprint appended to every
@@ -113,13 +187,19 @@ function planFingerprintMarker(fingerprint) {
113
187
  /**
114
188
  * Labels the authoring pass is never allowed to set. The `agent::*` axis is
115
189
  * the runtime's lifecycle state (persist owns the terminal `agent::ready`
116
- * flip itself), `type::*` is fixed to `type::story` by the v2 hierarchy, and
117
- * `persona::*` is a retired axis.
190
+ * flip itself), `type::*` is fixed to `type::story` by the v2 hierarchy,
191
+ * `persona::*` is a retired axis, `plan-run::*` is the runtime-derived
192
+ * cohort grouping axis (Story #4692), and `route::*` is the runtime-derived
193
+ * ceremony-route axis (Story #4707) — a hand-authored entry on either
194
+ * derived axis would compete with the deterministic label persist applies
195
+ * itself.
118
196
  */
119
197
  const FORBIDDEN_LABEL_PREFIXES = Object.freeze([
120
198
  'agent::',
121
199
  'type::',
122
200
  'persona::',
201
+ PLAN_RUN_LABEL_PREFIX,
202
+ 'route::',
123
203
  ]);
124
204
 
125
205
  /** GitHub's own label-name ceiling. */
@@ -607,6 +687,63 @@ async function mirrorNativeDependencyEdges({ provider, stories, idBySlug }) {
607
687
  }
608
688
  }
609
689
 
690
+ /**
691
+ * Ensure a runtime-derived persist label (`plan-run::<id>` cohort grouping,
692
+ * `route::lite` route marker) exists before it is applied — GitHub's
693
+ * create-issue path does not auto-create unknown labels on every provider
694
+ * route, and an opaque derived label never exists yet.
695
+ *
696
+ * **Non-fatal by design**, matching the native-blocked_by mirroring posture:
697
+ * neither label is load-bearing for correctness (grouping is cosmetic; a
698
+ * missing route marker degrades a lite Story to the standard — safer —
699
+ * sub-agent dispatch), so it is never a reason to fail persist. On an ensure
700
+ * failure (throw, or the label reported `missing` by the post-loop
701
+ * reconcile) the create loop proceeds **without** the label — applying an
702
+ * unensured label could fail the issue create itself, and the Stories matter
703
+ * more than their metadata. A provider that exposes no `ensureLabels` (test
704
+ * fakes, minimal providers) is assumed to accept arbitrary labels on create.
705
+ *
706
+ * @param {object} args
707
+ * @param {object} args.provider
708
+ * @param {string} args.label
709
+ * @param {string} args.color
710
+ * @param {string} args.description
711
+ * @param {string} args.role Human-readable role for the degrade warning.
712
+ * @returns {Promise<boolean>} Whether the create loop should apply the label.
713
+ */
714
+ async function ensurePersistLabel({
715
+ provider,
716
+ label,
717
+ color,
718
+ description,
719
+ role,
720
+ }) {
721
+ if (typeof provider?.ensureLabels !== 'function') {
722
+ return true;
723
+ }
724
+ try {
725
+ const result = await provider.ensureLabels([
726
+ { name: label, color, description },
727
+ ]);
728
+ if (Array.isArray(result?.missing) && result.missing.includes(label)) {
729
+ Logger.warn(
730
+ `[plan-persist] ${role} label "${label}" could not be verified ` +
731
+ 'on the remote — creating the Stories without it. Add the label ' +
732
+ 'by hand if you want it.',
733
+ );
734
+ return false;
735
+ }
736
+ return true;
737
+ } catch (err) {
738
+ Logger.warn(
739
+ `[plan-persist] ${role} label ensure failed (${err.message}) — ` +
740
+ 'creating the Stories without it. Add the label by hand if you ' +
741
+ 'want it.',
742
+ );
743
+ return false;
744
+ }
745
+ }
746
+
610
747
  /**
611
748
  * Create Story issues via `provider.createIssue`, resumably.
612
749
  *
@@ -631,24 +768,42 @@ async function mirrorNativeDependencyEdges({ provider, stories, idBySlug }) {
631
768
  * is named in a warning. Adoption never rewrites a body, so keying it on
632
769
  * anything weaker than content would silently ship a stale one.
633
770
  *
634
- * Story #4540 retired the `plan-run::<id>` label this used to apply when
635
- * N>1. Batch identity was the wrong axis to encode: it could not express an
636
- * edge to a Story planned in a different run, and ordering already lives in
637
- * the `blocked by #N` footers written below which `/deliver`'s resolver
638
- * reads directly, alongside native GitHub edges, from live state.
771
+ * **Every created Story carries the cohort's `plan-run::<id>` grouping
772
+ * label** (Story #4692, metadata only). The id is deterministic over the
773
+ * authored artifacts ({@link derivePlanRunId}), so a resumed persist derives
774
+ * the identical label its adopted Stories already carry from their original
775
+ * create no relabel call is needed on the resume path, and the cohort is
776
+ * never split across two labels. The label is ensured to exist before the
777
+ * first POST, **non-fatally**: grouping is cosmetic and never fails the run
778
+ * (see `ensureCohortLabel`). `/deliver` never reads it — delivery stays
779
+ * ids-only over live state (Story #4540's actual point).
639
780
  *
640
781
  * **Sibling order is mirrored into native GitHub `blocked_by` edges** once
641
782
  * every id is known (Story #4544), so plan-created order stops depending on
642
783
  * prose. That pass is non-fatal — see `mirrorNativeDependencyEdges`.
643
784
  *
785
+ * **A lite-routed cohort carries the `route::lite` marker** (Story #4707).
786
+ * When the caller resolves the plan's effective complexity route to `lite`
787
+ * (envelope verdict, or an audited planner downgrade), it passes the marker
788
+ * via `opts.routeLabel` and every created Story carries it — the persisted,
789
+ * `/deliver`-readable form of the route (`resolveStoryDispatchMode`). A
790
+ * full-routed plan passes nothing and its Stories carry **no** route marker.
791
+ * Like the cohort label, the ensure is non-fatal: a Story created without
792
+ * the marker degrades to the standard sub-agent dispatch, never to a skipped
793
+ * gate.
794
+ *
644
795
  * @param {object} args
645
796
  * @param {object} args.provider
646
797
  * @param {ReturnType<typeof assemblePlanStories>['stories']} args.stories
647
798
  * @param {object} [args.opts]
648
799
  * @param {boolean} [args.opts.dryRun=false]
800
+ * @param {string|null} [args.opts.routeLabel=null] Route marker label to
801
+ * apply to every created Story (`route::lite`), or null for none.
649
802
  * @returns {Promise<{
650
803
  * created: Array<{ slug: string, id: number, url?: string, title: string, adopted: boolean }>,
651
804
  * dependencyEdges: { edgesAdded: number, edgesSkipped: number, edgesFailed: number, storiesProcessed: number }|null,
805
+ * planRunLabel: string,
806
+ * routeLabel: string|null,
652
807
  * }>}
653
808
  */
654
809
  export async function createStoryIssues({ provider, stories, opts = {} }) {
@@ -659,6 +814,17 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
659
814
  }
660
815
 
661
816
  const list = Array.isArray(stories) ? stories : [];
817
+ const routeLabel =
818
+ typeof opts.routeLabel === 'string' && opts.routeLabel.trim() !== ''
819
+ ? opts.routeLabel.trim()
820
+ : null;
821
+
822
+ // Derived once for the whole cohort, before any write — a pure function of
823
+ // the authored artifacts, so dry-run can report it write-free and a resume
824
+ // re-derives the identical label.
825
+ const cohortLabel = planRunLabel(
826
+ derivePlanRunId(list.map((story) => story.fingerprint)),
827
+ );
662
828
 
663
829
  if (opts.dryRun) {
664
830
  return {
@@ -670,9 +836,32 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
670
836
  adopted: false,
671
837
  })),
672
838
  dependencyEdges: null,
839
+ planRunLabel: cohortLabel,
840
+ routeLabel,
673
841
  };
674
842
  }
675
843
 
844
+ const applyCohortLabel = await ensurePersistLabel({
845
+ provider,
846
+ label: cohortLabel,
847
+ color: PLAN_RUN_LABEL_COLOR,
848
+ description:
849
+ 'Groups the Stories one /plan persist run authored (metadata ' +
850
+ 'only — /deliver stays ids-only).',
851
+ role: 'cohort',
852
+ });
853
+ const applyRouteLabel =
854
+ routeLabel !== null &&
855
+ (await ensurePersistLabel({
856
+ provider,
857
+ label: routeLabel,
858
+ color: LITE_ROUTE_LABEL_COLOR,
859
+ description:
860
+ 'Ceremony-lite route marker: /deliver executes this Story inline ' +
861
+ '(no sub-agent fan-out); every close gate runs unchanged.',
862
+ role: 'route-marker',
863
+ }));
864
+
676
865
  const { byFingerprint, idsByTitle } = await indexExistingStories(provider);
677
866
  const created = [];
678
867
  const idBySlug = new Map();
@@ -681,6 +870,9 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
681
870
  const already = byFingerprint.get(story.fingerprint);
682
871
  if (!already) warnOnDivergentSameTitleStory(story, idsByTitle);
683
872
  if (already) {
873
+ // Adopted Stories already carry the cohort label from their original
874
+ // create — the deterministic derivation guarantees it is the same
875
+ // label this run derived, so no relabel call is needed here.
684
876
  Logger.info(
685
877
  `[plan-persist] resuming: Story "${story.slug}" already exists as ` +
686
878
  `#${already.id} with byte-identical authored content ` +
@@ -700,7 +892,11 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
700
892
  const result = await provider.createIssue({
701
893
  title: story.title,
702
894
  body: renderStoryBodyForCreate(story, idBySlug),
703
- labels: [...story.labels],
895
+ labels: [
896
+ ...story.labels,
897
+ ...(applyCohortLabel ? [cohortLabel] : []),
898
+ ...(applyRouteLabel ? [routeLabel] : []),
899
+ ],
704
900
  });
705
901
  const id = result?.id ?? result?.number;
706
902
  if (!Number.isInteger(id)) {
@@ -727,7 +923,12 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
727
923
  idBySlug,
728
924
  });
729
925
 
730
- return { created, dependencyEdges };
926
+ return {
927
+ created,
928
+ dependencyEdges,
929
+ planRunLabel: cohortLabel,
930
+ routeLabel: applyRouteLabel ? routeLabel : null,
931
+ };
731
932
  }
732
933
 
733
934
  /**
@@ -34,6 +34,7 @@ import {
34
34
  extractChangePaths,
35
35
  parse as parseStoryBody,
36
36
  } from '../story-body/story-body.js';
37
+ import { resolveStoryDispatchMode } from './complexity-gate.js';
37
38
 
38
39
  /** Labels/state that mean a blocker no longer gates its dependents. */
39
40
  const DONE_LABEL = 'agent::done';
@@ -311,12 +312,18 @@ export function buildStoriesEnvelope({
311
312
  const inSetDone = sorted.filter(isSatisfiedBlocker).map((s) => s.id);
312
313
  return {
313
314
  kind: 'stories',
315
+ // `dispatchMode` (Story #4707): the resolver derives the per-Story
316
+ // execution mode from the persisted `route::lite` marker so `/deliver`
317
+ // reads one field — `inline` (lite: no story-worker / acceptance-critic
318
+ // sub-agent boots) or `subagent` (everything else, the conservative
319
+ // default). Model-side fan-out only; close gates are untouched.
314
320
  stories: sorted.map(({ id, title, url, labels, state }) => ({
315
321
  id,
316
322
  title,
317
323
  url,
318
324
  labels,
319
325
  state,
326
+ dispatchMode: resolveStoryDispatchMode({ labels }).mode,
320
327
  })),
321
328
  dag: storiesToDag(sorted, nativeEdges, warn),
322
329
  done: [...new Set([...inSetDone, ...foreignDone])].sort((a, b) => a - b),
@@ -48,6 +48,10 @@ import {
48
48
  calculateReport,
49
49
  classifyReport,
50
50
  } from '../../maintainability-engine.js';
51
+ import {
52
+ emitRuntimeFriction,
53
+ RUNTIME_FRICTION_CATEGORIES,
54
+ } from '../../observability/runtime-friction.js';
51
55
  import { PROJECT_ROOT } from '../../project-root.js';
52
56
  import { transpileIfNeeded } from '../../transpile.js';
53
57
 
@@ -456,8 +460,10 @@ export async function analyzeChangedFiles(
456
460
  * Pure: turn a lint summary into Finding(s). Lint errors collapse into a
457
461
  * single high-risk finding (the structured comment shows the count); lint
458
462
  * warnings collapse into a single suggestion. An `executionFailed` summary
459
- * produces one suggestion finding describing the runner failure rather than
460
- * a high-risk false positive.
463
+ * produces **zero** findings (Story #4699): a runner that could not execute
464
+ * is an operational degradation, not a code finding — the provider routes it
465
+ * to friction telemetry instead so severity counts reflect code findings
466
+ * only.
461
467
  *
462
468
  * @param {{ errors: number, warnings: number, parsed?: boolean, skipped?: boolean, mode?: string, executionFailed?: boolean, evidenceSkipped?: boolean }} lintSummary
463
469
  * @returns {Finding[]}
@@ -466,20 +472,7 @@ export function buildLintFindings(lintSummary) {
466
472
  if (lintSummary.mode === 'off') return [];
467
473
  if (lintSummary.evidenceSkipped) return [];
468
474
  if (lintSummary.skipped) return [];
469
- if (lintSummary.executionFailed) {
470
- return [
471
- {
472
- severity: 'suggestion',
473
- title: 'Lint runner could not execute',
474
- body:
475
- 'The scoped lint runner produced no parseable output (binary missing, ' +
476
- 'parse failure, or environment issue). Verify with the canonical ' +
477
- '`npm run lint` before merging — treating as a suggestion to avoid a ' +
478
- 'false high-risk signal.',
479
- category: 'lint',
480
- },
481
- ];
482
- }
475
+ if (lintSummary.executionFailed) return [];
483
476
  const findings = [];
484
477
  if (lintSummary.errors > 0) {
485
478
  findings.push({
@@ -550,6 +543,7 @@ async function runLintPhase({
550
543
  * runScopedLintFn?: typeof runScopedLint,
551
544
  * analyzeChangedFilesFn?: typeof analyzeChangedFiles,
552
545
  * buildLintFindingsFn?: typeof buildLintFindings,
546
+ * emitToolDegradationFn?: typeof emitRuntimeFriction,
553
547
  * logger?: { info?: Function, warn?: Function, error?: Function },
554
548
  * scopeLint?: 'changed-only'|'off',
555
549
  * }} [deps]
@@ -561,6 +555,7 @@ export function createNativeProvider(deps = {}) {
561
555
  runScopedLintFn = runScopedLint,
562
556
  analyzeChangedFilesFn = analyzeChangedFiles,
563
557
  buildLintFindingsFn = buildLintFindings,
558
+ emitToolDegradationFn = emitRuntimeFriction,
564
559
  logger,
565
560
  scopeLint = 'changed-only',
566
561
  } = deps;
@@ -624,6 +619,29 @@ export function createNativeProvider(deps = {}) {
624
619
  logger,
625
620
  });
626
621
 
622
+ if (lintSummary.executionFailed) {
623
+ // Story #4699 — a tool that could not execute is an operational
624
+ // degradation, not a code finding. Route it to friction telemetry
625
+ // (best-effort) so severity counts reflect code findings only.
626
+ logger?.warn?.(
627
+ '[native-review] Lint runner could not execute — recorded as friction telemetry, no finding emitted. Verify with the canonical `npm run lint` before merging.',
628
+ );
629
+ try {
630
+ await emitToolDegradationFn({
631
+ storyId: ticketId,
632
+ category: RUNTIME_FRICTION_CATEGORIES.TOOL_DEGRADED,
633
+ tool: 'native-review-lint',
634
+ details: {
635
+ surface: 'scoped-lint',
636
+ reason:
637
+ 'lint runner produced no parseable output (binary missing, parse failure, or environment issue)',
638
+ },
639
+ });
640
+ } catch {
641
+ // Observability must never fail the review (best-effort contract).
642
+ }
643
+ }
644
+
627
645
  const lintFindings = buildLintFindingsFn(lintSummary);
628
646
 
629
647
  // Canonical ordering: critical (maintainability) first, then high
@@ -80,6 +80,7 @@ async function invokeStoryReviewCore({
80
80
  provider,
81
81
  runCodeReviewFn,
82
82
  runLocalLensReviewFn,
83
+ appendFindingsYieldFn,
83
84
  progress,
84
85
  }) {
85
86
  return runStoryReviewCore({
@@ -91,10 +92,11 @@ async function invokeStoryReviewCore({
91
92
  progress,
92
93
  progressTag: 'REVIEW',
93
94
  runCodeReviewFn,
94
- // Forward the seam only when the caller injects it; otherwise
95
- // `runStoryReviewCore` uses its default local-lens pass. `undefined`
96
- // deep-merges to the default via the destructuring default there.
95
+ // Forward the seams only when the caller injects them; otherwise
96
+ // `runStoryReviewCore` uses its defaults. `undefined` deep-merges to
97
+ // the default via the destructuring default there.
97
98
  ...(runLocalLensReviewFn ? { runLocalLensReviewFn } : {}),
99
+ ...(appendFindingsYieldFn ? { appendFindingsYieldFn } : {}),
98
100
  });
99
101
  }
100
102
 
@@ -163,6 +165,7 @@ async function postStoryReviewCrossRef({
163
165
  * provider: object,
164
166
  * runCodeReviewFn: Function,
165
167
  * runLocalLensReviewFn?: Function,
168
+ * appendFindingsYieldFn?: Function,
166
169
  * progress: (tag: string, msg: string) => void,
167
170
  * }} args
168
171
  * @returns {Promise<{
@@ -185,6 +188,7 @@ export async function runStoryScopeReview({
185
188
  provider,
186
189
  runCodeReviewFn,
187
190
  runLocalLensReviewFn,
191
+ appendFindingsYieldFn,
188
192
  progress,
189
193
  }) {
190
194
  if (prNumber == null) {
@@ -208,6 +212,7 @@ export async function runStoryScopeReview({
208
212
  provider,
209
213
  runCodeReviewFn,
210
214
  runLocalLensReviewFn,
215
+ appendFindingsYieldFn,
211
216
  progress,
212
217
  });
213
218