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.
- package/.agents/agents/acceptance-critic.md +10 -6
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +3 -1
- package/.agents/instructions.md +1 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +8 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +89 -6
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +38 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +26 -4
- package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -11
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/crap-engine.js +32 -13
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +13 -0
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
- package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
- package/.agents/scripts/lib/workers/crap-worker.js +2 -10
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +112 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +26 -36
- package/.agents/scripts/single-story-close.js +28 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/stories-wave-tick.js +214 -38
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/deliver.md +13 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +77 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
- package/.agents/workflows/helpers/plan-reference.md +15 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +3 -0
- package/README.md +13 -6
- package/docs/CHANGELOG.md +64 -0
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- 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 =
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
*
|
|
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
|
-
* @
|
|
25
|
-
* @returns {T}
|
|
29
|
+
* @returns {T|undefined}
|
|
26
30
|
*/
|
|
27
|
-
function resolveFlag(paramValue, parsedValue
|
|
28
|
-
return paramValue ?? parsedValue
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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(
|