mandrel 2.43.0 → 2.45.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.
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { withTransientRetry } from './errors.js';
20
+ import { paginateRest } from './request-helpers.js';
20
21
 
21
22
  /**
22
23
  * Detect the "label already exists" signal across the surfaces `gh label
@@ -60,6 +61,28 @@ export function isLabelAlreadyExistsError(err) {
60
61
  return false;
61
62
  }
62
63
 
64
+ /**
65
+ * Detect the "label does not exist" signal on the delete path. `gh api` exits
66
+ * non-zero on a 404 with `gh: Not Found (HTTP 404)` on stderr; the REST body
67
+ * carries `"message": "Not Found"`. Both are matched, and the numeric status
68
+ * is matched on its own so a transport that surfaces only `err.status` still
69
+ * classifies.
70
+ *
71
+ * Deliberately narrow: only a 404 counts. A 403 (scope) or a 422 must stay
72
+ * loud, because reading either as "already gone" would let a sweep report
73
+ * labels as reaped that are all still there.
74
+ *
75
+ * @param {unknown} err
76
+ * @returns {boolean}
77
+ */
78
+ export function isLabelNotFoundError(err) {
79
+ if (!err) return false;
80
+ if (err.status === 404 || err.statusCode === 404) return true;
81
+ return /\bHTTP\s+404\b|\bnot found\b/i.test(
82
+ `${err.message ?? ''} ${err.stderr ?? ''}`,
83
+ );
84
+ }
85
+
63
86
  export class LabelGateway {
64
87
  /**
65
88
  * @param {{ gh: object, owner: string, repo: string }} deps
@@ -162,6 +185,71 @@ export class LabelGateway {
162
185
  return missing;
163
186
  }
164
187
 
188
+ /**
189
+ * List the repository's whole label vocabulary, paginated.
190
+ *
191
+ * Deliberately NOT modelled on `_reconcileLabelsPresence`'s
192
+ * `gh label list --limit 500`. That hard cap is fine for its own job —
193
+ * "are these 20 bootstrap labels present?" — and fatal for this one: a
194
+ * caller deciding which labels to delete from a truncated view would skip
195
+ * exactly the labels that sort after an accumulated pile, which is the
196
+ * failure Story #5189 exists to stop reproducing. `paginateRest` walks
197
+ * pages until a short one lands and throws (loudly) rather than truncating
198
+ * if the repository somehow exceeds its ceiling.
199
+ *
200
+ * Rows are projected to the fields a caller can rely on; a row without a
201
+ * usable `name` is dropped rather than passed on as a deletable target.
202
+ *
203
+ * @returns {Promise<Array<{ name: string, color: string|null, description: string|null }>>}
204
+ * @field-manifest /repos/{owner}/{repo}/labels: name, color, description
205
+ */
206
+ async listLabels() {
207
+ const endpoint = `/repos/${this.owner}/${this.repo}/labels`;
208
+ const rows = await paginateRest(this._gh, endpoint, {
209
+ label: `listLabels ${this.owner}/${this.repo}`,
210
+ });
211
+ return (Array.isArray(rows) ? rows : [])
212
+ .filter((row) => typeof row?.name === 'string')
213
+ .map((row) => ({
214
+ name: row.name,
215
+ color: row.color ?? null,
216
+ description: row.description ?? null,
217
+ }));
218
+ }
219
+
220
+ /**
221
+ * Delete one label by name.
222
+ *
223
+ * Goes through the REST surface (`DELETE /repos/{owner}/{repo}/labels/{name}`)
224
+ * rather than `gh label delete`, so the "already gone" signal arrives as a
225
+ * structured 404 on the same facade every other read here uses instead of
226
+ * as CLI prose — and so this gateway needs no new verb on the `gh` facade.
227
+ *
228
+ * A missing label resolves as a successful no-op. That is not leniency: the
229
+ * sweep this port exists for is expected to run repeatedly and concurrently
230
+ * with the close-path reap, so "someone else already deleted it" is the
231
+ * normal case, not an error.
232
+ *
233
+ * @param {string} name
234
+ * @returns {Promise<{ deleted: boolean, reason: string|null }>}
235
+ */
236
+ async deleteLabel(name) {
237
+ if (typeof name !== 'string' || name.trim().length === 0) {
238
+ throw new Error('deleteLabel: a non-empty label name is required');
239
+ }
240
+ const endpoint = `/repos/${this.owner}/${this.repo}/labels/${encodeURIComponent(name)}`;
241
+ try {
242
+ await withTransientRetry(() =>
243
+ this._gh.api({ method: 'DELETE', endpoint }),
244
+ );
245
+ return { deleted: true, reason: null };
246
+ } catch (err) {
247
+ if (isLabelNotFoundError(err))
248
+ return { deleted: false, reason: 'not-found' };
249
+ throw err;
250
+ }
251
+ }
252
+
165
253
  _normalizeLabelListResult(result) {
166
254
  if (Array.isArray(result)) return result;
167
255
  if (result && typeof result.stdout === 'string') {
@@ -133,6 +133,8 @@ const DELEGATIONS = [
133
133
  ['getBranchProtection', 'branchProtection.getBranchProtection'],
134
134
  ['setBranchProtection', 'branchProtection.setBranchProtection'],
135
135
  ['ensureLabels', 'labels.ensureLabels'],
136
+ ['listLabels', 'labels.listLabels'],
137
+ ['deleteLabel', 'labels.deleteLabel'],
136
138
  ['_reconcileLabelsPresence', 'labels._reconcileLabelsPresence'],
137
139
  ['getMergeMethods', 'mergeMethods.getMergeMethods'],
138
140
  ['setMergeMethods', 'mergeMethods.setMergeMethods'],
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+
3
+ // .agents/scripts/prune-plan-run-labels.js — Story #5189.
4
+ //
5
+ // Sweep the repository's `plan-run::<id>` cohort labels and delete the ones
6
+ // that are provably spent.
7
+ //
8
+ // The close tail reaps incrementally — one Story's own labels, as it lands —
9
+ // which keeps a healthy repository flat but does nothing about a pile that
10
+ // already exists, and nothing about a cohort whose last Story was closed by
11
+ // hand rather than by a close. This is the surface that burns an accumulated
12
+ // pile down: it reads the whole label vocabulary through the paginating
13
+ // listing port, so what it can see is bounded by the repository rather than by
14
+ // an API page.
15
+ //
16
+ // The decision is not made here — `lib/orchestration/plan-run-labels/reap.js`
17
+ // owns it, so this sweep and the close-path reap cannot come to different
18
+ // conclusions about when a label is spent. In short: reapable means the label
19
+ // carries at least one issue and every one of them is closed. A label carrying
20
+ // zero issues is NOT reapable by default, because that shape is exactly what
21
+ // an in-flight `plan-persist` looks like between minting its label and
22
+ // creating its Stories; `--include-unreferenced` is the explicit opt-in for an
23
+ // operator who knows no persist is running.
24
+ //
25
+ // Exit codes:
26
+ // 0 nothing reapable (or, without `--check`, the reap was performed)
27
+ // 1 `--check` found at least one label that would be reaped
28
+ // 2 the sweep could not run
29
+
30
+ // Fail-fast if the framework's runtime deps are not installed — must be the
31
+ // first import so the check runs before any third-party-importing sibling
32
+ // module is evaluated (Story #3432).
33
+ import './lib/runtime-deps/ensure-installed.js';
34
+ import { runAsCli } from './lib/cli-utils.js';
35
+ import { resolveConfig } from './lib/config-resolver.js';
36
+ import { Logger } from './lib/Logger.js';
37
+ import {
38
+ REAP_REASONS,
39
+ sweepCohortLabels,
40
+ } from './lib/orchestration/plan-run-labels/reap.js';
41
+ import { createProvider } from './lib/provider-factory.js';
42
+
43
+ const EXIT_CLEAN = 0;
44
+ const EXIT_WOULD_REAP = 1;
45
+ const EXIT_CANNOT_RUN = 2;
46
+
47
+ const HELP = {
48
+ invocation:
49
+ 'node .agents/scripts/prune-plan-run-labels.js [--check] [--json] [--include-unreferenced] [--cwd <dir>]',
50
+ summary:
51
+ 'Delete the plan-run:: cohort labels whose Stories are all closed. Never touches any other label axis, and never changes how a cohort label is minted.',
52
+ flags: [
53
+ ['--check', 'Report what would be reaped, delete nothing, exit 1 if any.'],
54
+ ['--json', 'Emit the report as JSON instead of text.'],
55
+ [
56
+ '--include-unreferenced',
57
+ 'Also reap cohort labels carrying zero issues. Off by default: that shape is indistinguishable from a label an in-flight plan-persist just minted.',
58
+ ],
59
+ ['--cwd <dir>', 'Repository root to sweep. Default: process.cwd().'],
60
+ ],
61
+ notes: [
62
+ 'Reapable = the label carries at least one issue AND every issue carrying it\nis closed. One open issue anywhere in the cohort keeps the label.',
63
+ 'Exit codes:\n 0 clean, or reaped\n 1 --check found reapable labels\n 2 the sweep could not run',
64
+ ],
65
+ };
66
+
67
+ /**
68
+ * Parse argv into an options bag. An unknown flag is an error, never a silent
69
+ * no-op — a typo'd `--dry-run` must not read as "delete the labels".
70
+ *
71
+ * @param {string[]} argv
72
+ * @returns {{ check: boolean, json: boolean, includeUnreferenced: boolean, cwd: string }}
73
+ */
74
+ export function parseArgs(argv = []) {
75
+ const out = {
76
+ check: false,
77
+ json: false,
78
+ includeUnreferenced: false,
79
+ cwd: null,
80
+ };
81
+ let i = 0;
82
+ while (i < argv.length) {
83
+ const arg = argv[i];
84
+ const value = argv[i + 1];
85
+ i += 1;
86
+ if (arg === '--check') out.check = true;
87
+ else if (arg === '--json') out.json = true;
88
+ else if (arg === '--include-unreferenced') out.includeUnreferenced = true;
89
+ else if (arg === '--cwd') {
90
+ if (typeof value !== 'string' || value.length === 0) {
91
+ throw new Error('--cwd requires a directory');
92
+ }
93
+ out.cwd = value;
94
+ i += 1;
95
+ } else throw new Error(`unknown flag "${arg}" (try --help)`);
96
+ }
97
+ out.cwd = out.cwd ?? process.cwd();
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * One human-readable line per cohort label, naming the reason it was kept or
103
+ * reaped. The reason is printed for every label, not only the reapable ones —
104
+ * an operator auditing a pile of 235 needs to see why the other 234 stayed.
105
+ *
106
+ * @param {object} decision
107
+ * @param {boolean} check
108
+ * @returns {string}
109
+ */
110
+ function renderDecision(decision, check) {
111
+ if (!decision.reapable) {
112
+ const suffix =
113
+ decision.reason === REAP_REASONS.OPEN_STORIES
114
+ ? ` (open: ${decision.openIssues.join(', ') || 'unknown'})`
115
+ : '';
116
+ return ` · keep ${decision.label} — ${decision.reason}${suffix}`;
117
+ }
118
+ const verb = check ? 'would reap' : 'reaped';
119
+ return ` · ${verb} ${decision.label} — ${decision.reason} (${decision.issueCount} closed issue(s))`;
120
+ }
121
+
122
+ /**
123
+ * Render the whole report as text.
124
+ *
125
+ * @param {object} report
126
+ * @returns {string}
127
+ */
128
+ export function formatReport(report) {
129
+ const verb = report.check ? 'would reap' : 'reaped';
130
+ const count = report.check ? report.reapable.length : report.deleted.length;
131
+ const lines = [
132
+ `[prune-plan-run-labels] ${verb} ${count} of ${report.evaluated} cohort ` +
133
+ `label(s) (${report.totalLabels} label(s) in the repository)`,
134
+ ...report.decisions.map((d) => renderDecision(d, report.check)),
135
+ ];
136
+ for (const failure of report.failed) {
137
+ lines.push(` ! failed ${failure.label} — ${failure.detail}`);
138
+ }
139
+ if (report.check && report.reapable.length > 0) {
140
+ lines.push(
141
+ '',
142
+ 'Run without --check to delete them:',
143
+ ' node .agents/scripts/prune-plan-run-labels.js',
144
+ );
145
+ }
146
+ return lines.join('\n');
147
+ }
148
+
149
+ /**
150
+ * The whole sweep — argv in, exit code out, report written through `writeFn`.
151
+ *
152
+ * Exported with its provider/config/output surfaces as default-parameter
153
+ * seams (`rules/test-seams.md`) so a test drives the *real* argv parsing,
154
+ * report rendering and exit-code decision against a stub provider. Without
155
+ * that, the only testable thing here would be a re-implementation of the
156
+ * decision, which is precisely the copy that drifts.
157
+ *
158
+ * @param {string[]} argv
159
+ * @param {{
160
+ * createProviderFn?: Function,
161
+ * resolveConfigFn?: Function,
162
+ * writeFn?: (text: string) => void,
163
+ * warnFn?: (message: string) => void,
164
+ * }} [seams]
165
+ * @returns {Promise<number>} the process exit code.
166
+ */
167
+ export async function runSweep(
168
+ argv,
169
+ {
170
+ createProviderFn = createProvider,
171
+ resolveConfigFn = resolveConfig,
172
+ writeFn = (text) => process.stdout.write(text),
173
+ warnFn = (message) => Logger.warn(`[prune-plan-run-labels] ${message}`),
174
+ } = {},
175
+ ) {
176
+ let opts;
177
+ let report;
178
+ try {
179
+ opts = parseArgs(argv);
180
+ report = await sweepCohortLabels({
181
+ provider: createProviderFn(resolveConfigFn({ cwd: opts.cwd })),
182
+ includeUnreferenced: opts.includeUnreferenced,
183
+ check: opts.check,
184
+ onWarn: warnFn,
185
+ });
186
+ } catch (err) {
187
+ const message = err?.message ?? String(err);
188
+ writeFn(
189
+ `${JSON.stringify({ schemaVersion: '1', error: message }, null, 2)}\n`,
190
+ );
191
+ return EXIT_CANNOT_RUN;
192
+ }
193
+ writeFn(
194
+ opts.json
195
+ ? `${JSON.stringify(report, null, 2)}\n`
196
+ : `${formatReport(report)}\n`,
197
+ );
198
+ return report.check && report.reapable.length > 0
199
+ ? EXIT_WOULD_REAP
200
+ : EXIT_CLEAN;
201
+ }
202
+
203
+ /**
204
+ * CLI entry point. Returns its exit code rather than calling `process.exit()`,
205
+ * so `runAsCli`'s `propagateExitCode` path settles it through `flushStdio` and
206
+ * an unbounded report is not truncated at a pipe boundary (Story #4783).
207
+ *
208
+ * @returns {Promise<number>}
209
+ */
210
+ async function main() {
211
+ return runSweep(process.argv.slice(2));
212
+ }
213
+
214
+ runAsCli(import.meta.url, main, {
215
+ source: 'prune-plan-run-labels',
216
+ usage: HELP,
217
+ propagateExitCode: true,
218
+ });
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,26 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.45.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.44.0...mandrel-v2.45.0) (2026-09-07)
19
+
20
+
21
+ ### Fixed
22
+
23
+ * **git-cleanup:** drop the symbolic HEAD ref under its short spelling ([#5197](https://github.com/dsj1984/mandrel/issues/5197)) ([98802f6](https://github.com/dsj1984/mandrel/commit/98802f6ee49b699560ab7362815e054751f3024b))
24
+
25
+ ## [2.44.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.43.0...mandrel-v2.44.0) (2026-09-07)
26
+
27
+
28
+ ### Added
29
+
30
+ * reap a plan-run cohort label once every Story carrying it is closed ([#5189](https://github.com/dsj1984/mandrel/issues/5189)) ([#5194](https://github.com/dsj1984/mandrel/issues/5194)) ([43ecacb](https://github.com/dsj1984/mandrel/commit/43ecacb7e2fa8469813174623307149cfe59a60a))
31
+
32
+
33
+ ### Fixed
34
+
35
+ * **git-cleanup:** record an outcome for every remote-only branch, including the no-PR ones ([#5188](https://github.com/dsj1984/mandrel/issues/5188)) ([#5190](https://github.com/dsj1984/mandrel/issues/5190)) ([8016137](https://github.com/dsj1984/mandrel/commit/8016137e0c5e3b06fb608674cbb68a8fbf212ca8))
36
+ * **review:** disk-probe the scoped-lint code runner so an absent biome degrades by name (refs [#5193](https://github.com/dsj1984/mandrel/issues/5193)) ([#5195](https://github.com/dsj1984/mandrel/issues/5195)) ([cc4bb41](https://github.com/dsj1984/mandrel/commit/cc4bb41a5877366f7e4e488d6d522fcfa3c2f8cd))
37
+
18
38
  ## [2.43.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.42.0...mandrel-v2.43.0) (2026-09-07)
19
39
 
20
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.43.0",
3
+ "version": "2.45.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",