mandrel 2.9.0 → 2.11.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 (75) 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 +39 -25
  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 +36 -9
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +10 -6
  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/baselines/env-overrides.js +33 -0
  22. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  23. package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
  24. package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
  25. package/.agents/scripts/lib/config/quality.js +13 -0
  26. package/.agents/scripts/lib/config/temp-paths.js +121 -1
  27. package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
  28. package/.agents/scripts/lib/config-settings-schema.js +12 -16
  29. package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
  30. package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
  31. package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
  32. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
  33. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
  34. package/.agents/scripts/lib/orchestration/complexity-gate.js +533 -93
  35. package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
  36. package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
  37. package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
  38. package/.agents/scripts/lib/orchestration/plan-context.js +119 -3
  39. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
  40. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
  41. package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
  42. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +153 -2
  43. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +221 -14
  44. package/.agents/scripts/lib/orchestration/resolve-stories.js +12 -1
  45. package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
  46. package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
  48. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
  49. package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
  50. package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
  51. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
  52. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
  53. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
  54. package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
  55. package/.agents/scripts/lib/templates/decomposer-prompts.js +13 -6
  56. package/.agents/scripts/lib/test-env.js +65 -0
  57. package/.agents/scripts/plan-context.js +84 -9
  58. package/.agents/scripts/plan-critics.js +115 -3
  59. package/.agents/scripts/plan-persist.js +11 -1
  60. package/.agents/scripts/plan-run-epilogue.js +1 -1
  61. package/.agents/scripts/resolve-stories.js +2 -0
  62. package/.agents/scripts/single-story-confirm-merge.js +65 -5
  63. package/.agents/scripts/stories-wave-tick.js +1 -1
  64. package/.agents/workflows/deliver.md +88 -230
  65. package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
  66. package/.agents/workflows/helpers/deliver-reference.md +171 -0
  67. package/.agents/workflows/helpers/deliver-story-reference.md +223 -0
  68. package/.agents/workflows/helpers/deliver-story.md +115 -432
  69. package/.agents/workflows/helpers/plan-reference.md +246 -0
  70. package/.agents/workflows/plan.md +108 -304
  71. package/docs/CHANGELOG.md +46 -0
  72. package/lib/cli/registry.js +31 -14
  73. package/lib/migrations/index.js +2 -0
  74. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
  75. package/package.json +1 -1
@@ -30,11 +30,89 @@ 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
+ /**
50
+ * Stable color for the `route::lite` ceremony-route hint (Story #4707;
51
+ * hint-only since Story #4722 — `/deliver` re-derives the route from the
52
+ * Story body's shape).
53
+ */
54
+ const LITE_ROUTE_LABEL_COLOR = '#D4C5F9';
55
+
56
+ /** Length of the derived plan-run id (hex chars). */
57
+ const PLAN_RUN_ID_LENGTH = 8;
58
+
59
+ /**
60
+ * Normalize a caller-supplied plan-run token. Kept shared so human-readable
61
+ * ids map to one canonical label shape.
62
+ *
63
+ * @param {string} id
64
+ * @returns {string}
65
+ */
66
+ export function normalizePlanRunId(id) {
67
+ const token = String(id ?? '')
68
+ .trim()
69
+ .toLowerCase()
70
+ .replace(/^plan-run::/, '')
71
+ .replace(/[^a-z0-9._-]+/g, '-');
72
+ if (!token) {
73
+ throw new Error('plan-run id requires a non-empty planRunId');
74
+ }
75
+ return token;
76
+ }
77
+
78
+ /**
79
+ * Build a `plan-run::<id>` label from an explicit id.
80
+ *
81
+ * Unlike the pre-#4540 shape, this never mints a random token — a random id
82
+ * would split a resumed persist's cohort across two labels. Derive the id
83
+ * from the authored artifacts via {@link derivePlanRunId} instead.
84
+ *
85
+ * @param {string} id
86
+ * @returns {string}
87
+ */
88
+ export function planRunLabel(id) {
89
+ return `${PLAN_RUN_LABEL_PREFIX}${normalizePlanRunId(id)}`;
90
+ }
91
+
92
+ /**
93
+ * Derive the deterministic plan-run id for a cohort of assembled Stories.
94
+ *
95
+ * Hashes the **sorted** set of per-Story plan fingerprints (the same
96
+ * content identities the resumable-create contract adopts on), so the id is
97
+ * a pure function of the authored artifacts: the same `stories.json` yields
98
+ * the same `plan-run::<id>` on every run — a persist resumed after a
99
+ * mid-run failure applies the identical label to the newly-created
100
+ * remainder that the already-created (adopted) Stories carry — while a
101
+ * different plan derives a different label. Sorting makes the id
102
+ * independent of creation order.
103
+ *
104
+ * @param {string[]} fingerprints Per-Story plan fingerprints.
105
+ * @returns {string} Hex id, {@link PLAN_RUN_ID_LENGTH} chars.
106
+ */
107
+ export function derivePlanRunId(fingerprints) {
108
+ const sorted = (Array.isArray(fingerprints) ? fingerprints : [])
109
+ .map(String)
110
+ .sort();
111
+ return createHash('sha256')
112
+ .update(sorted.join(' '))
113
+ .digest('hex')
114
+ .slice(0, PLAN_RUN_ID_LENGTH);
115
+ }
38
116
 
39
117
  /**
40
118
  * Marker prefix for the per-Story plan fingerprint appended to every
@@ -113,13 +191,19 @@ function planFingerprintMarker(fingerprint) {
113
191
  /**
114
192
  * Labels the authoring pass is never allowed to set. The `agent::*` axis is
115
193
  * 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.
194
+ * flip itself), `type::*` is fixed to `type::story` by the v2 hierarchy,
195
+ * `persona::*` is a retired axis, `plan-run::*` is the runtime-derived
196
+ * cohort grouping axis (Story #4692), and `route::*` is the runtime-derived
197
+ * ceremony-route axis (Story #4707) — a hand-authored entry on either
198
+ * derived axis would compete with the deterministic label persist applies
199
+ * itself.
118
200
  */
119
201
  const FORBIDDEN_LABEL_PREFIXES = Object.freeze([
120
202
  'agent::',
121
203
  'type::',
122
204
  'persona::',
205
+ PLAN_RUN_LABEL_PREFIX,
206
+ 'route::',
123
207
  ]);
124
208
 
125
209
  /** GitHub's own label-name ceiling. */
@@ -607,6 +691,64 @@ async function mirrorNativeDependencyEdges({ provider, stories, idBySlug }) {
607
691
  }
608
692
  }
609
693
 
694
+ /**
695
+ * Ensure a runtime-derived persist label (`plan-run::<id>` cohort grouping,
696
+ * `route::lite` route marker) exists before it is applied — GitHub's
697
+ * create-issue path does not auto-create unknown labels on every provider
698
+ * route, and an opaque derived label never exists yet.
699
+ *
700
+ * **Non-fatal by design**, matching the native-blocked_by mirroring posture:
701
+ * neither label is load-bearing for correctness (grouping is cosmetic, and
702
+ * the route label is a human-visible hint only — `/deliver` re-derives the
703
+ * route from the Story body's shape, Story #4722), so it is never a reason
704
+ * to fail persist. On an ensure
705
+ * failure (throw, or the label reported `missing` by the post-loop
706
+ * reconcile) the create loop proceeds **without** the label — applying an
707
+ * unensured label could fail the issue create itself, and the Stories matter
708
+ * more than their metadata. A provider that exposes no `ensureLabels` (test
709
+ * fakes, minimal providers) is assumed to accept arbitrary labels on create.
710
+ *
711
+ * @param {object} args
712
+ * @param {object} args.provider
713
+ * @param {string} args.label
714
+ * @param {string} args.color
715
+ * @param {string} args.description
716
+ * @param {string} args.role Human-readable role for the degrade warning.
717
+ * @returns {Promise<boolean>} Whether the create loop should apply the label.
718
+ */
719
+ async function ensurePersistLabel({
720
+ provider,
721
+ label,
722
+ color,
723
+ description,
724
+ role,
725
+ }) {
726
+ if (typeof provider?.ensureLabels !== 'function') {
727
+ return true;
728
+ }
729
+ try {
730
+ const result = await provider.ensureLabels([
731
+ { name: label, color, description },
732
+ ]);
733
+ if (Array.isArray(result?.missing) && result.missing.includes(label)) {
734
+ Logger.warn(
735
+ `[plan-persist] ${role} label "${label}" could not be verified ` +
736
+ 'on the remote — creating the Stories without it. Add the label ' +
737
+ 'by hand if you want it.',
738
+ );
739
+ return false;
740
+ }
741
+ return true;
742
+ } catch (err) {
743
+ Logger.warn(
744
+ `[plan-persist] ${role} label ensure failed (${err.message}) — ` +
745
+ 'creating the Stories without it. Add the label by hand if you ' +
746
+ 'want it.',
747
+ );
748
+ return false;
749
+ }
750
+ }
751
+
610
752
  /**
611
753
  * Create Story issues via `provider.createIssue`, resumably.
612
754
  *
@@ -631,24 +773,43 @@ async function mirrorNativeDependencyEdges({ provider, stories, idBySlug }) {
631
773
  * is named in a warning. Adoption never rewrites a body, so keying it on
632
774
  * anything weaker than content would silently ship a stale one.
633
775
  *
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.
776
+ * **Every created Story carries the cohort's `plan-run::<id>` grouping
777
+ * label** (Story #4692, metadata only). The id is deterministic over the
778
+ * authored artifacts ({@link derivePlanRunId}), so a resumed persist derives
779
+ * the identical label its adopted Stories already carry from their original
780
+ * create no relabel call is needed on the resume path, and the cohort is
781
+ * never split across two labels. The label is ensured to exist before the
782
+ * first POST, **non-fatally**: grouping is cosmetic and never fails the run
783
+ * (see `ensureCohortLabel`). `/deliver` never reads it — delivery stays
784
+ * ids-only over live state (Story #4540's actual point).
639
785
  *
640
786
  * **Sibling order is mirrored into native GitHub `blocked_by` edges** once
641
787
  * every id is known (Story #4544), so plan-created order stops depending on
642
788
  * prose. That pass is non-fatal — see `mirrorNativeDependencyEdges`.
643
789
  *
790
+ * **A lite-routed cohort carries the `route::lite` hint** (Story #4707,
791
+ * hint-only since Story #4722). When the caller resolves the plan's
792
+ * effective complexity route to `lite` (the planner's recorded verdict,
793
+ * upheld by the shape backstop), it passes the label via `opts.routeLabel`
794
+ * and every created Story carries it — a **human-visible hint only**, never
795
+ * the control signal: `/deliver` re-derives the route from each Story body's
796
+ * own shape (`resolveStoryDispatchMode`), so a lost or failed label write
797
+ * cannot misroute delivery. A full-routed plan passes nothing and its
798
+ * Stories carry **no** route label. Like the cohort label, the ensure is
799
+ * non-fatal — the label is cosmetic either way.
800
+ *
644
801
  * @param {object} args
645
802
  * @param {object} args.provider
646
803
  * @param {ReturnType<typeof assemblePlanStories>['stories']} args.stories
647
804
  * @param {object} [args.opts]
648
805
  * @param {boolean} [args.opts.dryRun=false]
806
+ * @param {string|null} [args.opts.routeLabel=null] Route marker label to
807
+ * apply to every created Story (`route::lite`), or null for none.
649
808
  * @returns {Promise<{
650
809
  * created: Array<{ slug: string, id: number, url?: string, title: string, adopted: boolean }>,
651
810
  * dependencyEdges: { edgesAdded: number, edgesSkipped: number, edgesFailed: number, storiesProcessed: number }|null,
811
+ * planRunLabel: string,
812
+ * routeLabel: string|null,
652
813
  * }>}
653
814
  */
654
815
  export async function createStoryIssues({ provider, stories, opts = {} }) {
@@ -659,6 +820,17 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
659
820
  }
660
821
 
661
822
  const list = Array.isArray(stories) ? stories : [];
823
+ const routeLabel =
824
+ typeof opts.routeLabel === 'string' && opts.routeLabel.trim() !== ''
825
+ ? opts.routeLabel.trim()
826
+ : null;
827
+
828
+ // Derived once for the whole cohort, before any write — a pure function of
829
+ // the authored artifacts, so dry-run can report it write-free and a resume
830
+ // re-derives the identical label.
831
+ const cohortLabel = planRunLabel(
832
+ derivePlanRunId(list.map((story) => story.fingerprint)),
833
+ );
662
834
 
663
835
  if (opts.dryRun) {
664
836
  return {
@@ -670,9 +842,32 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
670
842
  adopted: false,
671
843
  })),
672
844
  dependencyEdges: null,
845
+ planRunLabel: cohortLabel,
846
+ routeLabel,
673
847
  };
674
848
  }
675
849
 
850
+ const applyCohortLabel = await ensurePersistLabel({
851
+ provider,
852
+ label: cohortLabel,
853
+ color: PLAN_RUN_LABEL_COLOR,
854
+ description:
855
+ 'Groups the Stories one /plan persist run authored (metadata ' +
856
+ 'only — /deliver stays ids-only).',
857
+ role: 'cohort',
858
+ });
859
+ const applyRouteLabel =
860
+ routeLabel !== null &&
861
+ (await ensurePersistLabel({
862
+ provider,
863
+ label: routeLabel,
864
+ color: LITE_ROUTE_LABEL_COLOR,
865
+ description:
866
+ 'Ceremony-lite hint (human-visible only): /deliver re-derives the ' +
867
+ 'route from the Story body shape; every close gate runs unchanged.',
868
+ role: 'route-marker',
869
+ }));
870
+
676
871
  const { byFingerprint, idsByTitle } = await indexExistingStories(provider);
677
872
  const created = [];
678
873
  const idBySlug = new Map();
@@ -681,6 +876,9 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
681
876
  const already = byFingerprint.get(story.fingerprint);
682
877
  if (!already) warnOnDivergentSameTitleStory(story, idsByTitle);
683
878
  if (already) {
879
+ // Adopted Stories already carry the cohort label from their original
880
+ // create — the deterministic derivation guarantees it is the same
881
+ // label this run derived, so no relabel call is needed here.
684
882
  Logger.info(
685
883
  `[plan-persist] resuming: Story "${story.slug}" already exists as ` +
686
884
  `#${already.id} with byte-identical authored content ` +
@@ -700,7 +898,11 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
700
898
  const result = await provider.createIssue({
701
899
  title: story.title,
702
900
  body: renderStoryBodyForCreate(story, idBySlug),
703
- labels: [...story.labels],
901
+ labels: [
902
+ ...story.labels,
903
+ ...(applyCohortLabel ? [cohortLabel] : []),
904
+ ...(applyRouteLabel ? [routeLabel] : []),
905
+ ],
704
906
  });
705
907
  const id = result?.id ?? result?.number;
706
908
  if (!Number.isInteger(id)) {
@@ -727,7 +929,12 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
727
929
  idBySlug,
728
930
  });
729
931
 
730
- return { created, dependencyEdges };
932
+ return {
933
+ created,
934
+ dependencyEdges,
935
+ planRunLabel: cohortLabel,
936
+ routeLabel: applyRouteLabel ? routeLabel : null,
937
+ };
731
938
  }
732
939
 
733
940
  /**
@@ -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';
@@ -306,17 +307,27 @@ export function buildStoriesEnvelope({
306
307
  nativeEdges = new Map(),
307
308
  foreignDone = [],
308
309
  warn,
310
+ config,
309
311
  }) {
310
312
  const sorted = [...stories].sort((a, b) => a.id - b.id);
311
313
  const inSetDone = sorted.filter(isSatisfiedBlocker).map((s) => s.id);
312
314
  return {
313
315
  kind: 'stories',
314
- stories: sorted.map(({ id, title, url, labels, state }) => ({
316
+ // `dispatchMode` (Story #4722): the resolver derives the per-Story
317
+ // execution mode from the fetched Story BODY's own shape (the shared
318
+ // shape function in `complexity-gate.js`) so `/deliver` reads one field —
319
+ // `inline` (lite-shaped: no story-worker / acceptance-critic sub-agent
320
+ // boots) or `subagent` (everything else, the conservative default). The
321
+ // `route::lite` label is a human-visible hint only, never the control
322
+ // signal: a lost label cannot misroute delivery. Model-side fan-out
323
+ // only; close gates are untouched.
324
+ stories: sorted.map(({ id, title, body, url, labels, state }) => ({
315
325
  id,
316
326
  title,
317
327
  url,
318
328
  labels,
319
329
  state,
330
+ dispatchMode: resolveStoryDispatchMode({ body, labels, config }).mode,
320
331
  })),
321
332
  dag: storiesToDag(sorted, nativeEdges, warn),
322
333
  done: [...new Set([...inSetDone, ...foreignDone])].sort((a, b) => a - b),
@@ -74,10 +74,15 @@ export const DEFAULT_DIFF_WIDTH = Object.freeze({
74
74
  * changed-file set intersect any sensitive-path class registered in
75
75
  * `audit-rules.json`?
76
76
  *
77
- * This is the **single source** of the derived level — both the review depth
78
- * ({@link resolveDepth}) and the acceptance-critic fresh-vs-inline routing
79
- * (`ceremony-routing.js#resolveCeremonyForRisk`) consume what this returns, so
80
- * the two ceremony decisions can never disagree about how risky a change is.
77
+ * This is the **single source** of the derived level — the review depth
78
+ * ({@link resolveDepth}), the acceptance-critic fresh-vs-inline routing
79
+ * (`ceremony-routing.js#resolveCeremonyForRisk`), and the dispatch-side
80
+ * complexity routing (`complexity-gate.js#deriveStoryShape`, Story #4722)
81
+ * all consume what this returns, so no ceremony decision can disagree about
82
+ * how risky a change is. Dispatch reads the **predicted** shape (the Story's
83
+ * declared `changes[]` footprint) and close reads the **actual** diff — one
84
+ * taxonomy, two read points, which is what keeps a lite-shaped Story whose
85
+ * footprint touches a sensitive path on the full route with its fresh critic.
81
86
  *
82
87
  * Returns `null` — the fail-safe "no derivable signal" level — when the change
83
88
  * set is empty/unknown or the manifest cannot be read. Both downstream
@@ -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