mandrel 2.8.0 → 2.10.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 (73) hide show
  1. package/.agents/agents/.markdownlint.json +4 -0
  2. package/.agents/agents/acceptance-critic.md +30 -5
  3. package/.agents/agents/auditor.md +36 -19
  4. package/.agents/agents/plan-critic.md +31 -5
  5. package/.agents/agents/story-worker.md +91 -100
  6. package/.agents/docs/configuration.md +39 -1
  7. package/.agents/docs/execution-reference.md +13 -0
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +131 -265
  10. package/.agents/rules/git-conventions.md +47 -83
  11. package/.agents/rules/orchestration-error-handling.md +28 -0
  12. package/.agents/schemas/agentrc.schema.json +44 -1
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +1 -1
  15. package/.agents/scripts/apply-quality-bootstrap.js +1 -1
  16. package/.agents/scripts/audit-to-stories.js +51 -0
  17. package/.agents/scripts/check-test-temp-hygiene.js +438 -0
  18. package/.agents/scripts/deliver-recover.js +23 -6
  19. package/.agents/scripts/lib/audit-suite/index.js +5 -0
  20. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
  21. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
  23. package/.agents/scripts/lib/config/temp-paths.js +121 -1
  24. package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
  25. package/.agents/scripts/lib/config-settings-schema.js +32 -0
  26. package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
  27. package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
  28. package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
  29. package/.agents/scripts/lib/observability/terse-result.js +114 -0
  30. package/.agents/scripts/lib/orchestration/complexity-gate.js +318 -0
  31. package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
  32. package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
  34. package/.agents/scripts/lib/orchestration/plan-context.js +60 -0
  35. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
  36. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
  37. package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
  38. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
  39. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
  40. package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
  41. package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
  42. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
  43. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
  44. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
  45. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
  46. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
  47. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
  48. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
  50. package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
  51. package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
  52. package/.agents/scripts/lib/story-body/story-body.js +18 -2
  53. package/.agents/scripts/lib/templates/decomposer-prompts.js +29 -6
  54. package/.agents/scripts/lib/test-env.js +65 -0
  55. package/.agents/scripts/plan-context.js +66 -9
  56. package/.agents/scripts/plan-critics.js +115 -3
  57. package/.agents/scripts/plan-persist.js +11 -1
  58. package/.agents/scripts/plan-run-epilogue.js +1 -1
  59. package/.agents/scripts/providers/github/issues.js +54 -7
  60. package/.agents/scripts/providers/github/search-budget.js +124 -0
  61. package/.agents/scripts/providers/github/search-query.js +71 -0
  62. package/.agents/scripts/single-story-confirm-merge.js +79 -10
  63. package/.agents/scripts/single-story-init.js +19 -3
  64. package/.agents/scripts/stories-wave-tick.js +1 -1
  65. package/.agents/scripts/sync-branch-from-base.js +9 -3
  66. package/.agents/workflows/deliver.md +86 -230
  67. package/.agents/workflows/helpers/deliver-reference.md +167 -0
  68. package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
  69. package/.agents/workflows/helpers/deliver-story.md +114 -422
  70. package/.agents/workflows/helpers/plan-reference.md +211 -0
  71. package/.agents/workflows/plan.md +107 -279
  72. package/docs/CHANGELOG.md +47 -0
  73. package/package.json +1 -1
@@ -23,6 +23,42 @@
23
23
  * no-op. `--delete-branch` is preserved verbatim, so the PR head branch is
24
24
  * still deleted on merge without depending on the repo's auto-delete
25
25
  * setting. Resolution is non-fatal — it degrades to the original cwd.
26
+ *
27
+ * Story #4681 made the arm survive a LOCAL-ONLY cleanup failure. Against an
28
+ * already-mergeable PR, `gh pr merge --auto --squash --delete-branch` merges
29
+ * immediately and then shells out to local `git` to drop the head branch.
30
+ * When the per-Story worktree still holds `story-<id>`, that local delete
31
+ * fails (`Cannot delete branch 'story-<id>' used by worktree at …`) and `gh`
32
+ * exits non-zero — even though the REMOTE merge already landed. Reporting
33
+ * that as an arm failure sent close's confirm phase straight to
34
+ * `blockOnUnlanded`, flipping a merged Story to a stale `agent::blocked` that
35
+ * only a hand-run `single-story-confirm-merge.js` could undo. The failure is
36
+ * now classified: a local-cleanup-only signature reports the arm as ENABLED
37
+ * with `localCleanupDeferred: true`, so the confirm phase polls the PR
38
+ * (observes MERGED) and the post-land tail reaps the local ref. Every other
39
+ * non-zero exit — a genuinely refused REMOTE merge — keeps the pre-existing
40
+ * `enabled: false` → blocked behaviour verbatim.
41
+ *
42
+ * Story #4682 restored the direct-merge fallback the v2.0.0 Story-only cutover
43
+ * dropped (originally PR #4480 / Story #4472, in the retired `AutomergeArmer`).
44
+ * GitHub native auto-merge (`gh pr merge --auto`) can only be QUEUED on a repo
45
+ * that has the "Allow auto-merge" setting enabled — which in practice requires
46
+ * branch protection. A repo with NO required checks and NO branch protection
47
+ * (every mandrel-bench sandbox, many real consumer repos) refuses the `--auto`
48
+ * arm: either "auto-merge is not allowed for this repository", or — once the
49
+ * PR has settled to an immediately-mergeable state, which the SECOND delivery
50
+ * into a warm repo reaches faster than the first into a cold one — the
51
+ * `enablePullRequestAutoMerge` "Pull request is in clean status" refusal. The
52
+ * close gates and Story-scope review have already cleared the merge by the
53
+ * time the arm runs, so the safe, must-land-satisfying response is a direct
54
+ * immediate squash-merge (no `--auto`). When the `--auto` failure matches the
55
+ * narrow {@link isAutoMergeUnavailable} signature, `enableAutoMergeWith`
56
+ * retries `gh pr merge --squash --delete-branch` and reports
57
+ * `{ enabled: true, directMerged: true }` on success — so the confirm phase
58
+ * polls the PR, observes MERGED, and lands it instead of blocking a PR that
59
+ * would never merge on its own. Every OTHER `--auto` failure — a genuine
60
+ * conflict, a red required check, an auth fault — matches neither the
61
+ * local-cleanup nor the unavailable signature and keeps blocking verbatim.
26
62
  */
27
63
 
28
64
  import { gh as defaultGh } from '../../../gh-exec.js';
@@ -52,6 +88,123 @@ export function isOperatorMergeReason(reason) {
52
88
  return OPERATOR_MERGE_ARM_REASONS.includes(reason);
53
89
  }
54
90
 
91
+ /**
92
+ * Signatures of a `gh pr merge --delete-branch` failure whose ONLY casualty
93
+ * is the LOCAL head-branch cleanup that runs *after* the remote merge has
94
+ * already been performed (or auto-merge already armed).
95
+ *
96
+ * Each pattern is emitted by local `git` (or `gh`'s wrapper around it) and
97
+ * names branch DELETION specifically:
98
+ * - `Cannot delete branch '<name>' used by worktree at …` — `git branch -D`
99
+ * refusing a ref another worktree has checked out (the Story #4681 report).
100
+ * - `failed to delete local branch …` — `gh`'s own wrapper wording.
101
+ *
102
+ * Deliberately narrow on two fronts. A genuinely refused REMOTE merge ("Pull
103
+ * request is not mergeable", a required status check, branch protection)
104
+ * matches neither pattern and keeps the existing blocked path. Nor does the
105
+ * bare `fatal: '<base>' is already used by worktree` checkout collision Story
106
+ * #4282 defends against: that one aborts `gh` *before* the branch delete and
107
+ * carries no evidence the merge stands, so it must keep failing the arm.
108
+ */
109
+ const LOCAL_CLEANUP_FAILURE =
110
+ /cannot delete branch[^\n]*used by worktree|failed to delete (?:the )?local branch/i;
111
+
112
+ /**
113
+ * Whether a non-zero `gh pr merge` exit is attributable solely to local
114
+ * branch cleanup, leaving the remote merge/arm itself intact.
115
+ *
116
+ * Module-private on purpose: `enableAutoMergeWith` is the only caller and the
117
+ * only surface worth pinning, so the classification is asserted through it
118
+ * rather than through a test-only export.
119
+ *
120
+ * @param {string|undefined|null} stderr
121
+ * @returns {boolean}
122
+ */
123
+ function isLocalCleanupOnlyFailure(stderr) {
124
+ return LOCAL_CLEANUP_FAILURE.test(String(stderr ?? ''));
125
+ }
126
+
127
+ /**
128
+ * Pure: does a `gh pr merge --auto` stderr indicate that GitHub native
129
+ * auto-merge is UNAVAILABLE on this repository / PR — as opposed to a genuine
130
+ * arm failure (a merge conflict, a red required check, an auth fault)?
131
+ *
132
+ * Two distinct refusals both mean "there is no queued auto-merge for this repo,
133
+ * merge it directly instead", and both are safe to retry as an immediate
134
+ * squash-merge:
135
+ *
136
+ * - **"auto-merge is not allowed for this repository"** — the repo has no
137
+ * "Allow auto-merge" setting (no branch protection). Constant per repo.
138
+ * - **"Pull request is in clean status"** — the `enablePullRequestAutoMerge`
139
+ * GraphQL mutation refuses to queue a merge on a PR that is ALREADY
140
+ * immediately mergeable with nothing to wait for (no required checks
141
+ * pending). This is the second-delivery wedge (Story #4682): the first
142
+ * delivery into a cold sandbox arms while GitHub is still computing the
143
+ * fresh PR's mergeability (the arm queues, then merges); the second
144
+ * delivery into the now-warm repo hits an instantly-clean PR, so the arm
145
+ * is refused here.
146
+ *
147
+ * Only these classes fall through to the direct-merge fallback; everything
148
+ * else (an unmatched non-zero exit) keeps the `enabled: false` → blocked path.
149
+ * Matched case-insensitively.
150
+ *
151
+ * Module-private on purpose (mirroring {@link isLocalCleanupOnlyFailure}):
152
+ * `enableAutoMergeWith` is the only caller, so the marker set is asserted
153
+ * through it rather than through a test-only export the production dead-export
154
+ * ratchet would then flag.
155
+ *
156
+ * @param {string|undefined|null} stderr
157
+ * @returns {boolean}
158
+ */
159
+ function isAutoMergeUnavailable(stderr) {
160
+ const text = String(stderr ?? '').toLowerCase();
161
+ return (
162
+ text.includes('auto merge is not allowed') ||
163
+ text.includes('auto-merge is not allowed') ||
164
+ text.includes('enablepullrequestautomerge') ||
165
+ text.includes('clean status') ||
166
+ (text.includes('auto') && text.includes('not enabled'))
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Direct (non-`--auto`) squash-merge fallback (Story #4682, restoring PR
172
+ * #4480 / Story #4472). Reached only when the `--auto` arm was refused with
173
+ * the {@link isAutoMergeUnavailable} signature — a repo with no native
174
+ * auto-merge, or an already-clean PR with nothing to queue behind. Omitting
175
+ * `--auto` makes `gh` merge synchronously; the same `--squash --delete-branch`
176
+ * shape and the same `armCwd` re-point are preserved so the trailing local
177
+ * `--delete-branch` housekeeping runs from the primary worktree (Story #4282).
178
+ *
179
+ * A local-cleanup-only grumble on the direct merge (Story #4681) still means
180
+ * the REMOTE merge landed, so it reports `directMerged` with
181
+ * `localCleanupDeferred`. Any other non-zero exit is a genuine failure the
182
+ * caller escalates.
183
+ *
184
+ * @returns {Promise<{ enabled: boolean, directMerged?: boolean, localCleanupDeferred?: boolean, reason?: string }>}
185
+ */
186
+ async function directMergeFallback({ exec, prNumber, armCwd, autoReason }) {
187
+ const direct = await exec(
188
+ ['pr', 'merge', String(prNumber), '--squash', '--delete-branch'],
189
+ { cwd: armCwd },
190
+ );
191
+ if (direct.status === 0) {
192
+ return { enabled: true, directMerged: true, reason: autoReason };
193
+ }
194
+ if (isLocalCleanupOnlyFailure(direct.stderr)) {
195
+ return {
196
+ enabled: true,
197
+ directMerged: true,
198
+ localCleanupDeferred: true,
199
+ reason: autoReason,
200
+ };
201
+ }
202
+ return {
203
+ enabled: false,
204
+ reason: `direct-merge fallback failed after auto-merge unavailable (${autoReason}); gh-exit-${direct.status}: ${(direct.stderr ?? '').trim().slice(0, 160)}`,
205
+ };
206
+ }
207
+
55
208
  /**
56
209
  * Enable GitHub native auto-merge on the PR. Non-fatal.
57
210
  *
@@ -62,7 +215,7 @@ export function isOperatorMergeReason(reason) {
62
215
  * runner?: (args: string[], opts: object) => ({ status: number, stdout?: string, stderr?: string } | Promise<{ status: number, stdout?: string, stderr?: string }>),
63
216
  * resolveArmCwd?: (cwd: string) => string,
64
217
  * }} opts
65
- * @returns {Promise<{ enabled: boolean, reason?: string }>}
218
+ * @returns {Promise<{ enabled: boolean, reason?: string, localCleanupDeferred?: boolean, directMerged?: boolean }>}
66
219
  */
67
220
  export async function enableAutoMergeWith({
68
221
  cwd,
@@ -89,10 +242,25 @@ export async function enableAutoMergeWith({
89
242
  { cwd: armCwd },
90
243
  );
91
244
  if (result.status === 0) return { enabled: true };
92
- return {
93
- enabled: false,
94
- reason: `gh-exit-${result.status}: ${(result.stderr ?? '').trim().slice(0, 200)}`,
95
- };
245
+ const detail = `gh-exit-${result.status}: ${(result.stderr ?? '').trim().slice(0, 200)}`;
246
+ if (isLocalCleanupOnlyFailure(result.stderr)) {
247
+ // The remote side stands; only the local head-branch cleanup failed.
248
+ // Report ENABLED so the confirm phase polls the real PR state instead
249
+ // of blocking a merge that already landed, and flag the deferred
250
+ // cleanup for the land tail's `git branch -D` to finish.
251
+ return { enabled: true, localCleanupDeferred: true, reason: detail };
252
+ }
253
+ if (isAutoMergeUnavailable(result.stderr)) {
254
+ // No native auto-merge on this repo (or nothing to queue behind an
255
+ // already-clean PR): merge directly so the PR still lands (Story #4682).
256
+ return directMergeFallback({
257
+ exec,
258
+ prNumber,
259
+ armCwd,
260
+ autoReason: detail,
261
+ });
262
+ }
263
+ return { enabled: false, reason: detail };
96
264
  } catch (err) {
97
265
  return { enabled: false, reason: `gh-spawn-error: ${err?.message ?? err}` };
98
266
  }
@@ -167,7 +335,11 @@ function makeDefaultGhAutoMergeRunner(gh) {
167
335
  * gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
168
336
  * progress: (tag: string, msg: string) => void,
169
337
  * }} args
170
- * @returns {Promise<{ autoMergeEnabled: boolean, autoMergeReason: string|null }>}
338
+ * @returns {Promise<{ autoMergeEnabled: boolean, autoMergeReason: string|null, localCleanupDeferred?: boolean, directMerged?: boolean }>}
339
+ * `localCleanupDeferred` is true when the arm stands but `gh`'s local
340
+ * head-branch delete failed (Story #4681) — the land tail owns the reap.
341
+ * `directMerged` is true when native auto-merge was unavailable and the PR
342
+ * was landed by a direct squash-merge instead (Story #4682).
171
343
  */
172
344
  export async function runAutoMergePhase({
173
345
  cwd,
@@ -211,15 +383,56 @@ export async function runAutoMergePhase({
211
383
  }
212
384
  const result = await enableAutoMergeWith({ cwd, prNumber, gh });
213
385
  if (result.enabled) {
386
+ if (result.directMerged) {
387
+ // No native auto-merge on this repo — the PR was merged directly
388
+ // instead of queued (Story #4682). The confirm phase polls the PR,
389
+ // observes MERGED, and runs the land tail; `localCleanupDeferred`
390
+ // still defers a local-ref reap when gh's `--delete-branch` grumbled.
391
+ progress(
392
+ 'PR',
393
+ `✅ Native auto-merge unavailable on PR #${prNumber} — direct squash-merge landed it` +
394
+ (result.localCleanupDeferred
395
+ ? " (gh's LOCAL branch cleanup deferred to the land tail; the merge stands)."
396
+ : '.'),
397
+ );
398
+ return {
399
+ autoMergeEnabled: true,
400
+ autoMergeReason: null,
401
+ directMerged: true,
402
+ localCleanupDeferred: Boolean(result.localCleanupDeferred),
403
+ };
404
+ }
405
+ if (result.localCleanupDeferred) {
406
+ // Warning, never a block (Story #4681): the merge/arm stands and the
407
+ // land tail reaps the local ref once the worktree releases it.
408
+ progress(
409
+ 'PR',
410
+ `⚠️ Auto-merge armed on PR #${prNumber}, but gh's LOCAL branch cleanup failed ` +
411
+ `(${result.reason}) — deferring the local ref reap to the land tail; the merge stands.`,
412
+ );
413
+ return {
414
+ autoMergeEnabled: true,
415
+ autoMergeReason: null,
416
+ localCleanupDeferred: true,
417
+ };
418
+ }
214
419
  progress(
215
420
  'PR',
216
421
  `✅ Auto-merge enabled on PR #${prNumber} (squash, delete-branch).`,
217
422
  );
218
- return { autoMergeEnabled: true, autoMergeReason: null };
423
+ return {
424
+ autoMergeEnabled: true,
425
+ autoMergeReason: null,
426
+ localCleanupDeferred: false,
427
+ };
219
428
  }
220
429
  progress(
221
430
  'PR',
222
431
  `⚠️ Auto-merge enablement failed (${result.reason}) — operator can merge manually.`,
223
432
  );
224
- return { autoMergeEnabled: false, autoMergeReason: result.reason };
433
+ return {
434
+ autoMergeEnabled: false,
435
+ autoMergeReason: result.reason,
436
+ localCleanupDeferred: false,
437
+ };
225
438
  }
@@ -80,6 +80,7 @@ async function invokeStoryReviewCore({
80
80
  provider,
81
81
  runCodeReviewFn,
82
82
  runLocalLensReviewFn,
83
+ appendFindingsYieldFn,
83
84
  progress,
84
85
  }) {
85
86
  return runStoryReviewCore({
@@ -91,10 +92,11 @@ async function invokeStoryReviewCore({
91
92
  progress,
92
93
  progressTag: 'REVIEW',
93
94
  runCodeReviewFn,
94
- // Forward the seam only when the caller injects it; otherwise
95
- // `runStoryReviewCore` uses its default local-lens pass. `undefined`
96
- // deep-merges to the default via the destructuring default there.
95
+ // Forward the seams only when the caller injects them; otherwise
96
+ // `runStoryReviewCore` uses its defaults. `undefined` deep-merges to
97
+ // the default via the destructuring default there.
97
98
  ...(runLocalLensReviewFn ? { runLocalLensReviewFn } : {}),
99
+ ...(appendFindingsYieldFn ? { appendFindingsYieldFn } : {}),
98
100
  });
99
101
  }
100
102
 
@@ -163,6 +165,7 @@ async function postStoryReviewCrossRef({
163
165
  * provider: object,
164
166
  * runCodeReviewFn: Function,
165
167
  * runLocalLensReviewFn?: Function,
168
+ * appendFindingsYieldFn?: Function,
166
169
  * progress: (tag: string, msg: string) => void,
167
170
  * }} args
168
171
  * @returns {Promise<{
@@ -185,6 +188,7 @@ export async function runStoryScopeReview({
185
188
  provider,
186
189
  runCodeReviewFn,
187
190
  runLocalLensReviewFn,
191
+ appendFindingsYieldFn,
188
192
  progress,
189
193
  }) {
190
194
  if (prNumber == null) {
@@ -208,6 +212,7 @@ export async function runStoryScopeReview({
208
212
  provider,
209
213
  runCodeReviewFn,
210
214
  runLocalLensReviewFn,
215
+ appendFindingsYieldFn,
211
216
  progress,
212
217
  });
213
218