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.
- package/.agents/docs/agentrc-reference.json +8 -2
- package/.agents/docs/configuration.md +7 -2
- package/.agents/instructions.md +4 -0
- package/.agents/rules/ci-remediation.md +131 -0
- package/.agents/rules/git-conventions.md +33 -0
- package/.agents/schemas/agentrc.schema.json +29 -6
- package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
- package/.agents/scripts/boot-sweep.js +183 -0
- package/.agents/scripts/epic-deliver-prepare.js +55 -0
- package/.agents/scripts/git-pr-quality-gate.js +7 -5
- package/.agents/scripts/lib/config/ci.js +24 -3
- package/.agents/scripts/lib/config/explain.js +11 -3
- package/.agents/scripts/lib/config/github.js +11 -7
- package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
- package/.agents/scripts/lib/config-settings-schema.js +6 -6
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +289 -1
- package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +17 -16
- package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
- package/.agents/scripts/lib/single-story-sweep.js +181 -54
- package/.agents/scripts/lib/templates/decomposer-prompts.js +17 -3
- package/.agents/scripts/pr-watch-with-update.js +324 -37
- package/.agents/scripts/run-verify.js +18 -3
- package/.agents/scripts/single-story-confirm-merge.js +1 -1
- package/.agents/scripts/single-story-init.js +7 -51
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
- package/.agents/skills/core/scope-triage/SKILL.md +5 -4
- package/.agents/workflows/git-deliver.md +36 -0
- package/.agents/workflows/helpers/deliver-epic-reference.md +41 -21
- package/.agents/workflows/helpers/deliver-epic.md +148 -28
- package/.agents/workflows/helpers/deliver-stories.md +18 -5
- package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
- package/.agents/workflows/helpers/single-story-deliver.md +56 -19
- package/.agents/workflows/plan.md +32 -4
- package/docs/CHANGELOG.md +21 -0
- 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.
|
|
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` | — | — |
|
package/.agents/instructions.md
CHANGED
|
@@ -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 /
|
|
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
|
|
15
|
-
*
|
|
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 {
|
|
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
|
}
|