mandrel 2.44.0 → 2.46.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.
@@ -158,6 +158,35 @@ export class GhGraphqlError extends GhExecError {
158
158
  }
159
159
  }
160
160
 
161
+ /**
162
+ * Render a `gh` failure as one operator-legible line: the error message plus
163
+ * the reason `gh` itself printed.
164
+ *
165
+ * Every `GhExecError` carries the captured `stderr`, but the classified
166
+ * message keeps only the exit status — so a caller that logs `err.message`
167
+ * alone flattens every unclassified failure to `gh-exec: gh exited with code
168
+ * 1`. That is exactly what hid an HTTP 422 `description is too long` behind
169
+ * three identical "label ensure failed" warnings (Story #5201). `gh` prints
170
+ * the actionable sentence on the *last* stderr line — the leading lines are
171
+ * the `HTTP 422:` banner and the API URL — so that line is what is appended.
172
+ *
173
+ * Non-`GhExecError` inputs (a plain `Error` from a provider fake, a thrown
174
+ * string) degrade to their own message, so this is safe on any catch path.
175
+ *
176
+ * @param {unknown} err
177
+ * @returns {string} `"<message>: <gh stderr detail>"`, or just the message
178
+ * when no stderr was captured.
179
+ */
180
+ export function describeGhFailure(err) {
181
+ const message = String(err?.message ?? err ?? 'unknown error');
182
+ const lines = String(err?.stderr ?? '')
183
+ .split('\n')
184
+ .map((line) => line.trim())
185
+ .filter(Boolean);
186
+ const detail = lines.at(-1);
187
+ return detail ? `${message}: ${detail}` : message;
188
+ }
189
+
161
190
  /**
162
191
  * Classify a non-zero `gh` invocation into the most specific typed error
163
192
  * subclass available. Pure function — no side effects, no I/O.
@@ -49,6 +49,19 @@ export function listLocalBranches(cwd) {
49
49
  * Pure: strip the `<remote>/` prefix off a remote-ref listing and drop the
50
50
  * symbolic `HEAD` entry.
51
51
  *
52
+ * `HEAD` arrives under two spellings. `%(refname:lstrip=3)` renders
53
+ * `refs/remotes/origin/HEAD` as the literal `HEAD`, but
54
+ * `%(refname:short)` — what both callers ask for — shortens it to the bare
55
+ * remote name `origin`, because git keeps the remote segment only when the
56
+ * result would otherwise be ambiguous. Missing that second spelling leaked
57
+ * a phantom `origin` candidate into the remote-only sweep, which the reap
58
+ * phase then reported as `already gone`.
59
+ *
60
+ * Both spellings are rejected **before** the prefix strip, because the
61
+ * strip is what makes them ambiguous: a real branch named `origin` lists
62
+ * as `origin/origin` and strips to `origin` too. Discriminating on the raw
63
+ * line keeps that branch reapable while still dropping the symbolic ref.
64
+ *
52
65
  * Shared by {@link listRemoteBranches} and {@link listRemoteMergedBranches}
53
66
  * so both enumerations speak the same short-name vocabulary the planner's
54
67
  * remote-only walk keys on — a divergence there would make the ancestry
@@ -64,8 +77,9 @@ function shortRemoteNames(stdout, remoteName) {
64
77
  .split('\n')
65
78
  .map((l) => l.trim())
66
79
  .filter(Boolean)
80
+ .filter((l) => l !== 'HEAD' && l !== remoteName)
67
81
  .map((l) => (l.startsWith(prefix) ? l.slice(prefix.length) : l))
68
- .filter((b) => b && b !== 'HEAD');
82
+ .filter(Boolean);
69
83
  }
70
84
 
71
85
  /* node:coverage ignore next */
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { createHash } from 'node:crypto';
18
18
  import { linkStoriesToEpic } from '../../../providers/github/sub-issue-add.js';
19
+ import { describeGhFailure } from '../../gh-exec.js';
19
20
  import { Logger } from '../../Logger.js';
20
21
  import { LABEL_COLORS, TYPE_LABELS } from '../../label-constants.js';
21
22
  import { composeEpicBody } from '../epic-container.js';
@@ -103,8 +104,9 @@ async function ensureEpicLabel({ provider }) {
103
104
  return true;
104
105
  } catch (err) {
105
106
  Logger.warn(
106
- `[plan-persist] "${TYPE_LABELS.EPIC}" label ensure failed (${err.message}) — ` +
107
- 'skipping the container Epic. The Stories are unaffected and deliver by id.',
107
+ `[plan-persist] "${TYPE_LABELS.EPIC}" label ensure failed ` +
108
+ `(${describeGhFailure(err)}) — skipping the container Epic. ` +
109
+ 'The Stories are unaffected and deliver by id.',
108
110
  );
109
111
  return false;
110
112
  }
@@ -632,10 +632,20 @@ function logEffectiveRoute(route, isLiteRoute) {
632
632
  * Log the operator-facing persist epilogue: adoption, the ready primary, the
633
633
  * deliver command, and the cohort grouping label.
634
634
  *
635
- * @param {{ created: object[], primary: object, planRunLabel: string }} args
635
+ * @param {{
636
+ * created: object[],
637
+ * primary: object,
638
+ * planRunLabel: string,
639
+ * planRunLabelApplied: boolean,
640
+ * }} args
636
641
  * @returns {void}
637
642
  */
638
- function logPersistEpilogue({ created, primary, planRunLabel }) {
643
+ function logPersistEpilogue({
644
+ created,
645
+ primary,
646
+ planRunLabel,
647
+ planRunLabelApplied,
648
+ }) {
639
649
  const adopted = created.filter((story) => story.adopted);
640
650
  if (adopted.length > 0) {
641
651
  Logger.info(
@@ -652,10 +662,15 @@ function logPersistEpilogue({ created, primary, planRunLabel }) {
652
662
  );
653
663
  // Metadata only — a GitHub filter for the cohort this run authored, never
654
664
  // a delivery-resolution input (/mandrel-deliver stays ids-only, Story #4540).
655
- Logger.info(
656
- `[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
657
- `label:${planRunLabel}`,
658
- );
665
+ // Gated on the ensure result: the label ensure degrades non-fatally, and
666
+ // advertising a filter GitHub just refused to create is worse than saying
667
+ // nothing (Story #5201). The derived id survives in the result envelope.
668
+ if (planRunLabelApplied) {
669
+ Logger.info(
670
+ `[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
671
+ `label:${planRunLabel}`,
672
+ );
673
+ }
659
674
  }
660
675
 
661
676
  /**
@@ -800,11 +815,12 @@ export async function runPlanPersist({
800
815
  const isLiteRoute = route?.route === 'lite';
801
816
  logEffectiveRoute(route, isLiteRoute);
802
817
 
803
- const { created, planRunLabel } = await createStoryIssues({
804
- provider,
805
- stories,
806
- opts: { dryRun, routeLabel: isLiteRoute ? LITE_ROUTE_LABEL : null },
807
- });
818
+ const { created, planRunLabel, planRunLabelApplied } =
819
+ await createStoryIssues({
820
+ provider,
821
+ stories,
822
+ opts: { dryRun, routeLabel: isLiteRoute ? LITE_ROUTE_LABEL : null },
823
+ });
808
824
 
809
825
  const primary = created[0];
810
826
  const waveTable = buildWaveTable(
@@ -883,7 +899,12 @@ export async function runPlanPersist({
883
899
  supersede.sourceTicketOrigin = sourceTicketOrigin;
884
900
 
885
901
  await cleanupPlanDirs({ config, planDir, skipCleanup });
886
- logPersistEpilogue({ created, primary, planRunLabel });
902
+ logPersistEpilogue({
903
+ created,
904
+ primary,
905
+ planRunLabel,
906
+ planRunLabelApplied,
907
+ });
887
908
 
888
909
  return {
889
910
  stories: created,
@@ -22,6 +22,7 @@ import {
22
22
  ownedProvenanceSource,
23
23
  } from '../../findings/provenance-field.js';
24
24
  import { carryProvenanceFooters } from '../../findings/route-finding.js';
25
+ import { describeGhFailure } from '../../gh-exec.js';
25
26
  import { Logger } from '../../Logger.js';
26
27
  import { AGENT_LABELS, TYPE_LABELS } from '../../label-constants.js';
27
28
  import {
@@ -902,8 +903,8 @@ async function ensurePersistLabel({
902
903
  return true;
903
904
  } catch (err) {
904
905
  Logger.warn(
905
- `[plan-persist] ${role} label ensure failed (${err.message}) — ` +
906
- 'creating the Stories without it. Add the label by hand if you ' +
906
+ `[plan-persist] ${role} label ensure failed (${describeGhFailure(err)})` +
907
+ ' — creating the Stories without it. Add the label by hand if you ' +
907
908
  'want it.',
908
909
  );
909
910
  return false;
@@ -978,6 +979,7 @@ async function ensurePersistLabel({
978
979
  * created: Array<{ slug: string, id: number, url?: string, title: string, adopted: boolean }>,
979
980
  * dependencyEdges: { edgesAdded: number, edgesSkipped: number, edgesFailed: number, storiesProcessed: number }|null,
980
981
  * planRunLabel: string,
982
+ * planRunLabelApplied: boolean,
981
983
  * routeLabel: string|null,
982
984
  * }>}
983
985
  */
@@ -1012,6 +1014,10 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
1012
1014
  })),
1013
1015
  dependencyEdges: null,
1014
1016
  planRunLabel: cohortLabel,
1017
+ // A dry run writes nothing, so nothing was ensured and nothing carries
1018
+ // the label — the derived id is still reported, the application is not
1019
+ // claimed.
1020
+ planRunLabelApplied: false,
1015
1021
  routeLabel,
1016
1022
  };
1017
1023
  }
@@ -1021,8 +1027,8 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
1021
1027
  label: cohortLabel,
1022
1028
  color: PLAN_RUN_LABEL_COLOR,
1023
1029
  description:
1024
- 'Groups the Stories one /mandrel-plan persist run authored (metadata ' +
1025
- 'only — /mandrel-deliver stays ids-only).',
1030
+ 'Groups the Stories one /mandrel-plan persist run authored — ' +
1031
+ 'metadata only, never a deliver input.',
1026
1032
  role: 'cohort',
1027
1033
  });
1028
1034
  const applyRouteLabel =
@@ -1032,8 +1038,8 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
1032
1038
  label: routeLabel,
1033
1039
  color: LITE_ROUTE_LABEL_COLOR,
1034
1040
  description:
1035
- 'Ceremony-lite hint (human-visible only): /mandrel-deliver re-derives the ' +
1036
- 'route from the Story body shape; every close gate runs unchanged.',
1041
+ 'Ceremony-lite hint only: /mandrel-deliver re-derives the route; ' +
1042
+ 'every close gate still runs.',
1037
1043
  role: 'route-marker',
1038
1044
  }));
1039
1045
 
@@ -1111,7 +1117,12 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
1111
1117
  return {
1112
1118
  created,
1113
1119
  dependencyEdges,
1120
+ // The derived id and whether it actually landed are separate facts. The id
1121
+ // is reported either way (a resumed persist re-derives it, and the
1122
+ // dry-run path reports it write-free); the flag is what an epilogue must
1123
+ // consult before advertising a `label:` filter that may match nothing.
1114
1124
  planRunLabel: cohortLabel,
1125
+ planRunLabelApplied: applyCohortLabel,
1115
1126
  routeLabel: applyRouteLabel ? routeLabel : null,
1116
1127
  };
1117
1128
  }
@@ -83,6 +83,46 @@ export function isLabelNotFoundError(err) {
83
83
  );
84
84
  }
85
85
 
86
+ /**
87
+ * GitHub's cap on a label description. A longer one is refused with an HTTP
88
+ * 422 that `gh label create` reports as a bare exit 1 — a deterministic
89
+ * create failure, never a truncation. Lives here rather than in the
90
+ * provider-agnostic label vocabulary because it is an API constraint of this
91
+ * provider (Story #5201, where two over-long `plan-persist` descriptions made
92
+ * the `plan-run::<id>` cohort label uncreatable on every run).
93
+ */
94
+ // Module-private on purpose: nothing in production reads the number outside
95
+ // the guard below, and the suite pins GitHub's published cap as a literal
96
+ // rather than against our own constant.
97
+ const LABEL_DESCRIPTION_MAX_LENGTH = 100;
98
+
99
+ /**
100
+ * Refuse a label description GitHub will reject anyway, **before** `gh` is
101
+ * spawned.
102
+ *
103
+ * The API caps a description at {@link LABEL_DESCRIPTION_MAX_LENGTH}
104
+ * characters and answers a longer one with an HTTP 422 that `gh label create`
105
+ * surfaces as a bare exit 1 — legible only to a caller that digs the reason
106
+ * out of stderr. Failing at the call site instead names the label and its
107
+ * actual length, so the fix is obvious from the message alone.
108
+ *
109
+ * Deliberately a throw rather than a silent truncation: a truncated
110
+ * description is a label whose text nobody chose, and every `ensureLabels`
111
+ * caller already handles a throw — the bootstrap paths surface it, and
112
+ * `plan-persist` degrades to creating its Stories without the cosmetic label.
113
+ *
114
+ * @param {{ name?: string, description?: string }} def
115
+ * @throws {Error} when the description exceeds the cap.
116
+ */
117
+ function assertLabelDescriptionWithinCap(def) {
118
+ const description = def?.description ?? '';
119
+ if (description.length <= LABEL_DESCRIPTION_MAX_LENGTH) return;
120
+ throw new Error(
121
+ `label "${def?.name}" description is ${description.length} characters; ` +
122
+ `GitHub rejects anything over ${LABEL_DESCRIPTION_MAX_LENGTH}`,
123
+ );
124
+ }
125
+
86
126
  export class LabelGateway {
87
127
  /**
88
128
  * @param {{ gh: object, owner: string, repo: string }} deps
@@ -94,7 +134,10 @@ export class LabelGateway {
94
134
  }
95
135
 
96
136
  /**
97
- * Idempotent label creation. For each labelDef, attempt `gh label create
137
+ * Idempotent label creation. Each def's description is checked against
138
+ * GitHub's length cap first (`assertLabelDescriptionWithinCap`) so a
139
+ * guaranteed-422 create never reaches the network. Then, for each labelDef,
140
+ * attempt `gh label create
98
141
  * <name> --color <hex> --description <text>`. The CLI prints
99
142
  * "label already exists" (or the API surfaces a 422 "already_exists"
100
143
  * error) when the name is taken; we swallow that and count it as
@@ -114,6 +157,7 @@ export class LabelGateway {
114
157
  const created = [];
115
158
  const skipped = [];
116
159
  for (const def of labelDefs) {
160
+ assertLabelDescriptionWithinCap(def);
117
161
  const color = (def.color ?? '').replace(/^#/, '');
118
162
  try {
119
163
  await withTransientRetry(() =>
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,20 @@ 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.46.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.45.0...mandrel-v2.46.0) (2026-09-07)
19
+
20
+
21
+ ### Fixed
22
+
23
+ * keep every shipped label description under GitHub's 100-char limit, and make a refused ensure legible ([#5201](https://github.com/dsj1984/mandrel/issues/5201)) ([#5202](https://github.com/dsj1984/mandrel/issues/5202)) ([f444ac6](https://github.com/dsj1984/mandrel/commit/f444ac62255b25fc44603f5603f128083ae327fa))
24
+
25
+ ## [2.45.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.44.0...mandrel-v2.45.0) (2026-09-07)
26
+
27
+
28
+ ### Fixed
29
+
30
+ * **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))
31
+
18
32
  ## [2.44.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.43.0...mandrel-v2.44.0) (2026-09-07)
19
33
 
20
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.44.0",
3
+ "version": "2.46.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/",