mandrel 2.24.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -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
  }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * memory-pool-advisory.js — the `/plan` Phase 0 memory-hygiene advisory.
3
+ *
4
+ * Replaces the retired memory-freshness pre-flight (Story #2557 / #4414) in
5
+ * the same slot, fixing both of that design's defects:
6
+ *
7
+ * 1. **Correct pool resolution.** The retired `resolveMemoryDir` built
8
+ * `~/.claude/projects/<github.repo>/memory/`, but harness project
9
+ * directories are **cwd-slugs** — the absolute cwd with every `/` and `.`
10
+ * replaced by `-` — so the old path never resolved in any consumer and
11
+ * the scan was a silent no-op everywhere.
12
+ * 2. **A named consumer.** The retired scanner emitted a per-entry staleness
13
+ * verdict nothing read. This emits one advisory the `/plan` spine
14
+ * surfaces at Gate #1, recommending `/memory-consolidate`.
15
+ *
16
+ * It also drops the semantic that made the old scanner unfixable: it renders
17
+ * **no per-entry verdict at all**. A memory citing a closed issue is a
18
+ * delivery retrospective whose subject is that issue — not a stale entry — and
19
+ * only the attended `/memory-consolidate` pass, reading content, can tell the
20
+ * difference. This module counts and stats; it never judges an entry.
21
+ *
22
+ * Detection is filesystem-only — no child processes, no `gh` probes, no
23
+ * network. Every failure path fails soft to "no pool, no recommendation": the
24
+ * advisory can degrade the nudge, never a plan.
25
+ *
26
+ * Test seams: `cwd`, `env`, `fsImpl` (node:fs-compatible `statSync` /
27
+ * `readdirSync` / `readFileSync`), `now`, and the two thresholds.
28
+ *
29
+ * `buildMemoryPoolAdvisory` is the **only** export: the helpers below have no
30
+ * caller outside this module, and exporting one solely for a test would add a
31
+ * row to the `dead-exports-production` ratchet (the `buildUiSurfaceSignal`
32
+ * precedent). Tests reach every branch through the seams above — do not
33
+ * "fix" the missing exports.
34
+ */
35
+
36
+ import * as defaultFs from 'node:fs';
37
+ import * as os from 'node:os';
38
+ import * as path from 'node:path';
39
+
40
+ /** Recommend a consolidation pass once the stamp is this old. */
41
+ const STALE_AFTER_DAYS = 30;
42
+
43
+ /** Recommend a consolidation pass once the pool holds more entries than this. */
44
+ const ENTRY_COUNT_CEILING = 100;
45
+
46
+ /** Stamp file written by `/memory-consolidate` after its operator gate. */
47
+ const STAMP_FILENAME = '.consolidation-stamp.json';
48
+
49
+ /** The index file is not itself a memory entry. */
50
+ const INDEX_FILENAME = 'MEMORY.md';
51
+
52
+ const MS_PER_DAY = 86_400_000;
53
+
54
+ /**
55
+ * Slugify an absolute path the way the harness names its per-project
56
+ * directories: every `/` and `.` becomes `-`. Verified against real
57
+ * directories in `~/.claude/projects/` — a plain checkout and a worktree both
58
+ * round-trip exactly.
59
+ *
60
+ * @param {string} absPath
61
+ * @returns {string}
62
+ */
63
+ function slugifyProjectPath(absPath) {
64
+ return String(absPath ?? '').replace(/[/.]/g, '-');
65
+ }
66
+
67
+ /**
68
+ * Resolve the memory pool directory for a working directory.
69
+ *
70
+ * `MANDREL_MEMORY_DIR` wins outright (operator override and test seam);
71
+ * otherwise `~/.claude/projects/<cwd-slug>/memory/`.
72
+ *
73
+ * @param {{ cwd?: string, env?: Record<string,string|undefined>, homedir?: string }} [opts]
74
+ * @returns {string|null} absolute pool path, or `null` when unresolvable
75
+ */
76
+ function resolveMemoryPoolDir({ cwd, env = process.env, homedir } = {}) {
77
+ const override = env?.MANDREL_MEMORY_DIR;
78
+ if (typeof override === 'string' && override.length > 0) return override;
79
+
80
+ const base = typeof cwd === 'string' && cwd.length > 0 ? cwd : null;
81
+ if (!base) return null;
82
+
83
+ const home =
84
+ typeof homedir === 'string' && homedir.length > 0 ? homedir : os.homedir();
85
+ if (!home) return null;
86
+
87
+ return path.join(
88
+ home,
89
+ '.claude',
90
+ 'projects',
91
+ slugifyProjectPath(base),
92
+ 'memory',
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Read the consolidation stamp, returning its ISO timestamp or `null`.
98
+ * A missing, unreadable, unparseable, or malformed stamp is indistinguishable
99
+ * from "never consolidated" — all four mean the same thing to the advisory.
100
+ *
101
+ * @returns {string|null}
102
+ */
103
+ function readStamp({ poolDir, fsImpl }) {
104
+ try {
105
+ const raw = fsImpl.readFileSync(path.join(poolDir, STAMP_FILENAME), 'utf8');
106
+ const parsed = JSON.parse(raw);
107
+ const value = parsed?.lastConsolidatedAt;
108
+ if (typeof value !== 'string' || value.length === 0) return null;
109
+ return Number.isNaN(Date.parse(value)) ? null : value;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Count memory entries — `.md` files other than the index.
117
+ *
118
+ * @returns {number|null} `null` when the directory cannot be listed
119
+ */
120
+ function countEntries({ poolDir, fsImpl }) {
121
+ try {
122
+ return fsImpl
123
+ .readdirSync(poolDir)
124
+ .filter((name) => name.endsWith('.md') && name !== INDEX_FILENAME).length;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Build the `memoryPoolAdvisory` envelope field.
132
+ *
133
+ * Advisory only — it carries **no routing authority**, mirroring
134
+ * `deliverLightSuggestion`. The `/plan` spine surfaces `recommend` at Gate #1;
135
+ * nothing auto-runs, and nothing here mutates the operator's memory store.
136
+ *
137
+ * @param {object} [opts]
138
+ * @param {string} [opts.cwd] — defaults to `process.cwd()`
139
+ * @param {Record<string,string|undefined>} [opts.env]
140
+ * @param {object} [opts.fsImpl] — node:fs-compatible seam
141
+ * @param {string} [opts.homedir]
142
+ * @param {Date|string|number} [opts.now]
143
+ * @param {number} [opts.staleAfterDays]
144
+ * @param {number} [opts.entryCountCeiling]
145
+ * @returns {{ present: boolean, entryCount: number, lastConsolidatedAt: string|null,
146
+ * recommend: boolean, reasons: string[] }}
147
+ */
148
+ export function buildMemoryPoolAdvisory({
149
+ cwd = process.cwd(),
150
+ env = process.env,
151
+ fsImpl = defaultFs,
152
+ homedir,
153
+ now = new Date(),
154
+ staleAfterDays = STALE_AFTER_DAYS,
155
+ entryCountCeiling = ENTRY_COUNT_CEILING,
156
+ } = {}) {
157
+ const absent = (reason) => ({
158
+ present: false,
159
+ entryCount: 0,
160
+ lastConsolidatedAt: null,
161
+ recommend: false,
162
+ reasons: [reason],
163
+ });
164
+
165
+ const poolDir = resolveMemoryPoolDir({ cwd, env, homedir });
166
+ if (!poolDir) {
167
+ return absent(
168
+ 'no memory pool could be resolved for this working directory',
169
+ );
170
+ }
171
+
172
+ let isDir = false;
173
+ try {
174
+ isDir = fsImpl.statSync(poolDir).isDirectory();
175
+ } catch {
176
+ isDir = false;
177
+ }
178
+ if (!isDir) {
179
+ return absent(`no memory pool at ${poolDir} — nothing to consolidate`);
180
+ }
181
+
182
+ const entryCount = countEntries({ poolDir, fsImpl });
183
+ if (entryCount === null) {
184
+ return absent(`memory pool at ${poolDir} could not be listed`);
185
+ }
186
+
187
+ const lastConsolidatedAt = readStamp({ poolDir, fsImpl });
188
+ const reasons = [];
189
+
190
+ // An empty pool has nothing to consolidate, whatever the stamp says.
191
+ if (entryCount === 0) {
192
+ return {
193
+ present: true,
194
+ entryCount: 0,
195
+ lastConsolidatedAt,
196
+ recommend: false,
197
+ reasons: ['memory pool is empty — nothing to consolidate'],
198
+ };
199
+ }
200
+
201
+ if (lastConsolidatedAt === null) {
202
+ reasons.push(
203
+ 'no consolidation stamp — this pool has never been consolidated',
204
+ );
205
+ } else {
206
+ const ageDays =
207
+ (new Date(now).getTime() - Date.parse(lastConsolidatedAt)) / MS_PER_DAY;
208
+ if (ageDays > staleAfterDays) {
209
+ reasons.push(
210
+ `last consolidated ${Math.floor(ageDays)} days ago (over the ${staleAfterDays}-day threshold)`,
211
+ );
212
+ }
213
+ }
214
+
215
+ if (entryCount > entryCountCeiling) {
216
+ reasons.push(
217
+ `${entryCount} entries (over the ${entryCountCeiling}-entry threshold)`,
218
+ );
219
+ }
220
+
221
+ return {
222
+ present: true,
223
+ entryCount,
224
+ lastConsolidatedAt,
225
+ recommend: reasons.length > 0,
226
+ reasons:
227
+ reasons.length > 0
228
+ ? reasons
229
+ : ['memory pool is within both freshness thresholds'],
230
+ };
231
+ }
@@ -34,6 +34,7 @@ import {
34
34
  extractChangePaths,
35
35
  parse as parseStoryBody,
36
36
  } from '../story-body/story-body.js';
37
+ import { expandIdList } from '../util/parse-id-list.js';
37
38
  import { resolveStoryDispatchMode } from './complexity-gate.js';
38
39
 
39
40
  /** Labels/state that mean a blocker no longer gates its dependents. */
@@ -355,29 +356,29 @@ export function buildStoriesEnvelope({
355
356
  }
356
357
 
357
358
  /**
358
- * Parse and validate the `--ids` list.
359
+ * Parse and validate the `--ids` list, expanding any `A-B` dash range.
360
+ *
361
+ * A contiguous span is how an operator names a plan run — `/deliver 4922 -
362
+ * 4926` — so the range is expanded here rather than transcribed by the host.
363
+ * `stories-wave-tick.js --stories` reads through this same function, which is
364
+ * what keeps the sequencing set identical to the resolved one.
359
365
  *
360
366
  * @param {string|undefined} raw
367
+ * @param {string} [flag] Flag name, for the error message.
361
368
  * @returns {number[]}
362
369
  */
363
- export function parseIds(raw) {
364
- const ids = String(raw ?? '')
365
- .split(',')
366
- .map((s) => s.trim())
367
- .filter(Boolean)
368
- .map((s) => {
369
- const n = Number.parseInt(s, 10);
370
- if (!Number.isInteger(n) || n <= 0 || String(n) !== s) {
371
- throw new Error(
372
- `[resolve-stories] --ids must be a comma-separated list of positive issue numbers (got "${s}").`,
373
- );
374
- }
375
- return n;
376
- });
370
+ export function parseIds(raw, flag = '--ids') {
371
+ const { ids, error } = expandIdList(raw, {
372
+ flag,
373
+ prefix: '[resolve-stories] ',
374
+ });
375
+ if (error) {
376
+ throw new Error(error);
377
+ }
377
378
  if (ids.length === 0) {
378
379
  throw new Error(
379
- '[resolve-stories] --ids is required: node resolve-stories.js --ids 101,102',
380
+ `[resolve-stories] ${flag} is required: node resolve-stories.js --ids 101,102 (or a range: --ids 101-104)`,
380
381
  );
381
382
  }
382
- return [...new Set(ids)];
383
+ return ids;
383
384
  }
@@ -494,6 +494,18 @@ async function executeAuditRoster({
494
494
  ? selectedAudits.map((lens) => `- \`${lens}\``)
495
495
  : ['- _(none — docs-only or no matching change-set lenses)_']),
496
496
  '',
497
+ // Story #4949 — the roster used to name the lenses and say nothing about
498
+ // how to dispatch them, which made a serial walk (and a nested
499
+ // coordinator) fully compliant with it. The lenses are read-only and share
500
+ // no write paths, so they are the textbook independent fan-out; naming the
501
+ // shape here is what turns that from an option into the instruction.
502
+ '**Dispatch shape (MUST): flat, parallel, one turn.** Spawn one ' +
503
+ '`auditor` sub-agent per lens listed above and issue every one of those ' +
504
+ 'spawns in a SINGLE turn — no nested fan-out, no serial walk. A ' +
505
+ 'coordinator sub-agent that re-dispatches the lenses is the failure ' +
506
+ 'this line exists to prevent: a grandchild routes its findings to the ' +
507
+ 'wrong parent or loses them outright.',
508
+ '',
497
509
  '```json',
498
510
  JSON.stringify(
499
511
  {