mandrel 1.57.0 → 1.59.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 (131) hide show
  1. package/.agents/README.md +89 -87
  2. package/.agents/docs/SDLC.md +11 -7
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/audit-rules.json +20 -0
  5. package/.agents/scripts/acceptance-eval.js +20 -3
  6. package/.agents/scripts/assert-branch.js +1 -3
  7. package/.agents/scripts/bootstrap.js +1 -1
  8. package/.agents/scripts/check-arch-cycles.js +360 -0
  9. package/.agents/scripts/coverage-capture.js +24 -3
  10. package/.agents/scripts/epic-deliver-preflight.js +5 -3
  11. package/.agents/scripts/epic-deliver-prepare.js +12 -4
  12. package/.agents/scripts/epic-execute-record-wave.js +1 -1
  13. package/.agents/scripts/evidence-gate.js +1 -1
  14. package/.agents/scripts/git-rebase-and-resolve.js +1 -1
  15. package/.agents/scripts/hierarchy-gate.js +34 -14
  16. package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
  17. package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
  18. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
  19. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
  20. package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
  21. package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
  22. package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
  23. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
  24. package/.agents/scripts/lib/baselines/writer.js +1 -1
  25. package/.agents/scripts/lib/close-validation/commands.js +188 -0
  26. package/.agents/scripts/lib/close-validation/gates.js +235 -0
  27. package/.agents/scripts/lib/close-validation/process.js +101 -0
  28. package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
  29. package/.agents/scripts/lib/close-validation/runner.js +325 -0
  30. package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
  31. package/.agents/scripts/lib/config/quality.js +6 -6
  32. package/.agents/scripts/lib/config-resolver.js +2 -5
  33. package/.agents/scripts/lib/coverage-capture.js +147 -4
  34. package/.agents/scripts/lib/cpu-pool.js +14 -0
  35. package/.agents/scripts/lib/crap-utils.js +6 -11
  36. package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
  37. package/.agents/scripts/lib/git-utils.js +24 -22
  38. package/.agents/scripts/lib/maintainability-engine.js +1 -1
  39. package/.agents/scripts/lib/maintainability-utils.js +4 -187
  40. package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
  42. package/.agents/scripts/lib/orchestration/code-review.js +90 -77
  43. package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
  44. package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
  45. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
  46. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
  47. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
  50. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
  51. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
  52. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
  53. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
  54. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
  55. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
  56. package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
  57. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
  58. package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
  59. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
  60. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
  61. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
  62. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
  63. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
  64. package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
  65. package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
  67. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
  68. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
  69. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
  70. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +241 -0
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +16 -3
  73. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  74. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  75. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  76. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  77. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  78. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  79. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  80. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  81. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  82. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  83. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
  84. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  85. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  86. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  87. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  88. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
  89. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
  90. package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
  91. package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
  92. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  93. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  94. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
  95. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
  96. package/.agents/scripts/lib/project-root.js +17 -0
  97. package/.agents/scripts/lib/story-adjacency.js +76 -0
  98. package/.agents/scripts/lib/story-lifecycle.js +1 -1
  99. package/.agents/scripts/lib/transpile.js +93 -0
  100. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  101. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  102. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  103. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  104. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  105. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  106. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  107. package/.agents/scripts/providers/github/tickets.js +110 -6
  108. package/.agents/scripts/run-lint.js +9 -0
  109. package/.agents/scripts/run-tests.js +24 -4
  110. package/.agents/scripts/stories-wave-tick.js +8 -5
  111. package/.agents/scripts/story-init.js +149 -10
  112. package/.agents/scripts/sync-branch-from-base.js +1 -1
  113. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  114. package/.agents/workflows/audit-documentation.md +226 -0
  115. package/.agents/workflows/epic-deliver.md +16 -23
  116. package/.agents/workflows/epic-plan.md +1 -1
  117. package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
  118. package/.agents/workflows/helpers/single-story-deliver.md +2 -1
  119. package/.agents/workflows/onboard.md +4 -3
  120. package/.agents/workflows/story-deliver.md +1 -1
  121. package/README.md +21 -8
  122. package/lib/cli/init.js +336 -0
  123. package/package.json +2 -1
  124. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  125. package/.agents/scripts/lib/close-validation.js +0 -897
  126. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  127. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  128. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  129. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  130. package/.agents/scripts/lib/task-utils.js +0 -26
  131. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -32,6 +32,7 @@
32
32
  */
33
33
 
34
34
  import { spawnSync } from 'node:child_process';
35
+ import { parseProviderFindings } from './parse-findings.js';
35
36
  import { renderDepthDirective } from './review-depth.js';
36
37
 
37
38
  /**
@@ -138,65 +139,12 @@ export function mapSecurityReviewSeverity(raw) {
138
139
  * @throws {Error} when stdout is not parseable JSON.
139
140
  */
140
141
  export function parseSecurityReviewFindings(rawStdout) {
141
- const text = (rawStdout ?? '').trim();
142
- if (text.length === 0) return [];
143
-
144
- let parsed;
145
- try {
146
- parsed = JSON.parse(text);
147
- } catch (err) {
148
- throw new Error(
149
- `[security-review] Failed to parse /security-review stdout as JSON: ${
150
- err?.message ?? err
151
- }`,
152
- );
153
- }
154
-
155
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
156
- if (Array.isArray(parsed.findings)) parsed = parsed.findings;
157
- else if (parsed.result !== undefined) parsed = parsed.result;
158
- else if (parsed.data !== undefined) parsed = parsed.data;
159
- }
160
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
161
- if (Array.isArray(parsed.findings)) parsed = parsed.findings;
162
- }
163
-
164
- if (!Array.isArray(parsed)) return [];
165
-
166
- /** @type {Finding[]} */
167
- const findings = [];
168
- for (const entry of parsed) {
169
- if (!entry || typeof entry !== 'object') continue;
170
- const title =
171
- typeof entry.title === 'string' && entry.title.trim().length > 0
172
- ? entry.title.trim()
173
- : null;
174
- const body =
175
- typeof entry.body === 'string' && entry.body.trim().length > 0
176
- ? entry.body
177
- : typeof entry.message === 'string' && entry.message.trim().length > 0
178
- ? entry.message
179
- : null;
180
- if (!title || !body) continue;
181
- /** @type {Finding} */
182
- const finding = {
183
- severity: mapSecurityReviewSeverity(entry.severity),
184
- title,
185
- body,
186
- category:
187
- typeof entry.category === 'string' && entry.category.length > 0
188
- ? entry.category
189
- : 'security',
190
- };
191
- if (typeof entry.file === 'string' && entry.file.length > 0) {
192
- finding.file = entry.file;
193
- }
194
- if (Number.isInteger(entry.line) && entry.line > 0) {
195
- finding.line = entry.line;
196
- }
197
- findings.push(finding);
198
- }
199
- return findings;
142
+ return parseProviderFindings(rawStdout, {
143
+ errorPrefix:
144
+ '[security-review] Failed to parse /security-review stdout as JSON',
145
+ mapSeverity: mapSecurityReviewSeverity,
146
+ defaultCategory: 'security',
147
+ });
200
148
  }
201
149
 
202
150
  /**
@@ -22,10 +22,8 @@
22
22
  * that mock the upstream module URLs.
23
23
  */
24
24
 
25
- import {
26
- buildDefaultGates as defaultBuildDefaultGates,
27
- runCloseValidation as defaultRunCloseValidation,
28
- } from '../../../close-validation.js';
25
+ import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-validation/gates.js';
26
+ import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
29
27
  import { Logger } from '../../../Logger.js';
30
28
 
31
29
  /**
@@ -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
+ }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import path from 'node:path';
12
12
  import { parseSprintArgs } from '../../../cli-args.js';
13
- import { PROJECT_ROOT } from '../../../config-resolver.js';
13
+ import { PROJECT_ROOT } from '../../../project-root.js';
14
14
 
15
15
  /**
16
16
  * Resolve a flag value from an explicit override, a parsed CLI arg, or a
@@ -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
  '',
@@ -1,9 +1,7 @@
1
1
  import nodeFs from 'node:fs';
2
2
  import path from 'node:path';
3
- import {
4
- buildDefaultGates,
5
- runCloseValidation,
6
- } from '../../close-validation.js';
3
+ import { buildDefaultGates } from '../../close-validation/gates.js';
4
+ import { runCloseValidation } from '../../close-validation/runner.js';
7
5
  import { resolveConfig } from '../../config-resolver.js';
8
6
  import { getStoryBranch, gitSync } from '../../git-utils.js';
9
7
  import { Logger } from '../../Logger.js';
@@ -39,16 +39,20 @@
39
39
  */
40
40
 
41
41
  import {
42
- acquireLease,
43
- normalizeOperatorHandle,
44
- releaseLease,
45
- } from './ticket-lease.js';
42
+ acquireLeaseFailClosed,
43
+ resolveOperatorFromCandidates,
44
+ } from './lease-guard-shared.js';
45
+ import { releaseLease } from './ticket-lease.js';
46
46
 
47
47
  /**
48
48
  * Resolve the operator handle used as the lease owner from resolved config.
49
- * Routes through the shared `normalizeOperatorHandle` so a leading `@` is
49
+ * Routes through the shared lease-guard kernel
50
+ * (`lease-guard-shared.resolveOperatorFromCandidates`) so a leading `@` is
50
51
  * stripped (the assignees API expects bare logins, not `@`-prefixed mentions)
51
- * and the self-held-claim comparison matches.
52
+ * and the self-held-claim comparison matches. The standalone surface's
53
+ * missing-handle policy is `'throw'` (intentional divergence from the plan
54
+ * path's `'null'`): init has no best-effort leg that can degrade, so an
55
+ * unowned lease must refuse immediately.
52
56
  *
53
57
  * @param {object} config Resolved `.agentrc.json` config.
54
58
  * @returns {string} Bare operator handle.
@@ -58,17 +62,16 @@ import {
58
62
  * path cannot safely serialise concurrent runs.
59
63
  */
60
64
  export function resolveOperator(config) {
61
- const handle = normalizeOperatorHandle(config?.github?.operatorHandle);
62
- if (handle === null) {
63
- throw new Error(
65
+ return resolveOperatorFromCandidates({
66
+ candidates: [config?.github?.operatorHandle],
67
+ missingHandleBehavior: 'throw',
68
+ missingHandleMessage:
64
69
  'single-story lease: no operator identity is configured. ' +
65
- 'github.operatorHandle is unset or still the shipped `@[USERNAME]` ' +
66
- 'placeholder, so the standalone Story lease has no owner. Set your own ' +
67
- 'handle in .agentrc.local.json (e.g. { "github": { "operatorHandle": ' +
68
- '"@your-login" } }) and re-run.',
69
- );
70
- }
71
- return handle;
70
+ 'github.operatorHandle is unset or still the shipped `@[USERNAME]` ' +
71
+ 'placeholder, so the standalone Story lease has no owner. Set your own ' +
72
+ 'handle in .agentrc.local.json (e.g. { "github": { "operatorHandle": ' +
73
+ '"@your-login" } }) and re-run.',
74
+ });
72
75
  }
73
76
 
74
77
  /**
@@ -99,31 +102,25 @@ export async function acquireStoryLease({
99
102
  now,
100
103
  }) {
101
104
  const owner = operator ?? resolveOperator(config);
102
- // Fail closed: with no live-heartbeat source on the standalone path, treat a
103
- // foreign assignee as a live claim. Anchoring `heartbeatAt` to the same
104
- // `now` the primitive evaluates against makes `isClaimLive` return true for
105
- // any foreign owner, so `acquireLease` refuses unless `steal` is set.
106
- const resolvedNow =
107
- typeof now === 'number' && Number.isFinite(now) ? now : Date.now();
108
- const result = await acquireLease({
105
+ // Fail closed: with no live-heartbeat source on the standalone path, the
106
+ // shared kernel anchors `heartbeatAt` to the same `now` the primitive
107
+ // evaluates against, so `isClaimLive` returns true for any foreign owner
108
+ // and `acquireLease` refuses unless `steal` is set.
109
+ return acquireLeaseFailClosed({
109
110
  provider,
110
111
  ticketId: storyId,
111
112
  operator: owner,
112
- heartbeatAt: resolvedNow,
113
113
  steal,
114
114
  config,
115
- now: resolvedNow,
116
- });
117
- if (!result.acquired) {
118
- throw new Error(
115
+ now,
116
+ anchorHeartbeatToNow: true,
117
+ renderRefusal: (result) =>
119
118
  `single-story lease: Story #${storyId} is currently held by @${result.owner}. ` +
120
- 'Another /single-story-deliver run owns this Story. Coordinate with that ' +
121
- 'operator, or re-run with --steal to forcibly transfer the claim once you ' +
122
- 'have confirmed the other run is dead. (The standalone path has no Epic ' +
123
- 'heartbeat ledger, so a foreign assignee always blocks unless stolen.)',
124
- );
125
- }
126
- return result;
119
+ 'Another /single-story-deliver run owns this Story. Coordinate with that ' +
120
+ 'operator, or re-run with --steal to forcibly transfer the claim once you ' +
121
+ 'have confirmed the other run is dead. (The standalone path has no Epic ' +
122
+ 'heartbeat ledger, so a foreign assignee always blocks unless stolen.)',
123
+ });
127
124
  }
128
125
 
129
126
  /**
@@ -12,9 +12,8 @@
12
12
 
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
-
16
- import { PROJECT_ROOT } from '../config-resolver.js';
17
15
  import { Logger } from '../Logger.js';
16
+ import { PROJECT_ROOT } from '../project-root.js';
18
17
 
19
18
  const POLICY_HEADING_RE = /^## Policy Capsule\s*$/;
20
19
  const ANY_H2_RE = /^## /;