mandrel 1.83.0 → 1.85.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 (35) 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/testing-standards.md +14 -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/git-pr-quality-gate.js +7 -5
  9. package/.agents/scripts/lib/config/ci.js +24 -3
  10. package/.agents/scripts/lib/config/explain.js +11 -3
  11. package/.agents/scripts/lib/config/github.js +11 -7
  12. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  13. package/.agents/scripts/lib/config-settings-schema.js +6 -6
  14. package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
  15. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
  16. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
  17. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
  18. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
  19. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
  20. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
  21. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +17 -16
  22. package/.agents/scripts/lib/templates/decomposer-prompts.js +17 -3
  23. package/.agents/scripts/pr-watch-with-update.js +324 -37
  24. package/.agents/scripts/run-verify.js +18 -3
  25. package/.agents/scripts/single-story-confirm-merge.js +1 -1
  26. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
  27. package/.agents/skills/core/scope-triage/SKILL.md +5 -4
  28. package/.agents/workflows/helpers/code-review.md +70 -5
  29. package/.agents/workflows/helpers/deliver-epic-reference.md +22 -8
  30. package/.agents/workflows/helpers/deliver-epic.md +123 -28
  31. package/.agents/workflows/helpers/deliver-stories.md +2 -2
  32. package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
  33. package/.agents/workflows/helpers/single-story-deliver.md +56 -19
  34. package/docs/CHANGELOG.md +16 -0
  35. 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.
@@ -161,6 +161,20 @@ assertions into a single "kitchen sink" test — split them.
161
161
  - Coverage targets apply to production code. Test helpers, fixtures, and
162
162
  generated code are excluded per the project's coverage config.
163
163
 
164
+ ## Anti-Gaming (review-side complement)
165
+
166
+ These standards define what a *correct* test looks like; they cannot, on
167
+ their own, catch a change that reaches green by **weakening the check rather
168
+ than fixing the code** — a relaxed assertion, a skipped or deleted test, a
169
+ swallowed error, a stub return, a fake rename, or a warning silenced by
170
+ comment deletion. That shortcut taxonomy is enumerated, and the reviewer-facing
171
+ detection lens for it lives, in the **Anti-Gaming / Shortcut Detection** pillar
172
+ (Pillar 4) of
173
+ [`../workflows/helpers/code-review.md`](../workflows/helpers/code-review.md#pillar-4-anti-gaming--shortcut-detection).
174
+ When you loosen a matcher, quarantine a test, or remove coverage, record the
175
+ spec-sanctioned rationale in the commit body or Story comment so that pillar
176
+ reads it as a deliberate decision rather than gaming.
177
+
164
178
  ## Property-Based Testing (a technique, not a tier)
165
179
 
166
180
  Property-based testing is a **technique** — generating a domain of inputs and
@@ -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"
@@ -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
  }
@@ -126,10 +126,8 @@ const KEY_MEANINGS = Object.freeze({
126
126
  'How many recent commits the snapshot summarizes.',
127
127
  'planning.riskHeuristics':
128
128
  'Phrases that flag a Story as high-risk for HITL escalation.',
129
- 'planning.taskSizing.maxAcceptance':
130
- 'Hard ceiling on acceptance criteria per Story.',
131
129
  'planning.taskSizing.softAcceptanceCount':
132
- 'Acceptance-criteria count above which a Story is flagged as large.',
130
+ 'Acceptance-criteria count above which a Story is flagged as large (advisory-only — there is no hard acceptance ceiling).',
133
131
  'planning.taskSizing.softFiles':
134
132
  'Touched-file count above which a Story is flagged as large.',
135
133
  'planning.taskSizing.hardFiles':
@@ -148,6 +146,16 @@ const KEY_MEANINGS = Object.freeze({
148
146
  'Time-to-live for the Epic lease before a stale claim is reclaimable.',
149
147
  'delivery.ci.skipForStoryPushes':
150
148
  'Whether Story-branch pushes carry a [skip ci] trailer.',
149
+ 'delivery.ci.earlyPr':
150
+ 'Whether /deliver opens the Epic PR early so CI warms during later waves.',
151
+ 'delivery.ci.watch.pollIntervalMs':
152
+ 'Poll cadence (ms) for the merge/CI watch loop.',
153
+ 'delivery.ci.watch.maxPolls':
154
+ 'Maximum number of poll probes before the CI watch gives up.',
155
+ 'delivery.ci.watch.maxResumes':
156
+ 'Maximum times the CI watch may resume after a transient stall.',
157
+ 'delivery.ci.autoMerge':
158
+ 'Merge posture: trust-ci merges on green checks; strict also requires a clean review gate.',
151
159
  'delivery.preflight.maxStories':
152
160
  'Pre-dispatch ceiling on estimated Story count (no cap when unset).',
153
161
  'delivery.preflight.maxWaves':
@@ -13,23 +13,27 @@
13
13
  */
14
14
 
15
15
  /**
16
- * Default required-check suite. The framework runs lint + format:check +
17
- * test by default; consumers override via
18
- * `github.branchProtection.requiredChecks` in `.agentrc.json`.
16
+ * Default required-check suite. Mirrors the live CI required-check set
17
+ * (`lint` + `test` + `baselines`); consumers override via
18
+ * `github.branchProtection.requiredChecks` in `.agentrc.json`. Kept in sync
19
+ * with `DEFAULT_CHECKS` in `git-pr-quality-gate.js` and the CI job names in
20
+ * `.github/workflows/ci.yml` — the retired `format:check` folded into `lint`
21
+ * (Story #1829) and `lifecycle-doc-drift` collapsed into `lint`/`docs:check`
22
+ * (Epic #1943), so neither belongs in the default set.
19
23
  */
20
24
  export const DEFAULT_REQUIRED_CHECKS = Object.freeze([
21
25
  Object.freeze({
22
26
  name: 'lint',
23
27
  cmd: Object.freeze(['npm', 'run', 'lint']),
24
28
  }),
25
- Object.freeze({
26
- name: 'format:check',
27
- cmd: Object.freeze(['npm', 'run', 'format:check']),
28
- }),
29
29
  Object.freeze({
30
30
  name: 'test',
31
31
  cmd: Object.freeze(['npm', 'test']),
32
32
  }),
33
+ Object.freeze({
34
+ name: 'baselines',
35
+ cmd: Object.freeze(['node', '.agents/scripts/check-baselines.js']),
36
+ }),
33
37
  ]);
34
38
 
35
39
  export const BRANCH_PROTECTION_DEFAULTS = Object.freeze({
@@ -226,10 +226,31 @@ const EPIC_AUDIT_SCHEMA = {
226
226
  // commit subjects so intermediate pushes do not stampede the CI fleet.
227
227
  // The Epic-branch merge commit produced by story-close.js's merge
228
228
  // runner never carries the marker, regardless of this flag.
229
+ //
230
+ // Story #4356 (Epic #4355) — CI-aware delivery namespace. `earlyPr` gates
231
+ // whether /deliver opens the Epic PR early (before every Story merges) so CI
232
+ // starts warming while later waves run; defaults to `true` via getCiDelivery.
233
+ // `watch.*` tunes the merge/CI watch poll loop (poll cadence, poll cap, and
234
+ // how many times the watcher may resume after a transient stall).
235
+ // `autoMerge` selects the merge posture: `"trust-ci"` (default) merges once
236
+ // required checks pass, `"strict"` additionally requires a clean review gate.
237
+ const CI_WATCH_SCHEMA = {
238
+ type: 'object',
239
+ properties: {
240
+ pollIntervalMs: { type: 'integer', minimum: 1 },
241
+ maxPolls: { type: 'integer', minimum: 1 },
242
+ maxResumes: { type: 'integer', minimum: 0 },
243
+ },
244
+ additionalProperties: false,
245
+ };
246
+
229
247
  const CI_DELIVERY_SCHEMA = {
230
248
  type: 'object',
231
249
  properties: {
232
250
  skipForStoryPushes: { type: 'boolean' },
251
+ earlyPr: { type: 'boolean' },
252
+ watch: CI_WATCH_SCHEMA,
253
+ autoMerge: { type: 'string', enum: ['trust-ci', 'strict'] },
233
254
  },
234
255
  additionalProperties: false,
235
256
  };
@@ -250,18 +250,18 @@ const CODEBASE_SNAPSHOT_SCHEMA = {
250
250
  * `planning.taskSizing` — Story-sizing thresholds consumed by
251
251
  * `ticket-validator-sizing.js`. Operator overrides shallow-merge with
252
252
  * `DEFAULT_TASK_SIZING` defaults (softFiles 15, hardFiles 30,
253
- * maxAcceptance 14, softAcceptanceCount 10 — the uniform relaxed profile
254
- * from Story #3874). Story #3760 collapsed the per-profile matrix and the
255
- * parallel `testSurface` axis into a flat set of knobs; the `sizingProfile`
256
- * enum was replaced by an optional body-level `wide` declaration that lifts
257
- * the `hardFiles` rejection.
253
+ * softAcceptanceCount 10 — the uniform relaxed profile from Story #3874).
254
+ * Story #3760 collapsed the per-profile matrix and the parallel
255
+ * `testSurface` axis into a flat set of knobs; the `sizingProfile` enum was
256
+ * replaced by an optional body-level `wide` declaration that lifts the
257
+ * `hardFiles` rejection. The hard `maxAcceptance` ceiling was removed after
258
+ * the Epic #4355 decomposition experiment — acceptance mass is advisory-only.
258
259
  */
259
260
  const TASK_SIZING_SCHEMA = {
260
261
  type: 'object',
261
262
  properties: {
262
263
  softFiles: { type: 'integer', minimum: 1 },
263
264
  hardFiles: { type: 'integer', minimum: 1 },
264
- maxAcceptance: { type: 'integer', minimum: 1 },
265
265
  softAcceptanceCount: { type: 'integer', minimum: 1 },
266
266
  // Under-size (merge-candidate) thresholds (Story #4312). A Story whose
267
267
  // footprint is at or below BOTH ceilings and that carries a `depends_on`
@@ -27,6 +27,20 @@
27
27
  * does not attempt to create a duplicate PR. This is the AC-10
28
28
  * idempotency contract the Finalizer relies on for cross-process
29
29
  * re-runs of `/deliver`.
30
+ *
31
+ * Early-PR draft mode — Story #4359 (Epic #4355).
32
+ *
33
+ * When `delivery.ci.earlyPr` is on (the default), `/deliver` opens the
34
+ * Epic PR as a **draft** at wave 1 (`openOrLocatePr({ draft: true })`)
35
+ * so every subsequent per-wave push runs CI attributed to its own wave.
36
+ * The `draft` flag only appends `--draft` to the `gh pr create` shell —
37
+ * the probe/locate path is unchanged, so a re-run on the same head
38
+ * branch still short-circuits without opening a duplicate. Phase 7 then
39
+ * flips that existing draft to ready-for-review via `markPrReady` rather
40
+ * than creating the PR. When `earlyPr` is off, no draft is opened at
41
+ * wave 1 and Phase 7 opens the PR at close time exactly as before
42
+ * (`openOrLocatePr` with no `draft`). The title/body contract
43
+ * (`feat: Epic #<id>` / `Closes #<id>`) is identical in both modes.
30
44
  */
31
45
 
32
46
  import { spawnSync } from 'node:child_process';
@@ -119,6 +133,12 @@ export function parsePrViewResult(stdout) {
119
133
  * or changelog entry).
120
134
  * @param {string} [args.body] — explicit PR body override; defaults to
121
135
  * `Closes #<epicId>`.
136
+ * @param {boolean} [args.draft] — when `true`, open the new PR as a draft
137
+ * (`gh pr create --draft`). Story #4359: the early-PR path opens the
138
+ * Epic PR as a draft at wave 1 so per-wave pushes warm CI; Phase 7 later
139
+ * flips it ready via `markPrReady`. Only affects the create path — the
140
+ * locate short-circuit never inspects draft state, so a re-run stays
141
+ * idempotent. Default `false`.
122
142
  * @param {string} [args.cwd] — working directory for the gh shells.
123
143
  * Default `process.cwd()`.
124
144
  * @param {Function} [args.ghSpawn] — override the gh invocation for
@@ -131,6 +151,7 @@ export async function openOrLocatePr({
131
151
  baseBranch = 'main',
132
152
  title,
133
153
  body,
154
+ draft = false,
134
155
  cwd = process.cwd(),
135
156
  ghSpawn = defaultGhSpawn,
136
157
  } = {}) {
@@ -206,6 +227,11 @@ export async function openOrLocatePr({
206
227
  finalTitle,
207
228
  '--body',
208
229
  finalBody,
230
+ // Story #4359: open as a draft when the early-PR path requests it.
231
+ // The flag is elided entirely when `draft` is false so the
232
+ // close-time (earlyPr=false) create path is byte-identical to the
233
+ // pre-Story behaviour.
234
+ ...(draft ? ['--draft'] : []),
209
235
  ],
210
236
  cwd,
211
237
  });
@@ -239,3 +265,42 @@ export async function openOrLocatePr({
239
265
  }
240
266
  return { prNumber: parsed.number, url: parsed.url, created: true };
241
267
  }
268
+
269
+ /**
270
+ * Mark an existing draft PR as ready-for-review — Story #4359 (Epic
271
+ * #4355). This is the Phase-7 counterpart to the wave-1 draft open: when
272
+ * `delivery.ci.earlyPr` is on, the Epic PR already exists as a draft, so
273
+ * finalize flips it ready rather than creating a PR.
274
+ *
275
+ * Idempotent: `gh pr ready` on an already-ready PR is a no-op that exits
276
+ * 0, so re-running finalize (or replaying after a crash) is safe. When the
277
+ * PR reference does not resolve, `gh` exits non-zero and this helper
278
+ * throws with the stderr detail.
279
+ *
280
+ * @param {object} args
281
+ * @param {string} args.pr — a PR reference `gh pr ready` accepts: the
282
+ * numeric id, the branch name, or the html URL. `openOrLocatePr`
283
+ * returns both `prNumber` and `url`; either is a valid input.
284
+ * @param {string} [args.cwd] — working directory for the gh shell.
285
+ * Default `process.cwd()`.
286
+ * @param {Function} [args.ghSpawn] — override the gh invocation for
287
+ * tests. Same shape as `defaultGhSpawn`.
288
+ * @returns {Promise<{ pr: string, ready: true }>}
289
+ */
290
+ export async function markPrReady({
291
+ pr,
292
+ cwd = process.cwd(),
293
+ ghSpawn = defaultGhSpawn,
294
+ } = {}) {
295
+ const ref = pr == null ? '' : String(pr).trim();
296
+ if (ref.length === 0) {
297
+ throw new TypeError('markPrReady: pr must be a non-empty reference');
298
+ }
299
+ const ready = ghSpawn({ args: ['pr', 'ready', ref], cwd });
300
+ if (ready.status !== 0) {
301
+ throw new Error(
302
+ `markPrReady: gh pr ready failed (status=${ready.status}): ${ready.stderr.trim()}`,
303
+ );
304
+ }
305
+ return { pr: ref, ready: true };
306
+ }