mandrel 2.25.0 → 2.27.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 (132) 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/reader.js +52 -38
  41. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  42. package/.agents/scripts/lib/baselines/scope.js +39 -90
  43. package/.agents/scripts/lib/baselines/writer.js +16 -11
  44. package/.agents/scripts/lib/changed-files.js +8 -1
  45. package/.agents/scripts/lib/cli-args.js +115 -1
  46. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  47. package/.agents/scripts/lib/crap-engine.js +32 -13
  48. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  49. package/.agents/scripts/lib/crap-utils.js +13 -0
  50. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  51. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  53. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  54. package/.agents/scripts/lib/git-utils.js +136 -80
  55. package/.agents/scripts/lib/import-graph.js +156 -0
  56. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  57. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  58. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  62. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  64. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  65. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  66. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  67. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  68. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  69. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  70. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  71. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  72. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  73. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  74. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  78. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  79. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  80. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  81. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  82. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  83. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  84. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  85. package/.agents/scripts/lib/story-adjacency.js +3 -3
  86. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  87. package/.agents/scripts/lib/test-tiers.js +11 -2
  88. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  89. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  90. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  91. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  92. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  93. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  94. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  96. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  97. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  98. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  99. package/.agents/scripts/lib/worktree-manager.js +14 -0
  100. package/.agents/scripts/plan-run-epilogue.js +17 -5
  101. package/.agents/scripts/providers/github/tickets.js +33 -10
  102. package/.agents/scripts/provision-git-hooks.js +85 -0
  103. package/.agents/scripts/quality-preview.js +112 -28
  104. package/.agents/scripts/resolve-stories.js +4 -1
  105. package/.agents/scripts/run-coverage.js +86 -35
  106. package/.agents/scripts/run-lint.js +20 -0
  107. package/.agents/scripts/run-tests.js +26 -36
  108. package/.agents/scripts/single-story-close.js +28 -2
  109. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  110. package/.agents/scripts/stories-wave-tick.js +214 -38
  111. package/.agents/scripts/update-coverage-baseline.js +34 -4
  112. package/.agents/scripts/update-duplication-baseline.js +209 -83
  113. package/.agents/scripts/validate-docs-freshness.js +1 -0
  114. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  115. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  116. package/.agents/skills/skills.index.json +2 -2
  117. package/.agents/workflows/audit-baselines.md +289 -0
  118. package/.agents/workflows/audit-navigability.md +5 -4
  119. package/.agents/workflows/deliver.md +13 -4
  120. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  121. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  122. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  123. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  124. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  125. package/.agents/workflows/helpers/plan-reference.md +15 -5
  126. package/.agents/workflows/memory-consolidate.md +116 -0
  127. package/.agents/workflows/plan.md +3 -0
  128. package/README.md +13 -6
  129. package/docs/CHANGELOG.md +71 -0
  130. package/package.json +9 -4
  131. package/.agents/schemas/friction-event.schema.json +0 -56
  132. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -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
  {
@@ -282,15 +282,33 @@ export async function readPrWaitProbe({
282
282
  * explicit `maxWaitSecondsOverride` still wins over the async cap — a headless
283
283
  * caller with no host ceiling opts back into single-block waiting.
284
284
  *
285
+ * `modeOverride` is the per-invocation `--merge-watch-mode` flag (Story #4949)
286
+ * and wins over `delivery.mergeWatch.mode` on exactly the precedence
287
+ * `maxWaitSecondsOverride` already uses. It exists because run topology is
288
+ * knowable only to the orchestrator: close sees one Story and cannot tell a
289
+ * solo delivery (where a foreground wait is the cheapest ending) from the Nth
290
+ * close of a wave (where each foreground wait is serialized dead time). The
291
+ * config default therefore stays `sync`, and the caller that knows better says
292
+ * so per invocation. The two flags remain composable — `--merge-watch-mode
293
+ * async --max-wait-seconds 900` selects the async posture and then overrides
294
+ * its probe cap, because the cap check below keys on the override's presence,
295
+ * not on where the mode came from.
296
+ *
285
297
  * @param {object} [config]
286
298
  * @param {number} [maxWaitSecondsOverride]
299
+ * @param {'sync'|'async'} [modeOverride]
287
300
  * @returns {{ mode: 'sync'|'async', intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
288
301
  */
289
- export function resolveMergeWaitConfig(config, maxWaitSecondsOverride) {
302
+ export function resolveMergeWaitConfig(
303
+ config,
304
+ maxWaitSecondsOverride,
305
+ modeOverride,
306
+ ) {
290
307
  const mergeWatch = config?.delivery?.mergeWatch ?? {};
291
308
  const int = (value, fallback, min = 1) =>
292
309
  Number.isInteger(value) && value >= min ? value : fallback;
293
- const mode = mergeWatch.mode === 'async' ? 'async' : 'sync';
310
+ const requestedMode = modeOverride ?? mergeWatch.mode;
311
+ const mode = requestedMode === 'async' ? 'async' : 'sync';
294
312
  const configuredMaxWait = int(
295
313
  maxWaitSecondsOverride,
296
314
  int(mergeWatch.maxWaitSeconds, DEFAULT_MAX_WAIT_SECONDS),
@@ -759,6 +777,9 @@ async function onMergeObserved({
759
777
  * @param {string|null} args.autoMergeReason
760
778
  * @param {object} args.provider
761
779
  * @param {object} [args.config]
780
+ * @param {'sync'|'async'} [args.mergeWatchMode] Per-invocation
781
+ * `--merge-watch-mode` override (Story #4949); wins over
782
+ * `delivery.mergeWatch.mode`.
762
783
  * @param {(tag: string, msg: string) => void} [args.progress]
763
784
  * @param {object} [args.injectedGh]
764
785
  * @param {Function} [args.injectedNotify]
@@ -791,6 +812,7 @@ export async function runConfirmMergePhase({
791
812
  provider,
792
813
  config,
793
814
  maxWaitSeconds: maxWaitSecondsOverride,
815
+ mergeWatchMode: mergeWatchModeOverride,
794
816
  progress,
795
817
  injectedGh,
796
818
  injectedNotify,
@@ -833,7 +855,11 @@ export async function runConfirmMergePhase({
833
855
  maxWaitSeconds,
834
856
  maxBudgetSeconds,
835
857
  updateAttempts,
836
- } = resolveMergeWaitConfig(config, maxWaitSecondsOverride);
858
+ } = resolveMergeWaitConfig(
859
+ config,
860
+ maxWaitSecondsOverride,
861
+ mergeWatchModeOverride,
862
+ );
837
863
  const intervalMs = intervalSeconds * 1000;
838
864
  const startedAtMs = nowMsFn();
839
865
  let anchorMs = startedAtMs;
@@ -38,7 +38,7 @@ import { gitSpawn as defaultGitSpawn } from '../../../git-utils.js';
38
38
  import { Logger as DefaultLogger } from '../../../Logger.js';
39
39
 
40
40
  /** Safe default Conventional-Commit type when none can be derived. */
41
- export const DEFAULT_CONVENTIONAL_TYPE = 'chore';
41
+ const DEFAULT_CONVENTIONAL_TYPE = 'chore';
42
42
 
43
43
  /**
44
44
  * The Conventional-Commit types Mandrel accepts. Mirrors
@@ -46,7 +46,7 @@ export const DEFAULT_CONVENTIONAL_TYPE = 'chore';
46
46
  * `changelog-sections`. Kept in sync by hand (single hard-cutover, no
47
47
  * shim) — adding a type means touching all three.
48
48
  */
49
- export const CONVENTIONAL_TYPES = Object.freeze([
49
+ const CONVENTIONAL_TYPES = Object.freeze([
50
50
  'feat',
51
51
  'fix',
52
52
  'perf',
@@ -99,7 +99,7 @@ const LEADING_TYPE_RE = new RegExp(
99
99
  * @param {string} subject
100
100
  * @returns {boolean}
101
101
  */
102
- export function isConventionalSubject(subject) {
102
+ function isConventionalSubject(subject) {
103
103
  if (typeof subject !== 'string') return false;
104
104
  return CONVENTIONAL_HEADER_RE.test(subject.trim());
105
105
  }
@@ -111,7 +111,7 @@ export function isConventionalSubject(subject) {
111
111
  * @param {string} subject
112
112
  * @returns {string|null}
113
113
  */
114
- export function parseConventionalType(subject) {
114
+ function parseConventionalType(subject) {
115
115
  if (typeof subject !== 'string') return null;
116
116
  const match = subject.trim().match(LEADING_TYPE_RE);
117
117
  return match ? match[1] : null;
@@ -125,7 +125,7 @@ export function parseConventionalType(subject) {
125
125
  * @param {string[]} types
126
126
  * @returns {string|null}
127
127
  */
128
- export function pickDominantType(types) {
128
+ function pickDominantType(types) {
129
129
  const present = new Set(types.filter(Boolean));
130
130
  for (const candidate of TYPE_PRECEDENCE) {
131
131
  if (present.has(candidate)) return candidate;
@@ -148,7 +148,7 @@ export function pickDominantType(types) {
148
148
  * }} args
149
149
  * @returns {string}
150
150
  */
151
- export function deriveTypeFromBranchCommits({
151
+ function deriveTypeFromBranchCommits({
152
152
  storyBranch,
153
153
  baseBranch,
154
154
  cwd = process.cwd(),
@@ -9,23 +9,27 @@
9
9
  */
10
10
 
11
11
  import path from 'node:path';
12
- import { parseSprintArgs } from '../../../cli-args.js';
12
+ import { parseMergeWatchMode, parseSprintArgs } from '../../../cli-args.js';
13
13
  import { getDeliveryRouting } from '../../../config/delivery-routing.js';
14
14
  import { PROJECT_ROOT } from '../../../project-root.js';
15
15
  import { isOperatorMergeReason } from './auto-merge.js';
16
16
 
17
17
  /**
18
- * Resolve a flag value from an explicit override, a parsed CLI arg, or a
19
- * hard default.
18
+ * Resolve a flag value from an explicit override or a parsed CLI arg.
19
+ *
20
+ * Returns `undefined` when neither is supplied — that absence is itself the
21
+ * answer here, letting each caller below apply its own default (a `!!` coerce,
22
+ * a config lookup, or a deliberate `undefined` passed further down). The
23
+ * former third `defaultValue` parameter was dropped in Story #4961: no call
24
+ * site passed it, so it documented a mode nobody used.
20
25
  *
21
26
  * @template T
22
27
  * @param {T|undefined} paramValue
23
28
  * @param {T|undefined} parsedValue
24
- * @param {T} defaultValue
25
- * @returns {T}
29
+ * @returns {T|undefined}
26
30
  */
27
- function resolveFlag(paramValue, parsedValue, defaultValue) {
28
- return paramValue ?? parsedValue ?? defaultValue;
31
+ function resolveFlag(paramValue, parsedValue) {
32
+ return paramValue ?? parsedValue;
29
33
  }
30
34
 
31
35
  /**
@@ -87,8 +91,8 @@ export function resolveWaitForMerge({
87
91
  * (`waitForMergeExplicit` / `noWaitForMerge`) for the runner to resolve once
88
92
  * the config and the arm outcome exist.
89
93
  *
90
- * @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, waitForMergeParam, noWaitForMergeParam, maxWaitSecondsParam }} raw
91
- * @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, waitForMergeExplicit, noWaitForMerge, maxWaitSeconds }}
94
+ * @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, waitForMergeParam, noWaitForMergeParam, maxWaitSecondsParam, mergeWatchModeParam }} raw
95
+ * @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, waitForMergeExplicit, noWaitForMerge, maxWaitSeconds, mergeWatchMode }}
92
96
  */
93
97
  export function parseCloseOptions({
94
98
  storyIdParam,
@@ -99,26 +103,27 @@ export function parseCloseOptions({
99
103
  waitForMergeParam,
100
104
  noWaitForMergeParam,
101
105
  maxWaitSecondsParam,
106
+ mergeWatchModeParam,
102
107
  }) {
103
- const parsed =
104
- storyIdParam !== undefined
105
- ? {
106
- storyId: storyIdParam,
107
- cwd: cwdParam ?? null,
108
- skipValidation: !!skipValidationParam,
109
- skipSync: !!skipSyncParam,
110
- noAutoMerge: !!noAutoMergeParam,
111
- // Preserve undefined so resolveWaitForMerge can apply the
112
- // closeAndLand config default when neither flag was injected.
113
- waitForMerge: waitForMergeParam,
114
- noWaitForMerge: !!noWaitForMergeParam,
115
- maxWaitSeconds: maxWaitSecondsParam,
116
- }
117
- : parseSprintArgs();
118
- const waitForMergeExplicit = waitForMergeParam ?? parsed.waitForMerge;
119
- const maxWaitSeconds = maxWaitSecondsParam ?? parsed.maxWaitSeconds;
108
+ // An injecting caller (`storyIdParam` supplied) is not reading argv at all,
109
+ // so there is nothing to parse and `parsed` stays empty. This used to build a
110
+ // stand-in object that copied every `*Param` into the slot of the same name —
111
+ // which is precisely what `resolveFlag` already does below, preferring the
112
+ // param over the parsed slot. One expression per flag now serves both
113
+ // callers, so a new flag is added in one place instead of two that can drift.
114
+ const parsed = storyIdParam === undefined ? parseSprintArgs() : {};
115
+ // Preserve undefined so resolveWaitForMerge can apply the closeAndLand
116
+ // config default when neither flag was supplied.
117
+ const waitForMergeExplicit = resolveFlag(
118
+ waitForMergeParam,
119
+ parsed.waitForMerge,
120
+ );
121
+ const maxWaitSeconds = resolveFlag(
122
+ maxWaitSecondsParam,
123
+ parsed.maxWaitSeconds,
124
+ );
120
125
  return {
121
- storyId: parsed.storyId,
126
+ storyId: resolveFlag(storyIdParam, parsed.storyId),
122
127
  cwd: path.resolve(cwdParam ?? parsed.cwd ?? PROJECT_ROOT),
123
128
  // `undefined` when unsupplied — the merge wait then reads
124
129
  // `delivery.mergeWatch.maxWaitSeconds`. A per-run override exists so a
@@ -128,21 +133,20 @@ export function parseCloseOptions({
128
133
  Number.isInteger(maxWaitSeconds) && maxWaitSeconds > 0
129
134
  ? maxWaitSeconds
130
135
  : undefined,
131
- skipValidation: resolveFlag(
132
- skipValidationParam,
133
- parsed.skipValidation,
134
- false,
136
+ // `undefined` when unsupplied — the merge wait then reads
137
+ // `delivery.mergeWatch.mode`. The two merge-watch flags stay composable and
138
+ // mode-agnostic: `--merge-watch-mode async` picks the posture, and an
139
+ // explicit `--max-wait-seconds` still wins over that posture's probe cap.
140
+ mergeWatchMode: parseMergeWatchMode(
141
+ resolveFlag(mergeWatchModeParam, parsed.mergeWatchMode),
135
142
  ),
136
- skipSync: resolveFlag(skipSyncParam, parsed.skipSync, false),
137
- noAutoMerge: resolveFlag(noAutoMergeParam, parsed.noAutoMerge, false),
143
+ skipValidation: !!resolveFlag(skipValidationParam, parsed.skipValidation),
144
+ skipSync: !!resolveFlag(skipSyncParam, parsed.skipSync),
145
+ noAutoMerge: !!resolveFlag(noAutoMergeParam, parsed.noAutoMerge),
138
146
  waitForMergeExplicit:
139
147
  typeof waitForMergeExplicit === 'boolean'
140
148
  ? waitForMergeExplicit
141
149
  : undefined,
142
- noWaitForMerge: resolveFlag(
143
- noWaitForMergeParam,
144
- parsed.noWaitForMerge,
145
- false,
146
- ),
150
+ noWaitForMerge: !!resolveFlag(noWaitForMergeParam, parsed.noWaitForMerge),
147
151
  };
148
152
  }
@@ -42,7 +42,12 @@ export function pushStoryBranch({
42
42
  }) {
43
43
  progress('GIT', `Pushing ${storyBranch} to origin...`);
44
44
  try {
45
- gitSync(cwd, 'push', '--no-verify', '-u', 'origin', storyBranch);
45
+ // No hook-bypass flag here, deliberately. Close runs its own gate chain
46
+ // before this point, but `--skip-validation` skips that chain, and the
47
+ // bypass then left nothing running at all. `pre-push` is the backstop,
48
+ // and it only became reachable once hooks were materialized into
49
+ // worktrees — which is where every Story branch is built.
50
+ gitSync(cwd, 'push', '-u', 'origin', storyBranch);
46
51
  progress('GIT', `✅ Pushed ${storyBranch}.`);
47
52
  } catch (err) {
48
53
  throw new Error(