mandrel 2.21.0 → 2.23.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 (44) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +5 -0
  3. package/.agents/docs/configuration.md +1 -0
  4. package/.agents/instructions.md +14 -17
  5. package/.agents/rules/git-conventions.md +1 -1
  6. package/.agents/rules/known-tooling-behavior.md +114 -0
  7. package/.agents/schemas/agentrc.schema.json +6 -0
  8. package/.agents/scripts/check-context-budget.js +134 -2
  9. package/.agents/scripts/diagnose-friction.js +95 -4
  10. package/.agents/scripts/lib/audit-suite/selector.js +275 -162
  11. package/.agents/scripts/lib/config/temp-paths.js +51 -7
  12. package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
  13. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  14. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  15. package/.agents/scripts/lib/label-constants.js +12 -1
  16. package/.agents/scripts/lib/observability/runtime-friction.js +41 -1
  17. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  18. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  19. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  20. package/.agents/scripts/lib/orchestration/complexity-gate.js +51 -46
  21. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  22. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  24. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  25. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  26. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  27. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  28. package/.agents/scripts/lib/orchestration/run-epilogue.js +69 -7
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
  30. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
  31. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
  32. package/.agents/scripts/lib/orchestration/story-follow-ups.js +380 -13
  33. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  34. package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
  35. package/.agents/scripts/resolve-stories.js +52 -33
  36. package/.agents/scripts/single-story-confirm-merge.js +5 -7
  37. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  38. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  39. package/.agents/workflows/helpers/deliver-story-reference.md +23 -21
  40. package/.agents/workflows/helpers/deliver-story.md +2 -2
  41. package/.agents/workflows/helpers/plan-reference.md +45 -4
  42. package/.agents/workflows/plan.md +21 -16
  43. package/docs/CHANGELOG.md +34 -0
  44. package/package.json +1 -1
@@ -21,9 +21,11 @@ import { gitSpawn } from '../git-utils.js';
21
21
  import { Logger } from '../Logger.js';
22
22
  import { composeRoutedProposals } from './retro-proposals.js';
23
23
  import {
24
+ assessRollupOutcome,
24
25
  buildFollowUpsCommentBody,
25
26
  gatherRunFrictionSignals,
26
27
  resolveFollowUpRepos,
28
+ summarizeSignalCategories,
27
29
  } from './story-follow-ups.js';
28
30
  import { upsertStructuredComment } from './ticketing.js';
29
31
 
@@ -550,27 +552,35 @@ async function executeFollowUpRollup({
550
552
  provider,
551
553
  config,
552
554
  cwd,
555
+ graduateFn = graduateRetroProposals,
553
556
  }) {
554
557
  // Shared with the story-scoped gather (Story #4649): `storyId` + `details`
555
558
  // are what the composer's recovery-netting keys on, and two hand-rolled
556
559
  // copies of this loop are how they got dropped in the first place.
557
- const signals = await gatherRunFrictionSignals(stories, config);
560
+ const { signals, window: frictionWindow } = await gatherRunFrictionSignals(
561
+ stories,
562
+ config,
563
+ );
558
564
  const repos = resolveFollowUpRepos(config);
559
565
  const primaryId = Number(stories[0]);
566
+ // Story #4850 — `runToken` and `anchorStoryIds` are INPUTS. This used to
567
+ // compose with the primary Story's numeric id standing in for the run and
568
+ // then rewrite the rendered title/body by regex over a `plan-run \d+`
569
+ // substring, which meant the composer's own wording could not be changed
570
+ // without silently breaking the patch. `anchorStoryIds` is what lets the
571
+ // composer tell a corpus confined to this run from one spanning the whole
572
+ // surviving window, so it never titles the latter as if it were the former.
560
573
  const proposals = composeRoutedProposals({
561
574
  anchorId: Number.isInteger(primaryId) ? primaryId : 1,
562
575
  anchorKind: 'run',
576
+ runToken: String(planRunId ?? ''),
577
+ anchorStoryIds: stories,
563
578
  frameworkRepo: repos.frameworkRepo,
564
579
  consumerRepo: repos.consumerRepo,
565
580
  signals,
566
581
  unresolvedBlockedEvents: [],
567
582
  });
568
- // Patch titles to mention the plan-run token (anchorKind run uses numeric id).
569
- for (const item of [...proposals.framework, ...proposals.consumer]) {
570
- item.title = item.title.replace(/plan-run \d+/, `plan-run ${planRunId}`);
571
- item.body = item.body.replace(/plan-run \d+/g, `plan-run ${planRunId}`);
572
- }
573
- const graduated = await graduateRetroProposals({
583
+ const graduated = await graduateFn({
574
584
  epicId: primaryId,
575
585
  provider,
576
586
  config,
@@ -582,6 +592,16 @@ async function executeFollowUpRollup({
582
592
  routedProposals: proposals,
583
593
  cwd,
584
594
  });
595
+ const categories = summarizeSignalCategories(signals);
596
+ const proposalCount = proposals.framework.length + proposals.consumer.length;
597
+ const outcome = assessRollupOutcome({
598
+ signalCount: signals.length,
599
+ proposalCount,
600
+ discardedCount: proposals.discarded.length,
601
+ filedCount: graduated.filed?.length ?? 0,
602
+ filingErrors: graduated.errors,
603
+ filingSkipped: graduated.skipped,
604
+ });
585
605
  if (Number.isInteger(primaryId) && primaryId > 0) {
586
606
  const body = buildFollowUpsCommentBody({
587
607
  storyId: primaryId,
@@ -591,6 +611,10 @@ async function executeFollowUpRollup({
591
611
  // render as a flagged claim ("0 signals across N Stories") rather than
592
612
  // as "nothing to follow up".
593
613
  storyCount: stories.length,
614
+ // Story #4828 — and the corpus is what lets a zero-proposal or
615
+ // zero-filed roll-up name what it saw instead of rendering as clean.
616
+ signalCount: signals.length,
617
+ categories,
594
618
  }).replace(
595
619
  `from Story #${primaryId}`,
596
620
  `from plan-run \`${planRunId}\` (primary Story #${primaryId})`,
@@ -602,6 +626,39 @@ async function executeFollowUpRollup({
602
626
  signalCount: signals.length,
603
627
  storyCount: stories.length,
604
628
  filed: graduated.filed?.length ?? 0,
629
+ // Story #4850 — the recurrence window the gather actually applied, and what
630
+ // it dropped. `signalCount` alone cannot distinguish "the window is bounded
631
+ // at 30 days and 40 rows aged out" from "nothing older exists", and an
632
+ // operator triaging a roll-up needs to know which corpus produced it.
633
+ frictionWindow,
634
+ // Story #4828 — everything below is what the roll-up saw and what became
635
+ // of it. The pre-#4828 result reported `signalCount` and `filed` and
636
+ // nothing in between, so nine signals routing into one proposal whose
637
+ // every filing attempt errored rendered as `{signalCount: 9, filed: 0,
638
+ // discarded: []}` — arithmetically consistent, and indistinguishable from
639
+ // a run with nothing to do.
640
+ proposalCount,
641
+ // The categories the corpus actually contained, so a zero-proposal
642
+ // roll-up names its own input rather than asserting emptiness.
643
+ categories,
644
+ filingErrors: Array.isArray(graduated.errors) ? graduated.errors : [],
645
+ filingSkipped: outcome.blockingSkipReasons,
646
+ // Signals in, nothing out — not even a below-threshold row.
647
+ zeroProposalSuspect: outcome.zeroProposals,
648
+ // Proposals cleared the threshold and the filer produced none of them.
649
+ unfiledProposalSuspect: outcome.unfiledProposals,
650
+ // Story #4824 — a roll-up that discards every candidate must still name
651
+ // what it discarded. Rendering that as "nothing to follow up" is how a
652
+ // defect recurring once per Story survived eighteen consecutive Stories.
653
+ // Surfaced on the step result so the CLI need not regex the comment body.
654
+ discarded: proposals.discarded.map((item) => ({
655
+ category: item.category,
656
+ occurrences: item.occurrences,
657
+ source: item.source,
658
+ storyCount: item.storyCount ?? null,
659
+ tools: item.tools ?? [],
660
+ fingerprint: item.fingerprint ?? null,
661
+ })),
605
662
  // Story #4578 — zero signals across a multi-Story run is a claim, not a
606
663
  // clean bill of health. Surfaced on the step result so the CLI can warn
607
664
  // the operator without re-deriving it from the comment prose.
@@ -698,6 +755,9 @@ async function executeSiblingCoherence({ planRunId, stories, provider }) {
698
755
  * @param {string} [args.cwd]
699
756
  * @param {{ gitSpawn: Function }} [args.git] - Injection seam for tests.
700
757
  * @param {typeof selectAudits} [args.selectAuditsFn] - Injection seam for tests.
758
+ * @param {typeof graduateRetroProposals} [args.graduateFn] - Injection seam so
759
+ * the roll-up's reporting layer can be asserted against a filer that fails
760
+ * (Story #4828) without spawning a real `gh`.
701
761
  * @returns {Promise<object>}
702
762
  */
703
763
  export async function runPlanRunEpilogue({
@@ -708,6 +768,7 @@ export async function runPlanRunEpilogue({
708
768
  cwd = process.cwd(),
709
769
  git = { gitSpawn },
710
770
  selectAuditsFn = selectAudits,
771
+ graduateFn = graduateRetroProposals,
711
772
  } = {}) {
712
773
  const plan = planRunEpilogue({ planRunId, stories });
713
774
  if (!plan.applicable) {
@@ -741,6 +802,7 @@ export async function runPlanRunEpilogue({
741
802
  provider,
742
803
  config,
743
804
  cwd,
805
+ graduateFn,
744
806
  }),
745
807
  );
746
808
  } else if (step.kind === 'sibling-coherence') {
@@ -28,8 +28,13 @@
28
28
  */
29
29
 
30
30
  import { parsePrNumberFromUrl } from '../../../github-url.js';
31
+ import { degradationEnvelope } from '../../review-providers/degraded-gates.js';
31
32
  import { runStoryReviewCore } from '../../story-close/phases/review-core.js';
32
33
  import { postStructuredComment } from '../../ticketing/state.js';
34
+ import {
35
+ buildOutcomeTally,
36
+ formatReviewOutcomeLines,
37
+ } from './review-outcome.js';
33
38
 
34
39
  /**
35
40
  * Extract the numeric PR ID from a `gh pr create` URL. The CLI returns a
@@ -62,13 +67,11 @@ export function buildStoryReviewCrossRefBody({
62
67
  prNumber,
63
68
  commentUrl,
64
69
  severity,
70
+ degradations,
65
71
  }) {
66
- const tally =
67
- `critical:${severity.critical} · high:${severity.high} · ` +
68
- `medium:${severity.medium} · suggestion:${severity.suggestion}`;
69
72
  return (
70
73
  `🔬 Story-scope code review posted on PR [#${prNumber}](${prUrl}): ` +
71
- `[view findings](${commentUrl}) — ${tally}.`
74
+ `[view findings](${commentUrl}) — ${buildOutcomeTally({ severity, degradations })}.`
72
75
  );
73
76
  }
74
77
 
@@ -116,6 +119,7 @@ async function postStoryReviewCrossRef({
116
119
  prNumber,
117
120
  commentUrl,
118
121
  severity,
122
+ degradations: result.degradations,
119
123
  });
120
124
  try {
121
125
  await postStructuredComment(provider, storyId, 'notification', body);
@@ -174,6 +178,8 @@ async function postStoryReviewCrossRef({
174
178
  * severity?: { critical: number, high: number, medium: number, suggestion: number },
175
179
  * posted?: boolean,
176
180
  * postedCommentId?: number|null,
181
+ * degraded?: boolean,
182
+ * degradations?: Array<object>,
177
183
  * crossRefPosted?: boolean,
178
184
  * localLensReview?: object,
179
185
  * }>}
@@ -222,10 +228,13 @@ export async function runStoryScopeReview({
222
228
  medium: 0,
223
229
  suggestion: 0,
224
230
  };
225
- progress(
226
- 'REVIEW',
227
- `Findings — critical:${sev.critical} high:${sev.high} medium:${sev.medium} suggestion:${sev.suggestion}. Posted to PR #${prNumber}: ${result.posted}.`,
228
- );
231
+ const outcome = formatReviewOutcomeLines({
232
+ severity: sev,
233
+ degradations: result.degradations,
234
+ prNumber,
235
+ posted: result.posted,
236
+ });
237
+ for (const line of outcome) progress('REVIEW', line);
229
238
 
230
239
  const crossRefPosted = await postStoryReviewCrossRef({
231
240
  provider,
@@ -242,6 +251,7 @@ export async function runStoryScopeReview({
242
251
  severity: sev,
243
252
  posted: result.posted,
244
253
  postedCommentId: result.postedCommentId ?? null,
254
+ ...degradationEnvelope(result.degradations),
245
255
  crossRefPosted,
246
256
  localLensReview: result.localLensReview,
247
257
  };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * phases/review-outcome.js — operator-facing rendering of the Story-scope
3
+ * review outcome (Story #4839).
4
+ *
5
+ * The review phase used to report a severity tally and nothing else, which made
6
+ * "every gate ran and found nothing" and "a gate never ran" render identically.
7
+ * Both surfaces the operator actually reads — the close progress stream and the
8
+ * cross-reference comment on the Story — now state the degraded gates
9
+ * explicitly, and always state them (as `none` when healthy) so an absent line
10
+ * can never be mistaken for a clean gate.
11
+ *
12
+ * A degraded gate is **reported, not blocking**: the canonical `npm run lint`
13
+ * close-validation gate has already covered this diff before the review phase
14
+ * runs, so failing the merge on a secondary read of an already-gated surface
15
+ * would cost delivery without buying coverage. The rationale for that posture
16
+ * lives with the channel itself in
17
+ * [`review-providers/degraded-gates.js`](../../review-providers/degraded-gates.js).
18
+ */
19
+
20
+ import { summarizeDegradations } from '../../review-providers/degraded-gates.js';
21
+
22
+ /**
23
+ * Pure: the tally suffix shared by the progress line and the cross-reference
24
+ * comment — severity counts plus the degraded-gate state.
25
+ *
26
+ * @param {{ severity: { critical: number, high: number, medium: number, suggestion: number }, degradations?: unknown }} args
27
+ * @returns {string}
28
+ */
29
+ export function buildOutcomeTally({ severity, degradations }) {
30
+ return (
31
+ `critical:${severity.critical} · high:${severity.high} · ` +
32
+ `medium:${severity.medium} · suggestion:${severity.suggestion} · ` +
33
+ `degraded gates: ${summarizeDegradations(degradations)}`
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Pure: the progress lines announcing a completed review. Always one line
39
+ * naming the tally; a second, explicitly-worded line when a gate did not run so
40
+ * the degradation cannot be skimmed past.
41
+ *
42
+ * @param {{
43
+ * severity: { critical: number, high: number, medium: number, suggestion: number },
44
+ * degradations?: unknown,
45
+ * prNumber: number,
46
+ * posted: boolean,
47
+ * }} args
48
+ * @returns {string[]}
49
+ */
50
+ export function formatReviewOutcomeLines({
51
+ severity,
52
+ degradations,
53
+ prNumber,
54
+ posted,
55
+ }) {
56
+ const tally = buildOutcomeTally({ severity, degradations });
57
+ const lines = [`Findings — ${tally}. Posted to PR #${prNumber}: ${posted}.`];
58
+ if (summarizeDegradations(degradations) !== 'none') {
59
+ lines.push(
60
+ '⚠️ Review ran DEGRADED — the surface(s) above were not reviewed. The close ' +
61
+ 'is not blocked (the canonical `npm run lint` close gate already covered ' +
62
+ 'this diff), but this review does not vouch for them.',
63
+ );
64
+ }
65
+ return lines;
66
+ }
@@ -38,6 +38,7 @@
38
38
 
39
39
  import { Logger } from '../../../Logger.js';
40
40
  import { runCodeReview } from '../../code-review.js';
41
+ import { summarizeDegradations } from '../../review-providers/degraded-gates.js';
41
42
  import { emitBlockedCloseResult } from '../emit-blocked.js';
42
43
  import { runLocalLensReview } from './local-lens-review.js';
43
44
  import { runStoryReviewCore } from './review-core.js';
@@ -86,7 +87,10 @@ function buildCodeReviewBlockedExtra({ storyId, reviewResult }) {
86
87
  function formatReviewSummary(reviewResult) {
87
88
  const { high, medium, suggestion } = resolveSeverity(reviewResult);
88
89
  const posted = reviewResult?.posted ?? false;
89
- return `Review completehigh=${high} medium=${medium} suggestion=${suggestion} (posted=${posted}).`;
90
+ // Story #4839`degraded` is always stated: an absent degradation line must
91
+ // never be the way an operator concludes that every gate ran.
92
+ const degraded = summarizeDegradations(reviewResult?.degradations);
93
+ return `Review complete — high=${high} medium=${medium} suggestion=${suggestion} degraded=${degraded} (posted=${posted}).`;
90
94
  }
91
95
 
92
96
  /**