mandrel 1.84.0 → 1.86.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 (46) hide show
  1. package/.agents/docs/agentrc-reference.json +8 -2
  2. package/.agents/docs/configuration.md +7 -2
  3. package/.agents/instructions.md +4 -0
  4. package/.agents/rules/ci-remediation.md +131 -0
  5. package/.agents/rules/git-conventions.md +33 -0
  6. package/.agents/schemas/agentrc.schema.json +29 -6
  7. package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
  8. package/.agents/scripts/boot-sweep.js +183 -0
  9. package/.agents/scripts/epic-deliver-prepare.js +55 -0
  10. package/.agents/scripts/git-pr-quality-gate.js +7 -5
  11. package/.agents/scripts/lib/config/ci.js +24 -3
  12. package/.agents/scripts/lib/config/explain.js +11 -3
  13. package/.agents/scripts/lib/config/github.js +11 -7
  14. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  15. package/.agents/scripts/lib/config-settings-schema.js +6 -6
  16. package/.agents/scripts/lib/orchestration/epic-cleanup.js +289 -1
  17. package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
  18. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
  19. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
  20. package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
  21. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
  22. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
  23. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
  24. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
  25. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
  26. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
  27. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
  28. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +17 -16
  29. package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
  30. package/.agents/scripts/lib/single-story-sweep.js +181 -54
  31. package/.agents/scripts/lib/templates/decomposer-prompts.js +17 -3
  32. package/.agents/scripts/pr-watch-with-update.js +324 -37
  33. package/.agents/scripts/run-verify.js +18 -3
  34. package/.agents/scripts/single-story-confirm-merge.js +1 -1
  35. package/.agents/scripts/single-story-init.js +7 -51
  36. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
  37. package/.agents/skills/core/scope-triage/SKILL.md +5 -4
  38. package/.agents/workflows/git-deliver.md +36 -0
  39. package/.agents/workflows/helpers/deliver-epic-reference.md +41 -21
  40. package/.agents/workflows/helpers/deliver-epic.md +148 -28
  41. package/.agents/workflows/helpers/deliver-stories.md +18 -5
  42. package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
  43. package/.agents/workflows/helpers/single-story-deliver.md +56 -19
  44. package/.agents/workflows/plan.md +32 -4
  45. package/docs/CHANGELOG.md +21 -0
  46. package/package.json +1 -1
@@ -92,7 +92,6 @@
92
92
  "taskSizing": {
93
93
  "softFiles": 15,
94
94
  "hardFiles": 30,
95
- "maxAcceptance": 14,
96
95
  "softAcceptanceCount": 10
97
96
  }
98
97
  },
@@ -101,7 +100,14 @@
101
100
  "maxTokenBudget": 300000,
102
101
  "lease": { "ttlMs": 900000 },
103
102
  "ci": {
104
- "skipForStoryPushes": true
103
+ "skipForStoryPushes": true,
104
+ "earlyPr": true,
105
+ "watch": {
106
+ "pollIntervalMs": 30000,
107
+ "maxPolls": 120,
108
+ "maxResumes": 3
109
+ },
110
+ "autoMerge": "trust-ci"
105
111
  },
106
112
  "preflight": {
107
113
  "maxStories": 50,
@@ -108,8 +108,7 @@ top-level keys are validation errors.
108
108
  | `taskSizing` | No | `object` | — | Story-sizing thresholds consumed by ticket-validator-sizing.js. Operator overrides shallow-merge with DEFAULT_TASK_SIZING defaults. Story #3760 collapsed the per-profile matrix and the parallel testSurface axis into a flat set of knobs; the sizingProfile enum was replaced by an optional body-level `wide` declaration that lifts the hardFiles rejection. Story #3874 cut over to one uniform relaxed profile sized for capability slices a frontier model delivers and self-verifies in one pass. |
109
109
  | `taskSizing.softFiles` | No | `integer` | — | File-count soft-warn threshold above which a typical-Story width finding fires (default 15). |
110
110
  | `taskSizing.hardFiles` | No | `integer` | — | File-count hard ceiling: a Story exceeding it is rejected unless it declares `wide` with a reason (default 30). |
111
- | `taskSizing.maxAcceptance` | No | `integer` | — | Hard ceiling on acceptance[] item count (default 14). |
112
- | `taskSizing.softAcceptanceCount` | No | `integer` | — | Soft-warn threshold on acceptance[] item count (default 10). |
111
+ | `taskSizing.softAcceptanceCount` | No | `integer` | — | Soft-warn threshold on acceptance[] item count (default 10). Acceptance mass is advisory-only — there is no hard acceptance ceiling. |
113
112
  | `taskSizing.mergeCandidateMaxFiles` | No | `integer` | — | Under-size threshold (Story #4312): a Story with at most this many declared changes[] files, at most mergeCandidateMaxAcceptance acceptance items, and at least one depends_on edge to a sibling trips the advisory `merge-candidate` soft finding (default 3). |
114
113
  | `taskSizing.mergeCandidateMaxAcceptance` | No | `integer` | — | Under-size threshold (Story #4312): the acceptance[] item ceiling of the `merge-candidate` soft finding heuristic (default 4). |
115
114
  | `failOnSharedEditors` | No | `boolean` | — | — |
@@ -293,6 +292,12 @@ top-level keys are validation errors.
293
292
  | `acceptanceEval.maxRounds` | No | `integer` | — | Maximum number of redraft rounds before escalation. Default 2; clamped into [1, hard ceiling] by lib/config/acceptance-eval.js so the cap can never be disabled (maxRounds: 0 clamps up to 1). |
294
293
  | `ci` | No | `object` | — | Nested configuration block. |
295
294
  | `ci.skipForStoryPushes` | No | `boolean` | — | Story #2899 (Epic #2880, F13). When true (default), pre-push tooling appends a '[skip ci]' trailer to Story-branch commit subjects so intermediate pushes do not stampede the CI fleet. The Epic-branch merge commit produced by story-close.js never carries the marker, regardless of this flag. |
295
+ | `ci.earlyPr` | No | `boolean` | — | Story #4356 (Epic #4355). When true (default), /deliver opens the Epic PR early — before every Story merges — so CI starts warming while later waves run. Set false to defer PR creation until the Epic branch is complete. |
296
+ | `ci.watch` | No | `object` | — | Story #4356 (Epic #4355). Poll-loop tuning for the merge/CI watch. pollIntervalMs is the cadence between check probes; maxPolls caps total probes before the watcher gives up; maxResumes caps how many times the watcher may resume after a transient stall. |
297
+ | `ci.watch.pollIntervalMs` | No | `integer` | — | — |
298
+ | `ci.watch.maxPolls` | No | `integer` | — | — |
299
+ | `ci.watch.maxResumes` | No | `integer` | — | — |
300
+ | `ci.autoMerge` | No | `"trust-ci"` \| `"strict"` | — | Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate. |
296
301
  | `preflight` | No | `object` | — | Story #2899 (Epic #2880, F13). Thresholds consumed by `.agents/scripts/epic-deliver-preflight.js`. When any value is exceeded the preflight envelope flags a breach and /deliver Phase 1 surfaces it via agent::blocked. |
297
302
  | `preflight.maxStories` | No | `integer` | — | — |
298
303
  | `preflight.maxWaves` | No | `integer` | — | — |
@@ -110,6 +110,10 @@ does not re-pay their bytes on every turn.
110
110
  - [`rules/orchestration-error-handling.md`](rules/orchestration-error-handling.md)
111
111
  — before writing or modifying orchestration scripts under
112
112
  `.agents/scripts/**`.
113
+ - [`rules/ci-remediation.md`](rules/ci-remediation.md) — before remediating
114
+ a red (or repeatedly slow) CI check during delivery (the root-cause-only
115
+ triage decision tree, the never-rerun / never-quarantine prohibitions,
116
+ and the escalation criteria).
113
117
  - [`rules/api-conventions.md`](rules/api-conventions.md),
114
118
  [`rules/gherkin-standards.md`](rules/gherkin-standards.md),
115
119
  [`rules/changelog-style.md`](rules/changelog-style.md),
@@ -0,0 +1,131 @@
1
+ # CI Failure Triage & Remediation
2
+
3
+ This rule applies when a delivery path is watching a pull request's CI checks
4
+ and a required check is red (or repeatedly slow) — the Epic Phase 8
5
+ watch-and-iterate loop
6
+ ([`deliver-epic.md`](../workflows/helpers/deliver-epic.md)) and the standalone
7
+ single-Story Step 4 CI watch + fix loop
8
+ ([`single-story-deliver.md`](../workflows/helpers/single-story-deliver.md))
9
+ both hand off to it. It is the single triage brain those mechanisms defer to:
10
+ the watcher (`pr-watch-with-update.js`) surfaces the failing check, the run
11
+ link, and the failure signature; this rule decides what to do next.
12
+
13
+ The animating principle: **a red check is a defect until proven otherwise, and
14
+ the fix is always to remove the defect — never to hide it.** There is no
15
+ rerun-the-failed-job path and no quarantine path in this rule, by design.
16
+ Reruns and quarantines mask defects; a flaky test that passes on the second
17
+ attempt is still a bug that will fail a future run for a real user or a future
18
+ delivery. Root-cause it or file it — never re-roll the dice.
19
+
20
+ ## The triage decision tree
21
+
22
+ When a required check goes red, walk this tree top to bottom. Do **not** skip
23
+ to "fix" before you have classified the failure — an unclassified fix is a
24
+ guess.
25
+
26
+ ### 1. Pull the evidence
27
+
28
+ Fetch the failing job log and record the failure signature (the failing check
29
+ name, the run id / run link, and the first distinctive error line). The
30
+ watcher already writes this to `temp/epic-<epicId>-ci-digest.{json,md}` (Epic
31
+ path) or surfaces it inline (standalone path) — start from that digest.
32
+
33
+ ### 2. Classify the failure
34
+
35
+ - **Deterministic (real) failure** — the check fails the same way every time,
36
+ and the failure is caused by the diff under review (a lint violation, a
37
+ broken test, a coverage regression, a baseline drift the diff genuinely
38
+ caused). → Go to [§ Real failures](#real-failures--route-to-the-per-check-fix-table).
39
+ - **Infra / transient failure** — a runner died, a network fetch timed out, a
40
+ dependency registry 5xx'd, a step hit a platform-conditional path. → Go to
41
+ [§ Infra, transient, and flaky failures](#infra-transient-and-flaky-failures-are-root-cause-defects).
42
+ - **Flaky failure** — the check fails intermittently: green on one run, red on
43
+ the next, with **no diff change** between them (order-dependent tests,
44
+ timing/race assertions, shared-state bleed, wall-clock or timezone
45
+ assumptions). → Go to
46
+ [§ Infra, transient, and flaky failures](#infra-transient-and-flaky-failures-are-root-cause-defects).
47
+
48
+ ## Real failures — route to the per-check fix table
49
+
50
+ A deterministic failure caused by the diff is remediated at source. Use the
51
+ existing per-check fix table — do **not** duplicate it here:
52
+
53
+ - **Epic Phase 8**:
54
+ [`deliver-epic-reference.md` § Phase 8 — Watch-and-iterate remediation](../workflows/helpers/deliver-epic-reference.md#phase-8--watch-and-iterate-remediation)
55
+ (§ 8.1 Remediation).
56
+ - **Standalone Step 4**:
57
+ [`single-story-deliver-reference.md` § Step 4 — CI watch + fix recovery](../workflows/helpers/single-story-deliver-reference.md#step-4--ci-watch--fix-recovery).
58
+
59
+ In short: lint/format → `npm run lint` + biome apply; maintainability/crap
60
+ baseline drift → re-run the ratcheted script and fix at source (refresh a
61
+ baseline only when the diff demonstrably can't be covered); test failure →
62
+ reproduce with `npm test`, fix source or test; coverage threshold → add tests.
63
+ Commit the fix on the delivery branch (`epic/<epicId>` or `story-<storyId>`),
64
+ push, and re-run the watcher. Auto-merge stays armed across retries.
65
+
66
+ ## Infra, transient, and flaky failures ARE root-cause defects
67
+
68
+ Treat every infra/transient failure **and** every flaky failure as a
69
+ root-cause defect. The remediation sequence is the same for both — you do not
70
+ get to wave it off because "CI was flaky."
71
+
72
+ 1. **Reproduce.** Run the failing check locally (or in a clean environment as
73
+ close to CI as you can get). Re-run it enough times to observe the
74
+ intermittency for a flaky failure. If you cannot reproduce it at all after a
75
+ genuine attempt, that itself is a finding — record it in the issue you file
76
+ in step 4.
77
+ 2. **Check whether it also fails on `main`.** Run the same check against an
78
+ unmodified `main` (or the Epic base branch) checkout. If it fails on `main`
79
+ too, the defect is **pre-existing** — it is not caused by the diff under
80
+ review, and the fix belongs in a separate change, not silently folded into
81
+ this delivery.
82
+ 3. **Bisect environment vs. code.** Determine whether the failure is driven by
83
+ the environment (runner OS, Node version, concurrency, a platform-
84
+ conditional branch, an external service) or by the code (an
85
+ order-dependent test, a race, a shared-state assumption). This tells you
86
+ where the fix has to land.
87
+ 4. **Then either fix in-scope OR file the defect.**
88
+ - **Fix in-scope** when the root cause is within this delivery's footprint
89
+ and the fix is a cohesive part of the change under review (e.g. a race in
90
+ a test the diff touches, a timezone assumption in code the Story owns).
91
+ Commit it on the delivery branch, push, and re-run the watcher.
92
+ - **File a `meta::framework-gap` issue** when the root cause is outside this
93
+ delivery's scope — a pre-existing flaky test, a runner/infra weakness, a
94
+ framework-level environment gap. Open the issue with the
95
+ `meta::framework-gap` label (see
96
+ [`git-conventions.md` § `meta::framework-gap`](git-conventions.md)),
97
+ and include **the run link and the failure signature** (failing check,
98
+ run id, first distinctive error line) captured in step 1 so a later
99
+ `/plan` Phase 0 sweep can act on it. Then remediate this delivery only if
100
+ the pre-existing defect is genuinely blocking it; otherwise proceed once
101
+ the defect is filed and the check is not caused by your diff.
102
+
103
+ **There is no shortcut.** You may **not** re-run the failed job to "see if it
104
+ goes green," and you may **not** quarantine, skip, or `.only`/`.skip` a flaky
105
+ test to get a green bar. Both mask the defect and are prohibited by this rule.
106
+
107
+ ## Escalation criteria
108
+
109
+ Escalate — flip the ticket to `agent::blocked`, post a `friction` comment, and
110
+ hand back to the operator — under **any** of the following. These extend the
111
+ existing three-strikes halt rule; they do not replace it.
112
+
113
+ - **Three strikes (existing).** Three consecutive remediation iterations on the
114
+ same failure class without convergence. Stop; the diagnosis is likely wrong
115
+ (see [`instructions.md` § 1.I Anti-Thrashing](../instructions.md)).
116
+ - **Wall-clock timebox.** Regardless of iteration count, if you have spent more
117
+ than **30 minutes of active remediation** on a single CI failure without a
118
+ green bar in sight, stop and escalate. A long grind on one red check is
119
+ itself a signal that the failure exceeds a single delivery turn's judgment.
120
+ - **Clearly-environmental → escalate immediately (fast path).** When the
121
+ failure is unambiguously environmental and outside your control — a runner
122
+ provisioning failure, a persistent registry/network outage, a
123
+ branch-protection or CI-configuration misconfiguration, an expired
124
+ credential — do **not** burn iterations trying to code around it. File the
125
+ `meta::framework-gap` issue (with run link + signature) and escalate on the
126
+ first encounter. This fast path exists so an operator-side or infra-side
127
+ problem reaches the operator immediately instead of consuming the turn.
128
+
129
+ When you escalate, name the failing check, the run link, the failure
130
+ signature, the classification you reached, and what you tried — never fall
131
+ silent.
@@ -120,6 +120,39 @@ rejected by `pre-push` hooks):
120
120
  (`--no-errors-on-unmatched` or equivalent) before escalating via
121
121
  `agent::blocked`.
122
122
 
123
+ ## Local checkout hygiene
124
+
125
+ **Invariant: the delivering flow owns tidying the local checkout — reaping its
126
+ own merged refs and fast-forwarding the base branch. `/git-cleanup` is a
127
+ recovery tool, not a routine chore.**
128
+
129
+ Every flow that lands work — `/deliver` (Epic and standalone-Story paths),
130
+ `/git-deliver` — is responsible for leaving the local checkout tidy without
131
+ operator intervention:
132
+
133
+ - **Fast-forwarding the base branch is owned by the flow.** The standalone
134
+ multi-Story path fast-forwards `main` itself in its summary phase (via
135
+ `git-cleanup.js --fast-forward-main --execute --yes`); the Epic path
136
+ fast-forwards `epic/<id>` / `main` on its merge-and-reap beat. No workflow
137
+ ends by telling the operator to "run `/git-cleanup` afterwards to catch up".
138
+ - **Reaping merged local refs is owned by the flow's next boot.** `/plan` and
139
+ `/git-deliver` open with a **protected boot sweep**
140
+ (`boot-sweep.js`) that fast-forwards `main`, prunes stale remote-tracking
141
+ refs, and reaps every local branch whose PR is already merged — skipping any
142
+ candidate with unpushed work, a dirty worktree, or a still-open parent
143
+ ticket. A branch a flow leaves behind (e.g. a `/git-deliver` feature branch
144
+ whose PR merges out of band) is therefore reaped automatically at the next
145
+ workflow boot, not left for the operator to sweep by hand.
146
+ - **`/git-cleanup` is recovery, not routine.** Run it by hand only to recover
147
+ an unusual state the automated hygiene does not cover — triaging stashes,
148
+ reaping across non-standard branch namespaces, or `--remote` pruning after a
149
+ force-push diverged a tip. It is **not** the expected way to keep `main`
150
+ current or to clear merged branches after a normal delivery; the delivering
151
+ flows already own that. If you find yourself reaching for `/git-cleanup`
152
+ after every routine `/deliver` or `/git-deliver` run, that is a signal the
153
+ owning flow's hygiene step regressed — fix the flow, do not codify the manual
154
+ sweep.
155
+
123
156
  ## Meta Labels (Retrospective Signal Routing)
124
157
 
125
158
  Two `meta::*` labels route retrospective signals into durable substrates so
@@ -339,15 +339,10 @@
339
339
  "minimum": 1,
340
340
  "description": "File-count hard ceiling: a Story exceeding it is rejected unless it declares `wide` with a reason (default 30)."
341
341
  },
342
- "maxAcceptance": {
343
- "type": "integer",
344
- "minimum": 1,
345
- "description": "Hard ceiling on acceptance[] item count (default 14)."
346
- },
347
342
  "softAcceptanceCount": {
348
343
  "type": "integer",
349
344
  "minimum": 1,
350
- "description": "Soft-warn threshold on acceptance[] item count (default 10)."
345
+ "description": "Soft-warn threshold on acceptance[] item count (default 10). Acceptance mass is advisory-only — there is no hard acceptance ceiling."
351
346
  },
352
347
  "mergeCandidateMaxFiles": {
353
348
  "type": "integer",
@@ -1421,6 +1416,34 @@
1421
1416
  "skipForStoryPushes": {
1422
1417
  "type": "boolean",
1423
1418
  "description": "Story #2899 (Epic #2880, F13). When true (default), pre-push tooling appends a '[skip ci]' trailer to Story-branch commit subjects so intermediate pushes do not stampede the CI fleet. The Epic-branch merge commit produced by story-close.js never carries the marker, regardless of this flag."
1419
+ },
1420
+ "earlyPr": {
1421
+ "type": "boolean",
1422
+ "description": "Story #4356 (Epic #4355). When true (default), /deliver opens the Epic PR early — before every Story merges — so CI starts warming while later waves run. Set false to defer PR creation until the Epic branch is complete."
1423
+ },
1424
+ "watch": {
1425
+ "type": "object",
1426
+ "description": "Story #4356 (Epic #4355). Poll-loop tuning for the merge/CI watch. pollIntervalMs is the cadence between check probes; maxPolls caps total probes before the watcher gives up; maxResumes caps how many times the watcher may resume after a transient stall.",
1427
+ "properties": {
1428
+ "pollIntervalMs": {
1429
+ "type": "integer",
1430
+ "minimum": 1
1431
+ },
1432
+ "maxPolls": {
1433
+ "type": "integer",
1434
+ "minimum": 1
1435
+ },
1436
+ "maxResumes": {
1437
+ "type": "integer",
1438
+ "minimum": 0
1439
+ }
1440
+ },
1441
+ "additionalProperties": false
1442
+ },
1443
+ "autoMerge": {
1444
+ "type": "string",
1445
+ "enum": ["trust-ci", "strict"],
1446
+ "description": "Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate."
1424
1447
  }
1425
1448
  },
1426
1449
  "additionalProperties": false
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://github.com/dsj1984/mandrel/blob/main/.agents/schemas/lifecycle/epic.watch.end.schema.json",
4
4
  "title": "epic.watch.end",
5
- "description": "Emitted by Watcher when required checks settle. checkOutcomes maps check-name → terminal state (success | failure | timed_out | skipped). AutomergePredicate subscribes to this event (test-only Watcher path; the production Phase 8.5 boundary is epic.automerge.start).",
5
+ "description": "Emitted by Watcher when required checks settle. checkOutcomes maps check-name → terminal state (success | failure | timed_out | skipped) or the slow-but-not-failed sentinel `still-running` (Story #4358: the poll cap fired with the check still pending after the resume budget was exhausted — distinct from a genuine `failure`). AutomergePredicate subscribes to this event (test-only Watcher path; the production Phase 8.5 boundary is epic.automerge.start).",
6
6
  "type": "object",
7
7
  "required": ["prUrl", "checkOutcomes"],
8
8
  "properties": {
@@ -17,6 +17,7 @@
17
17
  "neutral",
18
18
  "cancelled",
19
19
  "timed_out",
20
+ "still-running",
20
21
  "action_required",
21
22
  "stale",
22
23
  "skipped"
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ /* node:coverage ignore file */
3
+
4
+ /**
5
+ * boot-sweep.js — protected boot-sweep CLI (Story #4373).
6
+ *
7
+ * A thin, non-interactive wrapper over the scope-agnostic
8
+ * [`sweepMergedBranches`](./lib/single-story-sweep.js) engine, exposed so
9
+ * workflow prose can invoke a *protected* boot sweep directly. Unlike the
10
+ * plain `git-cleanup.js --branches` phase (which reaps every merged
11
+ * candidate the planner surfaces), this surface always applies the
12
+ * `evaluateProtection` partition — a merged branch with unpushed work, a
13
+ * dirty worktree, or a still-open parent Story ticket is skipped, not
14
+ * reaped.
15
+ *
16
+ * The sweep is best-effort: any failure (lock contention, git/gh error)
17
+ * is swallowed and reported in the result envelope, never thrown, so a
18
+ * caller can wire it into a boot path without risking the host run.
19
+ *
20
+ * Usage:
21
+ * node .agents/scripts/boot-sweep.js [--include <glob>...] \
22
+ * [--exclude <glob>...] [--current <branch>] [--base <branch>] \
23
+ * [--no-fast-forward] [--json]
24
+ *
25
+ * Defaults: `--include story-*`, fast-forward the base branch on.
26
+ * Exit code is always 0 — a boot sweep never fails its host.
27
+ */
28
+
29
+ import path from 'node:path';
30
+ import { parseArgs } from 'node:util';
31
+
32
+ import { runAsCli } from './lib/cli-utils.js';
33
+ import { PROJECT_ROOT, resolveConfig } from './lib/config-resolver.js';
34
+ import { Logger } from './lib/Logger.js';
35
+ import { createProvider } from './lib/provider-factory.js';
36
+ import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
37
+ import { sweepMergedBranches } from './lib/single-story-sweep.js';
38
+
39
+ const HELP = `Usage: node .agents/scripts/boot-sweep.js [options]
40
+
41
+ Runs the protected merged-branch boot sweep non-interactively: reaps every
42
+ local branch whose PR is MERGED and whose HEAD matches the merged headRefOid,
43
+ skipping any candidate the protection partition flags (unpushed work, dirty
44
+ worktree, still-open parent Story), then fast-forwards the base branch.
45
+
46
+ Options:
47
+ --include <glob> Branch glob to sweep (repeatable). Default: story-*
48
+ --exclude <glob> Branch glob to exclude (repeatable).
49
+ --current <branch> A branch to always exclude (e.g. the active story).
50
+ --base <branch> Base branch to fast-forward. Default: project baseBranch.
51
+ --no-fast-forward Skip the base-branch fast-forward step.
52
+ --json Emit the result envelope as JSON.
53
+ `;
54
+
55
+ /**
56
+ * Run the protected boot sweep. Best-effort: swallows any error and
57
+ * returns the sweep envelope so no caller can be blocked by a failure.
58
+ *
59
+ * DI-friendly: `injectedConfig` / `injectedProvider` let a caller (e.g.
60
+ * `epic-deliver-prepare.js`) reuse an already-resolved config + provider,
61
+ * and `injectedSweep` swaps the engine for unit tests.
62
+ *
63
+ * @param {{
64
+ * cwd?: string,
65
+ * base?: string,
66
+ * include?: string[],
67
+ * exclude?: string[],
68
+ * current?: string,
69
+ * fastForward?: boolean,
70
+ * injectedConfig?: object,
71
+ * injectedProvider?: object,
72
+ * injectedSweep?: Function,
73
+ * logger?: { info?: Function, warn?: Function },
74
+ * }} [args]
75
+ * @returns {Promise<object>} the {@link sweepMergedBranches} envelope.
76
+ */
77
+ export async function runBootSweep({
78
+ cwd,
79
+ base,
80
+ include,
81
+ exclude,
82
+ current,
83
+ fastForward = true,
84
+ injectedConfig,
85
+ injectedProvider,
86
+ injectedSweep,
87
+ logger = Logger,
88
+ } = {}) {
89
+ const root = path.resolve(cwd ?? PROJECT_ROOT);
90
+ try {
91
+ // Config/provider resolution is inside the try so a malformed
92
+ // `.agentrc.json` (or a provider-construction throw) degrades to the
93
+ // swallowed `ok:false` envelope below rather than propagating and
94
+ // exiting non-zero — the "host continues, exit 0" boot-sweep contract
95
+ // must hold even when config resolution is the thing that fails.
96
+ const config = injectedConfig ?? resolveConfig({ cwd: root });
97
+ const provider = injectedProvider ?? createProvider(config);
98
+ const baseBranch = base ?? config.project?.baseBranch ?? 'main';
99
+
100
+ const includeGlobs =
101
+ Array.isArray(include) && include.length > 0 ? include : ['story-*'];
102
+ const excludeGlobs = Array.isArray(exclude) ? [...exclude] : [];
103
+ if (typeof current === 'string' && current.length > 0) {
104
+ excludeGlobs.push(current);
105
+ }
106
+
107
+ const tempRoot = config?.project?.paths?.tempRoot ?? 'temp';
108
+ const lockPath = path.resolve(root, tempRoot, 'boot-sweep.lock');
109
+ const lockTimeoutMs =
110
+ config.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
111
+
112
+ const sweepFn = injectedSweep ?? sweepMergedBranches;
113
+ return await sweepFn({
114
+ cwd: root,
115
+ baseBranch,
116
+ include: includeGlobs,
117
+ exclude: excludeGlobs,
118
+ fastForward,
119
+ logTag: '[boot-sweep]',
120
+ logger: {
121
+ info: (m) => logger.info?.(m),
122
+ warn: (m) => logger.warn?.(m),
123
+ },
124
+ protectionCtx: buildProtectionCtx({ cwd: root, provider }),
125
+ lockPath,
126
+ lockTimeoutMs,
127
+ });
128
+ } catch (err) {
129
+ const msg = err?.message ?? String(err);
130
+ logger.warn?.(`[boot-sweep] sweep threw (host continues): ${msg}`);
131
+ return {
132
+ ok: false,
133
+ skipped: true,
134
+ error: msg,
135
+ candidates: 0,
136
+ localDeleted: 0,
137
+ remoteDeleted: 0,
138
+ protected: [],
139
+ failures: [],
140
+ };
141
+ }
142
+ }
143
+
144
+ async function main() {
145
+ const { values } = parseArgs({
146
+ options: {
147
+ base: { type: 'string' },
148
+ cwd: { type: 'string' },
149
+ include: { type: 'string', multiple: true, default: [] },
150
+ exclude: { type: 'string', multiple: true, default: [] },
151
+ current: { type: 'string' },
152
+ 'no-fast-forward': { type: 'boolean', default: false },
153
+ json: { type: 'boolean', default: false },
154
+ help: { type: 'boolean', short: 'h' },
155
+ },
156
+ strict: false,
157
+ });
158
+
159
+ if (values.help) {
160
+ Logger.info(HELP);
161
+ return;
162
+ }
163
+
164
+ const result = await runBootSweep({
165
+ cwd: typeof values.cwd === 'string' ? values.cwd : undefined,
166
+ base: typeof values.base === 'string' ? values.base : undefined,
167
+ include: Array.isArray(values.include) ? values.include : [],
168
+ exclude: Array.isArray(values.exclude) ? values.exclude : [],
169
+ current: typeof values.current === 'string' ? values.current : undefined,
170
+ fastForward: values['no-fast-forward'] !== true,
171
+ });
172
+
173
+ if (values.json) {
174
+ Logger.info(JSON.stringify(result, null, 2));
175
+ } else {
176
+ const protectedCount = result.protected?.length ?? 0;
177
+ Logger.info(
178
+ `[boot-sweep] reaped ${result.localDeleted} local + ${result.remoteDeleted} remote; protected ${protectedCount}.`,
179
+ );
180
+ }
181
+ }
182
+
183
+ runAsCli(import.meta.url, main, { source: 'boot-sweep' });
@@ -36,6 +36,7 @@ import fs from 'node:fs';
36
36
  import path from 'node:path';
37
37
  import { parseArgs } from 'node:util';
38
38
 
39
+ import { runBootSweep } from './boot-sweep.js';
39
40
  import { runAsCli } from './lib/cli-utils.js';
40
41
  import { getPaths, getRunners, resolveConfig } from './lib/config-resolver.js';
41
42
  import { currentBranch as gitCurrentBranch } from './lib/git-branch-lifecycle.js';
@@ -218,6 +219,49 @@ async function runPreflightGuardsForPrepare({
218
219
  });
219
220
  }
220
221
 
222
+ /**
223
+ * Route the Epic boot cleanup through the shared protected boot-sweep
224
+ * engine (Story #4373). Reaps merged, done `story-*` branches left over
225
+ * from prior runs — the protection partition skips any branch with
226
+ * unpushed work, a dirty worktree, or a still-open parent Story, so an
227
+ * in-flight Story is never touched. Fast-forward is off: the prepare may
228
+ * run on the Epic branch, and the fast-forward phase would otherwise
229
+ * check out the base branch.
230
+ *
231
+ * Best-effort — a sweep failure (lock contention, git/gh error) is
232
+ * swallowed and never blocks or fails the prepare. Skipped in the same
233
+ * injected-test shape the preflight guards use (a provider injected with
234
+ * no git seam) so unit tests never spawn real git/gh.
235
+ */
236
+ async function runBootSweepForPrepare({
237
+ cwd,
238
+ config,
239
+ provider,
240
+ injectedProvider,
241
+ injectedGit,
242
+ injectedSweep,
243
+ skipPreflightGuards,
244
+ }) {
245
+ const suppressed =
246
+ skipPreflightGuards || (Boolean(injectedProvider) && !injectedGit);
247
+ if (suppressed) return;
248
+ try {
249
+ await runBootSweep({
250
+ cwd,
251
+ include: ['story-*'],
252
+ fastForward: false,
253
+ injectedConfig: config,
254
+ injectedProvider: provider,
255
+ injectedSweep,
256
+ logger: Logger,
257
+ });
258
+ } catch (err) {
259
+ Logger.warn(
260
+ `[epic-deliver-prepare] ⚠️ boot sweep threw (prepare continues): ${err?.message ?? err}`,
261
+ );
262
+ }
263
+ }
264
+
221
265
  /**
222
266
  * Resolve the Epic state, preferring the preflight cache (Story #3027) and
223
267
  * falling back to a fresh snapshot + wave-DAG pass on miss or baseSha
@@ -333,6 +377,7 @@ export async function runEpicDeliverPrepare({
333
377
  steal = false,
334
378
  asOperator,
335
379
  injectedGit,
380
+ injectedSweep,
336
381
  leaseHeartbeatAt,
337
382
  leaseNow,
338
383
  skipPreflightGuards = false,
@@ -365,6 +410,16 @@ export async function runEpicDeliverPrepare({
365
410
  skipPreflightGuards,
366
411
  });
367
412
 
413
+ await runBootSweepForPrepare({
414
+ cwd,
415
+ config,
416
+ provider,
417
+ injectedProvider,
418
+ injectedGit,
419
+ injectedSweep,
420
+ skipPreflightGuards,
421
+ });
422
+
368
423
  const { state, cacheStatus } = await resolvePrepareState({
369
424
  epicId,
370
425
  cwd,
@@ -2,7 +2,7 @@
2
2
  /* node:coverage ignore file -- top-level CLI gate; spawns lint/format/test and asserts exit codes — heavy mocking would assert only mock structure */
3
3
 
4
4
  /**
5
- * git-pr-quality-gate.js — Lint / format / test gate for `/git-merge-pr`.
5
+ * git-pr-quality-gate.js — Lint / test / baselines gate for `/git-merge-pr`.
6
6
  *
7
7
  * `/git-merge-pr` Steps 3–4 previously hardcoded the command sequence
8
8
  * (`npm run lint`, `npm run format:check`, `npm test`) in the workflow
@@ -11,10 +11,12 @@
11
11
  * skill every time.
12
12
  *
13
13
  * This script runs the gate and emits a structured result so the .md routes
14
- * on outcome rather than re-implementing the command sequence. The three
15
- * checks it runs are read from
14
+ * on outcome rather than re-implementing the command sequence. The checks it
15
+ * runs are read from
16
16
  * `.agentrc.json → github.branchProtection.requiredChecks` when present,
17
- * falling back to the hardcoded default trio.
17
+ * falling back to the hardcoded default trio, which mirrors the live required
18
+ * check set (`lint`, `test`, `baselines` — Story #4356, Epic #4355; the stale
19
+ * `lifecycle-doc-drift` check was pruned in the same slice).
18
20
  *
19
21
  * Usage:
20
22
  * node .agents/scripts/git-pr-quality-gate.js [--json] [--skip <name>[,<name>]]
@@ -48,8 +50,8 @@ import { Logger } from './lib/Logger.js';
48
50
  */
49
51
  export const DEFAULT_CHECKS = Object.freeze([
50
52
  { name: 'lint', cmd: ['npm', 'run', 'lint'] },
51
- { name: 'format:check', cmd: ['npm', 'run', 'format:check'] },
52
53
  { name: 'test', cmd: ['npm', 'test'] },
54
+ { name: 'baselines', cmd: ['node', '.agents/scripts/check-baselines.js'] },
53
55
  ]);
54
56
 
55
57
  function resolveChecks(config) {
@@ -1,23 +1,34 @@
1
1
  /**
2
- * `delivery.ci` accessor + framework defaults — Story #2899 (Epic #2880, F13).
2
+ * `delivery.ci` accessor + framework defaults — Story #2899 (Epic #2880, F13)
3
+ * and Story #4356 (Epic #4355).
3
4
  *
4
5
  * `delivery.ci.skipForStoryPushes` defaults to `true` so per-Task Story-branch
5
6
  * commits append a `[skip ci]` trailer out of the box. The Epic-branch merge
6
7
  * commit produced by `story-close.js`'s merge runner never carries the
7
8
  * marker — that path is the one consumers actually want CI to evaluate.
9
+ *
10
+ * Story #4356 adds the CI-aware delivery knobs: `earlyPr` (default `true`)
11
+ * gates whether /deliver opens the Epic PR early so CI warms while later
12
+ * waves run; `watch` tunes the merge/CI watch poll loop; and `autoMerge`
13
+ * (default `"trust-ci"`) selects the merge posture — `"trust-ci"` merges once
14
+ * required checks pass, `"strict"` additionally requires a clean review gate.
8
15
  */
9
16
 
10
17
  export const CI_DELIVERY_DEFAULTS = Object.freeze({
11
18
  skipForStoryPushes: true,
19
+ earlyPr: true,
20
+ autoMerge: 'trust-ci',
12
21
  });
13
22
 
14
23
  /**
15
24
  * Read the merged `delivery.ci` block, applying framework defaults for any
16
25
  * field the operator omitted. Accepts the full resolved config, the bare
17
- * delivery bag, or the bare ci bag.
26
+ * delivery bag, or the bare ci bag. The `watch` sub-block is passed through
27
+ * as-is (undefined when unset) so consumers apply their own poll-loop
28
+ * defaults; only the scalar knobs carry framework defaults here.
18
29
  *
19
30
  * @param {object | null | undefined} config
20
- * @returns {typeof CI_DELIVERY_DEFAULTS}
31
+ * @returns {{ skipForStoryPushes: boolean, earlyPr: boolean, autoMerge: 'trust-ci' | 'strict', watch: object | undefined }}
21
32
  */
22
33
  export function getCiDelivery(config) {
23
34
  const ci = config?.delivery?.ci ?? config?.ci ?? config ?? {};
@@ -26,5 +37,15 @@ export function getCiDelivery(config) {
26
37
  typeof ci.skipForStoryPushes === 'boolean'
27
38
  ? ci.skipForStoryPushes
28
39
  : CI_DELIVERY_DEFAULTS.skipForStoryPushes,
40
+ earlyPr:
41
+ typeof ci.earlyPr === 'boolean'
42
+ ? ci.earlyPr
43
+ : CI_DELIVERY_DEFAULTS.earlyPr,
44
+ autoMerge:
45
+ ci.autoMerge === 'trust-ci' || ci.autoMerge === 'strict'
46
+ ? ci.autoMerge
47
+ : CI_DELIVERY_DEFAULTS.autoMerge,
48
+ watch:
49
+ ci.watch && typeof ci.watch === 'object' ? { ...ci.watch } : undefined,
29
50
  };
30
51
  }