mandrel 1.57.0 → 1.58.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.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * normalize-pr-title.js — guarantee the standalone-Story PR title is a
3
+ * valid Conventional Commit subject so the squash-merge subject on `main`
4
+ * parses for release-please.
5
+ *
6
+ * Story #3969 (framework gap). The repo squash-merges, and GitHub uses the
7
+ * PR title as the squash-commit subject. `buildPullRequest` previously
8
+ * emitted the raw human issue title (`<storyTitle> (#<id>)`), which is a
9
+ * plain description ("Rename the published npm package…") that
10
+ * release-please's Conventional-Commit parser rejects:
11
+ *
12
+ * ❯ commit could not be parsed: … Rename the published npm package …
13
+ * ❯ error: unexpected token ' ' at 1:7, valid tokens [(, !, :]
14
+ * ❯ commits: 0 → no release cut
15
+ *
16
+ * The `commit-msg` commitlint Husky hook only validates *local* commits and
17
+ * never runs on a GitHub-UI squash-merge title, so nothing mechanized the
18
+ * documented "author the PR title in conventional form" contract. This
19
+ * module mechanizes it.
20
+ *
21
+ * Contract (pure where possible — the only side effect is an injectable
22
+ * `git log` read used to derive the type):
23
+ *
24
+ * - If `storyTitle` is **already** a parseable Conventional Commit
25
+ * subject, it is preserved verbatim and suffixed with `(#<storyId>)`.
26
+ * No re-prefixing, no double type.
27
+ * - Otherwise the title is **synthesized** into conventional form:
28
+ * `<type>: <descriptive text> (#<storyId>)`. The `type` is derived
29
+ * from the branch's own (already-conventional) commit subjects when
30
+ * available, falling back to a safe configured default (`chore`).
31
+ *
32
+ * Mirrors the already-normalized Epic-finalize default in
33
+ * `lib/orchestration/finalize/open-or-locate-pr.js` (`feat: Epic #<id>`),
34
+ * bringing the standalone path to the same guarantee.
35
+ */
36
+
37
+ import { gitSpawn as defaultGitSpawn } from '../../../git-utils.js';
38
+ import { Logger as DefaultLogger } from '../../../Logger.js';
39
+
40
+ /** Safe default Conventional-Commit type when none can be derived. */
41
+ export const DEFAULT_CONVENTIONAL_TYPE = 'chore';
42
+
43
+ /**
44
+ * The Conventional-Commit types Mandrel accepts. Mirrors
45
+ * `commitlint.config.js` → `type-enum` and `release-please-config.json` →
46
+ * `changelog-sections`. Kept in sync by hand (single hard-cutover, no
47
+ * shim) — adding a type means touching all three.
48
+ */
49
+ export const CONVENTIONAL_TYPES = Object.freeze([
50
+ 'feat',
51
+ 'fix',
52
+ 'perf',
53
+ 'refactor',
54
+ 'revert',
55
+ 'docs',
56
+ 'style',
57
+ 'chore',
58
+ 'test',
59
+ 'build',
60
+ 'ci',
61
+ ]);
62
+
63
+ // Precedence used when a branch carries a mix of conventional types: pick
64
+ // the most release-significant one so the squash subject communicates the
65
+ // branch's headline impact (and release-please bumps appropriately).
66
+ const TYPE_PRECEDENCE = Object.freeze([
67
+ 'feat',
68
+ 'fix',
69
+ 'perf',
70
+ 'refactor',
71
+ 'revert',
72
+ 'docs',
73
+ 'style',
74
+ 'test',
75
+ 'build',
76
+ 'ci',
77
+ 'chore',
78
+ ]);
79
+
80
+ const TYPE_GROUP = CONVENTIONAL_TYPES.join('|');
81
+ // Anchored Conventional-Commit header matcher:
82
+ // <type>(<optional scope>)<optional !>: <non-empty description>
83
+ // Mirrors the shape `@commitlint/config-conventional` enforces (a known
84
+ // type, an optional parenthesised scope, an optional breaking `!`, a
85
+ // colon-space separator, and a non-empty subject). Used for the pure
86
+ // "is this already conventional?" check and to pull the type off a branch
87
+ // commit subject without spawning commitlint per call.
88
+ const CONVENTIONAL_HEADER_RE = new RegExp(
89
+ `^(?:${TYPE_GROUP})(?:\\([^()\\r\\n]+\\))?!?: \\S.*$`,
90
+ );
91
+ const LEADING_TYPE_RE = new RegExp(
92
+ `^(${TYPE_GROUP})(?:\\([^()\\r\\n]+\\))?!?:`,
93
+ );
94
+
95
+ /**
96
+ * True iff `subject` is a parseable Conventional Commit subject under the
97
+ * repo's type vocabulary. Pure.
98
+ *
99
+ * @param {string} subject
100
+ * @returns {boolean}
101
+ */
102
+ export function isConventionalSubject(subject) {
103
+ if (typeof subject !== 'string') return false;
104
+ return CONVENTIONAL_HEADER_RE.test(subject.trim());
105
+ }
106
+
107
+ /**
108
+ * Extract the Conventional-Commit `type` from a single commit subject, or
109
+ * `null` when the subject is not conventional. Pure.
110
+ *
111
+ * @param {string} subject
112
+ * @returns {string|null}
113
+ */
114
+ export function parseConventionalType(subject) {
115
+ if (typeof subject !== 'string') return null;
116
+ const match = subject.trim().match(LEADING_TYPE_RE);
117
+ return match ? match[1] : null;
118
+ }
119
+
120
+ /**
121
+ * Pick the most release-significant type from a list of conventional
122
+ * types, honouring `TYPE_PRECEDENCE`. Returns `null` for an empty list.
123
+ * Pure.
124
+ *
125
+ * @param {string[]} types
126
+ * @returns {string|null}
127
+ */
128
+ export function pickDominantType(types) {
129
+ const present = new Set(types.filter(Boolean));
130
+ for (const candidate of TYPE_PRECEDENCE) {
131
+ if (present.has(candidate)) return candidate;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * Read the branch's own commit subjects (commits unique to the Story
138
+ * branch relative to the base branch) and derive the dominant
139
+ * Conventional-Commit type. Returns `DEFAULT_CONVENTIONAL_TYPE` when no
140
+ * conventional subject is found or the git read fails.
141
+ *
142
+ * @param {{
143
+ * storyBranch: string,
144
+ * baseBranch: string,
145
+ * cwd?: string,
146
+ * gitSpawn?: typeof defaultGitSpawn,
147
+ * logger?: { warn?: Function },
148
+ * }} args
149
+ * @returns {string}
150
+ */
151
+ export function deriveTypeFromBranchCommits({
152
+ storyBranch,
153
+ baseBranch,
154
+ cwd = process.cwd(),
155
+ gitSpawn = defaultGitSpawn,
156
+ logger = DefaultLogger,
157
+ }) {
158
+ try {
159
+ const range = `${baseBranch}..${storyBranch}`;
160
+ const result = gitSpawn(cwd, 'log', '--no-merges', '--format=%s', range);
161
+ if (!result || result.status !== 0) {
162
+ logger?.warn?.(
163
+ `[normalize-pr-title] git log ${range} failed (status=${result?.status ?? 'n/a'}); ` +
164
+ `defaulting type to "${DEFAULT_CONVENTIONAL_TYPE}".`,
165
+ );
166
+ return DEFAULT_CONVENTIONAL_TYPE;
167
+ }
168
+ const types = String(result.stdout ?? '')
169
+ .split('\n')
170
+ .map((line) => parseConventionalType(line))
171
+ .filter(Boolean);
172
+ return pickDominantType(types) ?? DEFAULT_CONVENTIONAL_TYPE;
173
+ } catch (err) {
174
+ logger?.warn?.(
175
+ `[normalize-pr-title] could not derive type from branch commits ` +
176
+ `(defaulting to "${DEFAULT_CONVENTIONAL_TYPE}"): ${err?.message ?? err}`,
177
+ );
178
+ return DEFAULT_CONVENTIONAL_TYPE;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Produce a PR title that parses as a Conventional Commit.
184
+ *
185
+ * - Already-conventional `storyTitle` → preserved verbatim + `(#<id>)`.
186
+ * - Otherwise → `<derivedType>: <storyTitle> (#<id>)`.
187
+ * - Empty / missing `storyTitle` → `<derivedType>: Story #<id>`.
188
+ *
189
+ * The type derivation (`deriveTypeFromBranchCommits`) is the only side
190
+ * effect, and is skipped entirely when the title is already conventional.
191
+ *
192
+ * @param {{
193
+ * storyTitle: string,
194
+ * storyId: number|string,
195
+ * storyBranch?: string,
196
+ * baseBranch?: string,
197
+ * cwd?: string,
198
+ * gitSpawn?: typeof defaultGitSpawn,
199
+ * logger?: { warn?: Function },
200
+ * }} args
201
+ * @returns {string}
202
+ */
203
+ export function normalizePrTitle({
204
+ storyTitle,
205
+ storyId,
206
+ storyBranch,
207
+ baseBranch,
208
+ cwd = process.cwd(),
209
+ gitSpawn = defaultGitSpawn,
210
+ logger = DefaultLogger,
211
+ }) {
212
+ const idSuffix = `(#${storyId})`;
213
+ const trimmed = typeof storyTitle === 'string' ? storyTitle.trim() : '';
214
+
215
+ // Already conventional → preserve verbatim, append the id reference.
216
+ if (isConventionalSubject(trimmed)) {
217
+ return `${trimmed} ${idSuffix}`;
218
+ }
219
+
220
+ // Not conventional → derive a type and synthesize.
221
+ const type =
222
+ storyBranch && baseBranch
223
+ ? deriveTypeFromBranchCommits({
224
+ storyBranch,
225
+ baseBranch,
226
+ cwd,
227
+ gitSpawn,
228
+ logger,
229
+ })
230
+ : DEFAULT_CONVENTIONAL_TYPE;
231
+
232
+ // Lowercase the leading character of a synthesized description so the
233
+ // subject satisfies commitlint's `subject-case` rule (matching the
234
+ // `shapeMergeSubject` behaviour). An already-conventional title is left
235
+ // untouched (it was preserved verbatim above). The empty-title fallback
236
+ // uses a lowercased `story #<id>` for the same reason.
237
+ const rawDescription = trimmed.length > 0 ? trimmed : `Story #${storyId}`;
238
+ const description =
239
+ rawDescription.charAt(0).toLowerCase() + rawDescription.slice(1);
240
+ return `${type}: ${description} ${idSuffix}`;
241
+ }
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { gh as defaultGh } from '../../../gh-exec.js';
21
21
  import { Logger } from '../../../Logger.js';
22
+ import { normalizePrTitle } from './normalize-pr-title.js';
22
23
 
23
24
  /**
24
25
  * Probe for an existing open PR with `head = storyBranch`; create one if
@@ -76,9 +77,21 @@ export async function ensurePullRequestWith({
76
77
  }
77
78
 
78
79
  progress('PR', `Opening PR for ${storyBranch} → ${baseBranch}...`);
79
- const title = storyTitle?.trim()
80
- ? `${storyTitle} (#${storyId})`
81
- : `Story #${storyId}`;
80
+ // The repo squash-merges and GitHub uses the PR title as the squash
81
+ // subject on `main`. A raw human issue title is not a Conventional
82
+ // Commit, so release-please silently counts it as 0 releasable commits
83
+ // (Story #3969). Normalize the title to conventional form: preserve an
84
+ // already-conventional `storyTitle` verbatim, otherwise synthesize a
85
+ // type derived from the branch's own commit subjects (default `chore`).
86
+ // `gh-exec` spawns `gh` against the current process cwd (the worktree),
87
+ // so the branch-commit read uses the same cwd.
88
+ const title = normalizePrTitle({
89
+ storyTitle,
90
+ storyId,
91
+ storyBranch,
92
+ baseBranch,
93
+ cwd: _cwd ?? process.cwd(),
94
+ });
82
95
  const body = [
83
96
  `Closes #${storyId}`,
84
97
  '',
package/README.md CHANGED
@@ -145,6 +145,14 @@ npx mandrel sync # re-materialize ./.agents/
145
145
  npx mandrel doctor # verify the install
146
146
  ```
147
147
 
148
+ ### Migrating from `@mandrelai/agents`
149
+
150
+ The framework package was renamed from the scoped `@mandrelai/agents` to the
151
+ unscoped `mandrel`. Already on the old name? `mandrel update` does **not**
152
+ auto-migrate (it resolves the package by name), so make the one-time manual
153
+ hop documented in
154
+ [`docs/migrate-mandrelai-to-mandrel.md`](docs/migrate-mandrelai-to-mandrel.md).
155
+
148
156
  ## Contributors
149
157
 
150
158
  Only `.agents/` is distributed to consumers — it ships inside the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.57.0",
3
+ "version": "1.58.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
5
  "main": "index.js",
6
6
  "files": [