mandrel 2.25.0 → 2.26.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 (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -24,6 +24,10 @@ import {
24
24
  parse as parseStoryBody,
25
25
  serialize as serializeStoryBody,
26
26
  } from '../../story-body/story-body.js';
27
+ import {
28
+ concurrentMap,
29
+ FANOUT_CONCURRENCY,
30
+ } from '../../util/concurrent-map.js';
27
31
  import { assertSpecWithinBudget } from '../spec-spill.js';
28
32
  import { assertAcceptancePartition } from '../split-policy-validator.js';
29
33
  import {
@@ -795,6 +799,14 @@ async function ensurePersistLabel({
795
799
  * (see `ensureCohortLabel`). `/deliver` never reads it — delivery stays
796
800
  * ids-only over live state (Story #4540's actual point).
797
801
  *
802
+ * **The create loop stays serial and dependency-ordered** — deliberately, and
803
+ * unlike every other per-Story loop on this path (Story #4952). It is not an
804
+ * independent fan-out: `renderStoryBodyForCreate(story, idBySlug)` resolves a
805
+ * Story's `depends_on` slugs to the real issue ids its siblings were just
806
+ * minted with, and `idBySlug` is filled *in loop order* by the POSTs
807
+ * themselves. Running it concurrently would render `#undefined` dependency
808
+ * refs for any Story whose dependency had not yet returned an id.
809
+ *
798
810
  * **Sibling order is mirrored into native GitHub `blocked_by` edges** once
799
811
  * every id is known (Story #4544), so plan-created order stops depending on
800
812
  * prose. That pass is non-fatal — see `mirrorNativeDependencyEdges`.
@@ -960,6 +972,16 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
960
972
  * Fails closed: an un-flipped Story is invisible to `/deliver`, which is the
961
973
  * safe direction — the operator is told exactly which ids need the label.
962
974
  *
975
+ * **Collect failures, never fast-fail** (preserved verbatim under the
976
+ * Story #4952 concurrency conversion). Every Story is attempted even when an
977
+ * earlier one's PATCH rejects, because the whole value of the closing error is
978
+ * naming the *complete* set of ids that still need the label by hand. The
979
+ * mapper below therefore absorbs its own rejection into a per-Story outcome
980
+ * rather than letting `concurrentMap`'s first-rejection-wins policy abandon
981
+ * the remaining flips. `concurrentMap` preserves input order, so `readied[]`
982
+ * and the reported failures come back in `created[]` order exactly as the
983
+ * serial loop produced them.
984
+ *
963
985
  * @param {object} args
964
986
  * @param {object} args.provider
965
987
  * @param {Array<{ id: number, slug: string }>} args.created
@@ -972,18 +994,32 @@ export async function markStoriesReady({ provider, created }) {
972
994
  'Stories to agent::ready.',
973
995
  );
974
996
  }
975
- const readied = [];
976
- const failed = [];
977
- for (const story of created) {
978
- try {
979
- await provider.updateTicket(story.id, {
980
- labels: { add: [AGENT_LABELS.READY] },
981
- });
982
- readied.push(story.id);
983
- } catch (err) {
984
- failed.push(`#${story.id} (${story.slug}): ${err.message}`);
985
- }
986
- }
997
+ const outcomes = await concurrentMap(
998
+ created,
999
+ async (story) => {
1000
+ try {
1001
+ await provider.updateTicket(story.id, {
1002
+ labels: { add: [AGENT_LABELS.READY] },
1003
+ });
1004
+ return { id: story.id, failure: null };
1005
+ } catch (err) {
1006
+ return {
1007
+ id: story.id,
1008
+ failure: `#${story.id} (${story.slug}): ${err.message}`,
1009
+ };
1010
+ }
1011
+ },
1012
+ // The terminal per-Story `agent::ready` PATCHes (Story #4952): each flip
1013
+ // is an independent single-issue write, so the loop was serial only by
1014
+ // construction. A latency fix, not a throughput one.
1015
+ { concurrency: FANOUT_CONCURRENCY },
1016
+ );
1017
+ const readied = outcomes
1018
+ .filter((outcome) => outcome.failure === null)
1019
+ .map((outcome) => outcome.id);
1020
+ const failed = outcomes
1021
+ .filter((outcome) => outcome.failure !== null)
1022
+ .map((outcome) => outcome.failure);
987
1023
  if (failed.length > 0) {
988
1024
  throw new Error(
989
1025
  `[plan-persist] ${failed.length} Story(ies) were created with their ` +
@@ -36,6 +36,10 @@
36
36
  */
37
37
 
38
38
  import { Logger } from '../../Logger.js';
39
+ import {
40
+ concurrentMap,
41
+ FANOUT_CONCURRENCY,
42
+ } from '../../util/concurrent-map.js';
39
43
  import { upsertStructuredComment } from '../ticketing.js';
40
44
 
41
45
  /** Structured-comment type marking a source issue as superseded. */
@@ -440,41 +444,94 @@ export async function closeSupersededTickets({
440
444
  const createdBySlug = new Map(
441
445
  (created ?? []).map((story) => [story.slug, story]),
442
446
  );
443
-
444
- const report = emptyReport({ enabled: true, dryRun });
445
-
446
- for (const story of stories ?? []) {
447
- const createdStory = createdBySlug.get(story.slug);
448
- for (const { id, note } of story.supersedes ?? []) {
447
+ const units = collectSupersedeUnits(stories, createdBySlug);
448
+
449
+ // Story #4952 — one bounded fan-out across distinct source tickets. The
450
+ // mapper inherits `closeOneSupersededTicket`'s never-throw contract (and
451
+ // resolves the two pre-write outcomes itself), so `concurrentMap`'s
452
+ // first-rejection-wins policy can never fire and no unit is abandoned
453
+ // because a sibling failed. Input order is preserved, so the report arrays
454
+ // below read exactly as the serial nested loop wrote them.
455
+ const outcomes = await concurrentMap(
456
+ units,
457
+ ({ id, note, createdStory }) => {
449
458
  if (!createdStory) {
450
- report.skipped.push({ ticket: id, reason: 'story-not-created' });
451
- continue;
452
- }
453
- if (dryRun) {
454
- report.planned.push({ ticket: id, storySlug: createdStory.slug });
455
- continue;
459
+ return { outcome: 'skipped', reason: 'story-not-created' };
456
460
  }
457
- const result = await closeOneSupersededTicket({
461
+ if (dryRun) return { outcome: 'planned' };
462
+ return closeOneSupersededTicket({
458
463
  provider,
459
464
  id,
460
465
  note,
461
466
  story: createdStory,
462
467
  sourceTicketIds: sources,
463
468
  });
464
- if (result.outcome === 'closed') {
465
- report.closed.push(id);
466
- } else if (result.outcome === 'skipped') {
467
- report.skipped.push({ ticket: id, reason: result.reason });
468
- } else {
469
- report.failed.push({ ticket: id, reason: result.reason });
470
- }
471
- }
472
- }
469
+ },
470
+ // The per-source-ticket close (Story #4952) fans out across **distinct**
471
+ // tickets `assertSupersedePartition` has already failed the run closed
472
+ // if two Stories claim the same id, so no two units in flight can touch
473
+ // the same issue. Within one unit the probe → comment → close sequence
474
+ // stays strictly ordered: commenting on an issue the probe reported
475
+ // closed, or closing one the comment never landed on, is the whole
476
+ // failure mode this phase is careful about.
477
+ { concurrency: FANOUT_CONCURRENCY },
478
+ );
479
+
480
+ const report = emptyReport({ enabled: true, dryRun });
481
+ outcomes.forEach((result, index) => {
482
+ recordSupersedeOutcome(report, units[index], result);
483
+ });
473
484
 
474
485
  logSupersedeReport(report);
475
486
  return report;
476
487
  }
477
488
 
489
+ /**
490
+ * Flatten the per-Story `supersedes[]` maps into one list of close units, in
491
+ * the nested iteration order the report arrays are expected to follow.
492
+ *
493
+ * @param {Array<{ slug: string, supersedes?: Array<{ id: number, note: string|null }> }>|undefined} stories
494
+ * @param {Map<string, { slug: string, id: number, title: string }>} createdBySlug
495
+ * @returns {Array<{ id: number, note: string|null, createdStory: object|undefined }>}
496
+ */
497
+ function collectSupersedeUnits(stories, createdBySlug) {
498
+ const units = [];
499
+ for (const story of stories ?? []) {
500
+ const createdStory = createdBySlug.get(story.slug);
501
+ for (const { id, note } of story.supersedes ?? []) {
502
+ units.push({ id, note, createdStory });
503
+ }
504
+ }
505
+ return units;
506
+ }
507
+
508
+ /**
509
+ * File one unit's outcome onto the report.
510
+ *
511
+ * @param {SupersedeReport} report
512
+ * @param {{ id: number, createdStory: object|undefined }} unit
513
+ * @param {{ outcome: string, reason?: string }} result
514
+ * @returns {void}
515
+ */
516
+ function recordSupersedeOutcome(report, unit, result) {
517
+ if (result.outcome === 'closed') {
518
+ report.closed.push(unit.id);
519
+ return;
520
+ }
521
+ if (result.outcome === 'planned') {
522
+ report.planned.push({
523
+ ticket: unit.id,
524
+ storySlug: unit.createdStory.slug,
525
+ });
526
+ return;
527
+ }
528
+ if (result.outcome === 'skipped') {
529
+ report.skipped.push({ ticket: unit.id, reason: result.reason });
530
+ return;
531
+ }
532
+ report.failed.push({ ticket: unit.id, reason: result.reason });
533
+ }
534
+
478
535
  /**
479
536
  * Surface the supersede outcome on the console so a partial failure is
480
537
  * visible without reading the JSON envelope.
@@ -18,7 +18,9 @@
18
18
  *
19
19
  * - **dangling-citation** — a sentence referencing a document section
20
20
  * (`§`, "design note", "review doc") with no repo-relative path and no
21
- * `#<digits>` issue anchor in the same sentence.
21
+ * `#<digits>` issue anchor in the same sentence. An anchor written
22
+ * inside a code span counts — the conventional markdown form (Story
23
+ * #4906).
22
24
  * - **open-question** — interrogative-to-operator phrasing ("Flag if",
23
25
  * "TBD", "confirm with the operator", a trailing `?`) in Goal/Spec
24
26
  * prose outside code spans. Bodies record decisions; unresolved
@@ -73,28 +75,55 @@ const OPEN_QUESTION_MARKERS = [
73
75
  */
74
76
 
75
77
  /**
76
- * Strip fenced code blocks and inline code spans so code content (shell
77
- * snippets, grep patterns, JSON) never trips a prose heuristic.
78
+ * Private-use sentinel standing in for one extracted inline code span.
79
+ * It carries no citation marker, no anchor and no sentence boundary, so it
80
+ * is inert for every marker heuristic while recording where the span sat.
81
+ */
82
+ const CODE_SLOT_PATTERN = /\uE000(\d+)\uE001/g;
83
+
84
+ /**
85
+ * Replace fenced code blocks with a space and each inline code span with a
86
+ * positional slot, so code content (shell snippets, grep patterns, JSON)
87
+ * never trips a prose heuristic yet stays recoverable for the anchor check.
78
88
  *
79
89
  * @param {string} text
80
- * @returns {string}
90
+ * @returns {{ slotted: string, spans: string[] }}
81
91
  */
82
- function stripCodeSpans(text) {
83
- return text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ');
92
+ function slotCodeSpans(text) {
93
+ const spans = [];
94
+ const slotted = text
95
+ .replace(/```[\s\S]*?```/g, ' ')
96
+ .replace(/`[^`\n]*`/g, (span) => {
97
+ spans.push(span.slice(1, -1));
98
+ return `\uE000${spans.length - 1}\uE001`;
99
+ });
100
+ return { slotted, spans };
84
101
  }
85
102
 
86
103
  /**
87
104
  * Split prose into sentence-ish units. Newlines are boundaries too, so a
88
- * bullet list yields one unit per bullet.
105
+ * bullet list yields one unit per bullet. Each unit carries two views of
106
+ * the same sentence: `prose`, with code content removed, which every
107
+ * marker heuristic reads; and `anchorText`, with inline code content
108
+ * restored, so a citation anchored inside a code span is still visible to
109
+ * the anchor check. Restoring only ever adds anchors — markers are matched
110
+ * against `prose` alone, exactly as before.
89
111
  *
90
112
  * @param {string} text
91
- * @returns {string[]}
113
+ * @returns {Array<{ prose: string, anchorText: string }>}
92
114
  */
93
115
  function splitSentences(text) {
94
- return text
116
+ const { slotted, spans } = slotCodeSpans(text);
117
+ return slotted
95
118
  .split(/(?<=[.!?])\s+|\n+/)
96
- .map((s) => s.trim())
97
- .filter(Boolean);
119
+ .map((unit) => ({
120
+ prose: unit.replace(CODE_SLOT_PATTERN, ' ').trim(),
121
+ anchorText: unit.replace(
122
+ CODE_SLOT_PATTERN,
123
+ (_slot, index) => ` ${spans[Number(index)]} `,
124
+ ),
125
+ }))
126
+ .filter((unit) => unit.prose.length > 0);
98
127
  }
99
128
 
100
129
  /**
@@ -112,18 +141,21 @@ function excerpt(text) {
112
141
 
113
142
  /**
114
143
  * dangling-citation: a citation-marker sentence with no locating anchor.
144
+ * The marker is matched against the sentence's code-stripped prose; the
145
+ * anchor against its code-restored text, so a path or issue reference
146
+ * written in a code span — the conventional markdown form — counts.
115
147
  *
116
- * @param {string} prose - Code-stripped body prose.
148
+ * @param {string} prose - Raw body prose.
117
149
  * @param {string} slug
118
150
  * @returns {TextHygieneFinding[]}
119
151
  */
120
152
  function findDanglingCitations(prose, slug) {
121
153
  const findings = [];
122
- for (const sentence of splitSentences(prose)) {
154
+ for (const { prose: sentence, anchorText } of splitSentences(prose)) {
123
155
  const cites = CITATION_MARKERS.some((m) => m.test(sentence));
124
156
  if (!cites) continue;
125
157
  const anchored =
126
- ISSUE_ANCHOR.test(sentence) || REPO_PATH_ANCHOR.test(sentence);
158
+ ISSUE_ANCHOR.test(anchorText) || REPO_PATH_ANCHOR.test(anchorText);
127
159
  if (anchored) continue;
128
160
  findings.push({
129
161
  kind: 'dangling-citation',
@@ -142,13 +174,13 @@ function findDanglingCitations(prose, slug) {
142
174
  * open-question: operator-directed phrasing (or a trailing `?`) in prose a
143
175
  * non-interactive sub-agent executes.
144
176
  *
145
- * @param {string} prose - Code-stripped Goal/Spec prose.
177
+ * @param {string} prose - Raw Goal/Spec prose.
146
178
  * @param {string} slug
147
179
  * @returns {TextHygieneFinding[]}
148
180
  */
149
181
  function findOpenQuestions(prose, slug) {
150
182
  const findings = [];
151
- for (const sentence of splitSentences(prose)) {
183
+ for (const { prose: sentence } of splitSentences(prose)) {
152
184
  const marked =
153
185
  OPEN_QUESTION_MARKERS.some((m) => m.test(sentence)) ||
154
186
  sentence.endsWith('?');
@@ -217,12 +249,11 @@ export function evaluateTextHygiene({ draftStories = null } = {}) {
217
249
  }
218
250
  const goal = typeof body.goal === 'string' ? body.goal : '';
219
251
  const spec = typeof body.spec === 'string' ? body.spec : '';
220
- const bodyProse = stripCodeSpans(
221
- typeof story.body === 'string' ? story.body : [goal, spec].join('\n'),
222
- );
252
+ const bodyProse =
253
+ typeof story.body === 'string' ? story.body : [goal, spec].join('\n');
223
254
  findings.push(
224
255
  ...findDanglingCitations(bodyProse, slug),
225
- ...findOpenQuestions(stripCodeSpans([goal, spec].join('\n')), slug),
256
+ ...findOpenQuestions([goal, spec].join('\n'), slug),
226
257
  ...findSlicingMass(body, slug),
227
258
  );
228
259
  }
@@ -7,7 +7,6 @@
7
7
  * the provider call needed to load the Epic.
8
8
  */
9
9
 
10
- import * as os from 'node:os';
11
10
  import path from 'node:path';
12
11
  import {
13
12
  resolveFeatureRoots,
@@ -15,39 +14,15 @@ import {
15
14
  } from '../../bdd-runner-detect.js';
16
15
  import { scanBddScenarios } from '../../bdd-scenario-scanner.js';
17
16
  import { getPaths, PROJECT_ROOT } from '../../config-resolver.js';
18
- import { scanMemoryFreshness } from '../../feedback-loop/memory-freshness.js';
19
17
  import { fetchPriorFeedback } from '../../feedback-loop/prior-feedback-fetcher.js';
20
18
  import { Logger } from '../../Logger.js';
21
19
  import { hasTicketSection } from '../../ticket-body-sections.js';
20
+ import {
21
+ concurrentMap,
22
+ FANOUT_CONCURRENCY,
23
+ } from '../../util/concurrent-map.js';
22
24
  import { ensureDocsDigest } from '../docs-digest.js';
23
-
24
- /**
25
- * Resolve the per-project memory directory used by the memory-freshness
26
- * pre-flight (Story #2557 / Epic #2547).
27
- *
28
- * Resolution order:
29
- * 1. `MANDREL_MEMORY_DIR` environment variable (test seam and operator
30
- * override).
31
- * 2. `~/.claude/projects/<repo>/memory/` — the standard Claude Code
32
- * memory substrate path, scoped by the configured GitHub repo so each
33
- * consumer project gets its own memory pool.
34
- * 3. `null` when neither is resolvable. The scanner tolerates a missing
35
- * `memoryDir` and surfaces a single `errors[]` entry.
36
- *
37
- * @param {{ github?: { owner?: string, repo?: string }|null }} opts
38
- * @returns {string|null}
39
- */
40
- function resolveMemoryDir({ github } = {}) {
41
- if (
42
- typeof process.env.MANDREL_MEMORY_DIR === 'string' &&
43
- process.env.MANDREL_MEMORY_DIR.length > 0
44
- ) {
45
- return process.env.MANDREL_MEMORY_DIR;
46
- }
47
- const repo = github?.repo;
48
- if (typeof repo !== 'string' || repo.length === 0) return null;
49
- return path.join(os.homedir(), '.claude', 'projects', repo, 'memory');
50
- }
25
+ import { buildMemoryPoolAdvisory } from './memory-pool-advisory.js';
51
26
 
52
27
  /**
53
28
  * Build the digest-first `docsContext` envelope field (Story #4433 — hard
@@ -95,6 +70,24 @@ async function buildPlanningDocsContext({ seedIssueId, settings, cwd }) {
95
70
  return { mode: 'digest', digestPath: relPath };
96
71
  }
97
72
 
73
+ /**
74
+ * Story #2637 — index existing BDD scenarios so the Acceptance Engineer step
75
+ * can annotate planned ACs with matches from the project's `.feature` files.
76
+ * Empty array when the project has not adopted BDD; the scanner is
77
+ * best-effort and never throws on filesystem errors.
78
+ *
79
+ * @returns {Array<object>}
80
+ */
81
+ function scanBddScenariosBestEffort() {
82
+ try {
83
+ const featureRoots = resolveFeatureRoots({ cwd: PROJECT_ROOT });
84
+ return scanBddScenarios({ featureRoots });
85
+ } catch (err) {
86
+ Logger.warn(`[plan-context] BDD scenario scan skipped: ${err.message}`);
87
+ return [];
88
+ }
89
+ }
90
+
98
91
  /**
99
92
  * Build the authoring context the host LLM (or the
100
93
  * `/plan` author step) needs to write the Tech Spec.
@@ -130,51 +123,54 @@ export async function buildAuthoringContext(
130
123
 
131
124
  const { cwd = PROJECT_ROOT } = opts;
132
125
 
133
- const docsContext = await buildPlanningDocsContext({
134
- seedIssueId: epic.id,
135
- settings,
136
- cwd,
137
- });
138
-
139
- // Story #2094 Task #2103 — verify the project's BDD runner pending-tag
140
- // support so the acceptance-spec body can record either the verified tag
141
- // (features-first ordering) or "fallback: dependencies-first ordering"
142
- // when no supported runner is present.
143
- const bddRunner = await verifyBddRunnerPendingTag({ cwd: PROJECT_ROOT });
144
-
145
- // Story #2637 — index existing BDD scenarios so the Acceptance Engineer
146
- // step can annotate planned ACs with matches from the project's
147
- // `.feature` files. Empty array when the project has not adopted BDD;
148
- // the scanner is best-effort and never throws on filesystem errors.
149
- let bddScenarios = [];
150
- try {
151
- const featureRoots = resolveFeatureRoots({ cwd: PROJECT_ROOT });
152
- bddScenarios = scanBddScenarios({ featureRoots });
153
- } catch (err) {
154
- Logger.warn(`[plan-context] BDD scenario scan skipped: ${err.message}`);
155
- }
156
-
157
- // Story #2557 — memory-freshness pre-flight runs BEFORE the prior-feedback
158
- // fetch so the planner sees a deduplicated, currently-actionable memory
159
- // store. The scanner is best-effort: missing memory dir or gh-CLI failures
160
- // land in `memoryFreshness.errors[]` and never throw.
161
126
  const githubCfg = opts.github ?? null;
162
- const memoryDir = resolveMemoryDir({ github: githubCfg });
163
- const memoryFreshness = await scanMemoryFreshness({
164
- memoryDir,
165
- owner: githubCfg?.owner,
166
- repo: githubCfg?.repo,
167
- projectRoot: PROJECT_ROOT,
168
- });
169
127
 
170
- // Story #2554surface open meta feedback issues to the planner so retro
171
- // signals are routed into durable substrates rather than lost in chat.
172
- // The fetcher is best-effort: missing owner/repo or gh-CLI failures land
173
- // in `errors[]` and never throw.
174
- const priorFeedback = await fetchPriorFeedback({
175
- owner: githubCfg?.owner,
176
- repo: githubCfg?.repo,
177
- });
128
+ // Story #4952these five gathers share no data, so they run under bounded
129
+ // concurrency instead of five sequential awaits on the interactive `/plan`
130
+ // path. `concurrentMap` preserves input order, so the destructuring is
131
+ // positional and the produced context is identical to the serial build:
132
+ //
133
+ // 1. the digest-first `docsContext` pointer (Story #4433);
134
+ // 2. Story #2094 Task #2103 — the project's BDD runner pending-tag
135
+ // support, so the acceptance-spec body records either the verified tag
136
+ // (features-first ordering) or "fallback: dependencies-first ordering"
137
+ // when no supported runner is present;
138
+ // 3. the best-effort `.feature` scenario index;
139
+ // 4. Story #4919 — the memory-pool advisory that replaced the retired
140
+ // memory-freshness pre-flight (#2557 / #4414) in the same slot. The
141
+ // scanner marked an entry stale when a cited issue was closed, but the
142
+ // memory corpus is delivery retrospectives whose subject IS a delivered
143
+ // Story — and its directory (`~/.claude/projects/<repo>/memory/`) never
144
+ // resolved, because harness project dirs are cwd-slugs. This renders no
145
+ // per-entry verdict at all: it stats and counts, and the `/plan` spine
146
+ // surfaces `recommend` at Gate #1. Filesystem-only and total;
147
+ // 5. Story #2554 — open meta feedback issues, so retro signals are routed
148
+ // into durable substrates rather than lost in chat. Best-effort:
149
+ // missing owner/repo or gh-CLI failures land in `errors[]`, never throw.
150
+ const [
151
+ docsContext,
152
+ bddRunner,
153
+ bddScenarios,
154
+ memoryPoolAdvisory,
155
+ priorFeedback,
156
+ ] = await concurrentMap(
157
+ [
158
+ () => buildPlanningDocsContext({ seedIssueId: epic.id, settings, cwd }),
159
+ () => verifyBddRunnerPendingTag({ cwd: PROJECT_ROOT }),
160
+ () => scanBddScenariosBestEffort(),
161
+ () => buildMemoryPoolAdvisory({ cwd: PROJECT_ROOT }),
162
+ () =>
163
+ fetchPriorFeedback({
164
+ owner: githubCfg?.owner,
165
+ repo: githubCfg?.repo,
166
+ }),
167
+ ],
168
+ (gather) => gather(),
169
+ // The independent context gathers (Story #4952): a handful of local
170
+ // probes plus one `gh` read, so this rides the shared fan-out bound
171
+ // rather than declaring its own.
172
+ { concurrency: FANOUT_CONCURRENCY },
173
+ );
178
174
 
179
175
  // Story #4811 — the codebase snapshot (#2634), its authoring grounding
180
176
  // (#4139 F10) and the spec-freshness helpers behind it are retired. The
@@ -211,7 +207,7 @@ export async function buildAuthoringContext(
211
207
  docsContext,
212
208
  bddRunner,
213
209
  bddScenarios,
214
- memoryFreshness,
210
+ memoryPoolAdvisory,
215
211
  priorFeedback,
216
212
  };
217
213
  }