mandrel 1.88.0 → 1.90.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/README.md +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +62 -27
- package/.agents/docs/configuration.md +5 -4
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +10 -6
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/coverage-capture.js +17 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/close-validation/gates.js +64 -24
- package/.agents/scripts/lib/config/ci.js +12 -1
- package/.agents/scripts/lib/config/runners.js +13 -5
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/npm-scripts.js +55 -0
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +116 -43
- package/.agents/workflows/helpers/deliver-epic.md +123 -54
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -366
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
|
@@ -41,11 +41,47 @@
|
|
|
41
41
|
* labels, post comments, or call `notify`. Downstream listeners
|
|
42
42
|
* (Cleaner / LabelTransitioner on `epic.merge.confirmed`, Task #2912)
|
|
43
43
|
* own those side effects.
|
|
44
|
+
*
|
|
45
|
+
* Must-land terminal step (Story #4427, Epic #4425 slice 2). In
|
|
46
|
+
* headless (`--yes`) delivery runs — signalled via the explicit
|
|
47
|
+
* `headless` constructor option, threaded from `/deliver`'s `--yes`
|
|
48
|
+
* flag through `lifecycle-emit.js`'s `--headless` runtime flag and
|
|
49
|
+
* `buildDefaultListenerChain({ headless })` — budget exhaustion no
|
|
50
|
+
* longer falls straight through to `epic.blocked`. Instead the watcher
|
|
51
|
+
* classifies the block (`classifyMergeBlock`, the shared classifier
|
|
52
|
+
* from Story #4426) and, bounded by one attempt each per watch run:
|
|
53
|
+
*
|
|
54
|
+
* - `checks-pending-timeout` (required checks still progressing) →
|
|
55
|
+
* extend the watch budget once and keep polling in the SAME watch
|
|
56
|
+
* cycle.
|
|
57
|
+
* - `api-race-other` (no definitive block signal) → re-arm once by
|
|
58
|
+
* re-emitting `epic.merge.ready` on the bus. This does NOT call
|
|
59
|
+
* `gh pr merge` directly — AutomergeArmer remains the sole
|
|
60
|
+
* authorized call site (merge-lockout invariant, Story #4427 AC).
|
|
61
|
+
* AutomergeArmer's own idempotent `gh pr view` probe short-circuits
|
|
62
|
+
* to a single `epic.merge.armed` re-emit when auto-merge is
|
|
63
|
+
* already armed on the PR, which re-triggers this watcher's
|
|
64
|
+
* `handle()` for a fresh watch cycle that continues the resume
|
|
65
|
+
* ledger's attempt count.
|
|
66
|
+
* - `branch-protection-human-required`, or retries already
|
|
67
|
+
* exhausted (both bounded attempts spent) → terminal: emit
|
|
68
|
+
* `merge.unlanded` (scope `"epic"`, carrying the block class) via
|
|
69
|
+
* `emitMergeUnlanded`, THEN fall through to the existing single
|
|
70
|
+
* `epic.blocked` emit below — one blocked path, never a duplicate
|
|
71
|
+
* `agent::blocked` transition.
|
|
72
|
+
*
|
|
73
|
+
* Attended-mode (headless === false, the default) behavior is
|
|
74
|
+
* byte-for-byte unchanged: budget exhaustion emits exactly
|
|
75
|
+
* `epic.blocked` with `reason: 'merge-watch:budget-exceeded'`, no
|
|
76
|
+
* classification, no retry, no `merge.unlanded`.
|
|
44
77
|
*/
|
|
45
78
|
|
|
46
79
|
import { spawnSync } from 'node:child_process';
|
|
47
80
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
48
81
|
import path from 'node:path';
|
|
82
|
+
import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
83
|
+
import { classifyMergeBlock } from '../../merge-block-class.js';
|
|
84
|
+
import { emitMergeUnlanded } from '../emit-merge-unlanded.js';
|
|
49
85
|
|
|
50
86
|
/**
|
|
51
87
|
* Default poll interval and budget. The schema in
|
|
@@ -59,15 +95,31 @@ export const DEFAULT_INTERVAL_SECONDS = 30;
|
|
|
59
95
|
export const DEFAULT_MAX_BUDGET_SECONDS = 3600;
|
|
60
96
|
|
|
61
97
|
/**
|
|
62
|
-
*
|
|
98
|
+
* Fields requested from `gh pr view` on every poll. The merge-confirm
|
|
99
|
+
* fields (`mergeCommit`, `mergedAt`, `number`) are the original Story
|
|
100
|
+
* #2896 contract; `mergeStateStatus`, `reviewDecision`, and
|
|
101
|
+
* `statusCheckRollup` were added in Story #4427 so a headless
|
|
102
|
+
* budget-exhaustion path can classify the block (`classifyMergeBlock`)
|
|
103
|
+
* from the SAME probe already being polled, instead of issuing a
|
|
104
|
+
* second `gh` call.
|
|
105
|
+
*/
|
|
106
|
+
const PR_VIEW_JSON_FIELDS =
|
|
107
|
+
'mergeCommit,mergedAt,number,mergeStateStatus,reviewDecision,statusCheckRollup';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Default `gh pr view --json <PR_VIEW_JSON_FIELDS>` probe. Pure-spawn
|
|
63
111
|
* helper — exported so tests can stub the shell-out without touching
|
|
64
112
|
* the spawn wrapper.
|
|
65
113
|
*/
|
|
66
114
|
export function ghPrViewMerge({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
67
115
|
const result = spawnFn(
|
|
68
116
|
'gh',
|
|
69
|
-
['pr', 'view', prUrl, '--json',
|
|
70
|
-
{
|
|
117
|
+
['pr', 'view', prUrl, '--json', PR_VIEW_JSON_FIELDS],
|
|
118
|
+
{
|
|
119
|
+
cwd,
|
|
120
|
+
encoding: 'utf-8',
|
|
121
|
+
shell: false,
|
|
122
|
+
},
|
|
71
123
|
);
|
|
72
124
|
return {
|
|
73
125
|
status: result.status ?? 1,
|
|
@@ -77,24 +129,58 @@ export function ghPrViewMerge({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
77
129
|
}
|
|
78
130
|
|
|
79
131
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* `
|
|
83
|
-
*
|
|
132
|
+
* Pure: derive an aggregate `checksStatus` (`success` | `pending` |
|
|
133
|
+
* `still-running` | `failure` | `unknown`) from a
|
|
134
|
+
* `statusCheckRollup` array (`gh pr view --json statusCheckRollup`
|
|
135
|
+
* shape: `{ status, conclusion }` per check). Mirrors the values
|
|
136
|
+
* `classifyMergeBlock` expects on `prProbe.checksStatus`.
|
|
137
|
+
*/
|
|
138
|
+
export function deriveChecksStatus(statusCheckRollup) {
|
|
139
|
+
if (!Array.isArray(statusCheckRollup) || statusCheckRollup.length === 0) {
|
|
140
|
+
return 'unknown';
|
|
141
|
+
}
|
|
142
|
+
let anyPending = false;
|
|
143
|
+
for (const check of statusCheckRollup) {
|
|
144
|
+
const conclusion = String(check?.conclusion ?? '').toUpperCase();
|
|
145
|
+
const status = String(check?.status ?? '').toUpperCase();
|
|
146
|
+
if (['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ERROR'].includes(conclusion)) {
|
|
147
|
+
return 'failure';
|
|
148
|
+
}
|
|
149
|
+
if (status !== 'COMPLETED') {
|
|
150
|
+
anyPending = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return anyPending ? 'still-running' : 'success';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse `gh pr view --json <PR_VIEW_JSON_FIELDS>` output. Returns
|
|
158
|
+
* `{ mergeCommitSha, mergedAt, prNumber, mergeStateStatus,
|
|
159
|
+
* reviewDecision, checksStatus }` where `mergeCommitSha` is `null`
|
|
160
|
+
* until the PR has merged. Pure — exported for tests so the
|
|
161
|
+
* JSON-shape pin is reviewable.
|
|
84
162
|
*/
|
|
85
163
|
export function parseMergeView(stdout) {
|
|
164
|
+
const empty = {
|
|
165
|
+
mergeCommitSha: null,
|
|
166
|
+
mergedAt: null,
|
|
167
|
+
prNumber: null,
|
|
168
|
+
mergeStateStatus: null,
|
|
169
|
+
reviewDecision: null,
|
|
170
|
+
checksStatus: 'unknown',
|
|
171
|
+
};
|
|
86
172
|
const trimmed = String(stdout ?? '').trim();
|
|
87
173
|
if (trimmed.length === 0) {
|
|
88
|
-
return
|
|
174
|
+
return empty;
|
|
89
175
|
}
|
|
90
176
|
let parsed;
|
|
91
177
|
try {
|
|
92
178
|
parsed = JSON.parse(trimmed);
|
|
93
179
|
} catch {
|
|
94
|
-
return
|
|
180
|
+
return empty;
|
|
95
181
|
}
|
|
96
182
|
if (!parsed || typeof parsed !== 'object') {
|
|
97
|
-
return
|
|
183
|
+
return empty;
|
|
98
184
|
}
|
|
99
185
|
const merge = parsed.mergeCommit;
|
|
100
186
|
const sha =
|
|
@@ -103,9 +189,35 @@ export function parseMergeView(stdout) {
|
|
|
103
189
|
: null;
|
|
104
190
|
const mergedAt = typeof parsed.mergedAt === 'string' ? parsed.mergedAt : null;
|
|
105
191
|
const prNumber = Number.isInteger(parsed.number) ? parsed.number : null;
|
|
106
|
-
|
|
192
|
+
const mergeStateStatus =
|
|
193
|
+
typeof parsed.mergeStateStatus === 'string'
|
|
194
|
+
? parsed.mergeStateStatus
|
|
195
|
+
: null;
|
|
196
|
+
const reviewDecision =
|
|
197
|
+
typeof parsed.reviewDecision === 'string' ? parsed.reviewDecision : null;
|
|
198
|
+
const checksStatus = deriveChecksStatus(parsed.statusCheckRollup);
|
|
199
|
+
return {
|
|
200
|
+
mergeCommitSha: sha,
|
|
201
|
+
mergedAt,
|
|
202
|
+
prNumber,
|
|
203
|
+
mergeStateStatus,
|
|
204
|
+
reviewDecision,
|
|
205
|
+
checksStatus,
|
|
206
|
+
};
|
|
107
207
|
}
|
|
108
208
|
|
|
209
|
+
// `parsePrNumberFromUrl` (imported above from the Story #3649 canonical
|
|
210
|
+
// `lib/github-url.js` helper) is the last-resort fallback for a `gh pr
|
|
211
|
+
// view` probe that never successfully returned `number` (e.g. every poll
|
|
212
|
+
// on the final watch cycle probe-failed). `emitMergeUnlanded` requires a
|
|
213
|
+
// positive-integer `prNumber`, and `prUrl` is always present by the time
|
|
214
|
+
// a watch cycle starts (checked in `handle()`), so this is the
|
|
215
|
+
// last-resort source of truth. Re-exported here so existing imports of
|
|
216
|
+
// `parsePrNumberFromUrl` from this module (e.g.
|
|
217
|
+
// `tests/epic-must-land-terminal.test.js`) keep working without a
|
|
218
|
+
// duplicate implementation (code-review finding, Epic #4425).
|
|
219
|
+
export { parsePrNumberFromUrl };
|
|
220
|
+
|
|
109
221
|
/**
|
|
110
222
|
* Resolve the resume-ledger path for an Epic. Pure helper — exported
|
|
111
223
|
* so tests can pin the layout.
|
|
@@ -194,6 +306,16 @@ export class MergeWatcher {
|
|
|
194
306
|
* epoch ms.
|
|
195
307
|
* @param {() => string} [opts.nowIsoFn] override for tests; returns
|
|
196
308
|
* ISO-8601 wall-clock for the attempt record.
|
|
309
|
+
* @param {boolean} [opts.headless] Explicit must-land signal (Story
|
|
310
|
+
* #4427). Defaults to `false` — attended-mode behavior (immediate
|
|
311
|
+
* `epic.blocked` on budget exhaustion, no classification, no
|
|
312
|
+
* retry) is unchanged. `true` engages the bounded classify-and-
|
|
313
|
+
* retry terminal step. Threaded from `/deliver`'s `--yes` flag via
|
|
314
|
+
* `lifecycle-emit.js --headless true` → `buildDefaultListenerChain`
|
|
315
|
+
* — an explicit constructor input, never an ambient global.
|
|
316
|
+
* @param {Function} [opts.emitMergeUnlandedFn] override for tests
|
|
317
|
+
* (defaults to the real `emitMergeUnlanded`, which appends to the
|
|
318
|
+
* on-disk lifecycle ledger).
|
|
197
319
|
* @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
|
|
198
320
|
*/
|
|
199
321
|
constructor(opts = {}) {
|
|
@@ -229,14 +351,26 @@ export class MergeWatcher {
|
|
|
229
351
|
this.nowMsFn = opts.nowMsFn ?? Date.now;
|
|
230
352
|
this.nowIsoFn =
|
|
231
353
|
opts.nowIsoFn ?? (() => new Date(this.nowMsFn()).toISOString());
|
|
354
|
+
this.headless = opts.headless === true;
|
|
355
|
+
this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
|
|
232
356
|
this.logger = opts.logger ?? console;
|
|
233
357
|
/** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
|
|
234
358
|
this._seen = new Set();
|
|
359
|
+
/**
|
|
360
|
+
* Must-land bounded-retry state (Story #4427, headless only). Each
|
|
361
|
+
* flips to `true` on its single use across the instance's whole
|
|
362
|
+
* lifetime — NOT per watch cycle — so a re-armed or budget-extended
|
|
363
|
+
* cycle that times out again falls straight through to the
|
|
364
|
+
* `merge.unlanded` terminal rather than retrying indefinitely.
|
|
365
|
+
*/
|
|
366
|
+
this._budgetExtended = false;
|
|
367
|
+
this._reArmed = false;
|
|
235
368
|
/**
|
|
236
369
|
* Classification log — every `epic.merge.armed` observed lands
|
|
237
370
|
* here with the outcome (`confirmed`, `budget-exceeded`,
|
|
238
|
-
* `skipped-duplicate`, `failed
|
|
239
|
-
* "no silent skip"
|
|
371
|
+
* `skipped-duplicate`, `failed`, or — headless only — `extended` /
|
|
372
|
+
* `re-armed`). Mirrors the Armer / Cleaner "no silent skip"
|
|
373
|
+
* surface.
|
|
240
374
|
*/
|
|
241
375
|
this.classifications = [];
|
|
242
376
|
// Frozen tuple — MergeWatcher subscribes to EXACTLY one event.
|
|
@@ -292,7 +426,9 @@ export class MergeWatcher {
|
|
|
292
426
|
}
|
|
293
427
|
|
|
294
428
|
const intervalMs = this.intervalSeconds * 1000;
|
|
295
|
-
const
|
|
429
|
+
// `let`, not `const`: the headless must-land path extends this
|
|
430
|
+
// once on a `checks-pending-timeout` classification (Story #4427).
|
|
431
|
+
let budgetMs = this.maxBudgetSeconds * 1000;
|
|
296
432
|
const startedAtMs = this.nowMsFn();
|
|
297
433
|
let attempt = priorAttempts;
|
|
298
434
|
|
|
@@ -377,6 +513,112 @@ export class MergeWatcher {
|
|
|
377
513
|
// Not merged. Budget check before sleeping.
|
|
378
514
|
const elapsedMs = this.nowMsFn() - startedAtMs;
|
|
379
515
|
if (elapsedMs + intervalMs > budgetMs) {
|
|
516
|
+
// Headless must-land: classify the block and try the bounded
|
|
517
|
+
// per-instance retry (budget extension OR re-arm, each at most
|
|
518
|
+
// once across this watcher's whole lifetime) before giving up.
|
|
519
|
+
// Attended mode (this.headless === false) skips straight to the
|
|
520
|
+
// unchanged budget-exceeded → epic.blocked path below.
|
|
521
|
+
if (this.headless) {
|
|
522
|
+
const elapsedSeconds = Math.floor(elapsedMs / 1000);
|
|
523
|
+
const classification = classifyMergeBlock({
|
|
524
|
+
prProbe: {
|
|
525
|
+
reviewDecision: view.reviewDecision,
|
|
526
|
+
mergeStateStatus: view.mergeStateStatus,
|
|
527
|
+
checksStatus: view.checksStatus,
|
|
528
|
+
error:
|
|
529
|
+
probe.status !== 0
|
|
530
|
+
? probe.stderr || 'gh pr view failed'
|
|
531
|
+
: undefined,
|
|
532
|
+
},
|
|
533
|
+
budget: { exhausted: true, elapsedSeconds },
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
if (
|
|
537
|
+
classification.blockClass === 'checks-pending-timeout' &&
|
|
538
|
+
!this._budgetExtended
|
|
539
|
+
) {
|
|
540
|
+
this._budgetExtended = true;
|
|
541
|
+
budgetMs += this.maxBudgetSeconds * 1000;
|
|
542
|
+
this.classifications.push({
|
|
543
|
+
event,
|
|
544
|
+
seqId,
|
|
545
|
+
outcome: 'extended',
|
|
546
|
+
reason: classification.reason,
|
|
547
|
+
prUrl,
|
|
548
|
+
pollAttempts: attempt,
|
|
549
|
+
});
|
|
550
|
+
this.logger.info?.(
|
|
551
|
+
`[MergeWatcher] extending watch budget once (checks-pending-timeout): ${classification.reason}`,
|
|
552
|
+
);
|
|
553
|
+
await this.sleepFn(intervalMs);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (
|
|
558
|
+
classification.blockClass === 'api-race-other' &&
|
|
559
|
+
!this._reArmed
|
|
560
|
+
) {
|
|
561
|
+
this._reArmed = true;
|
|
562
|
+
this.classifications.push({
|
|
563
|
+
event,
|
|
564
|
+
seqId,
|
|
565
|
+
outcome: 're-armed',
|
|
566
|
+
reason: classification.reason,
|
|
567
|
+
prUrl,
|
|
568
|
+
pollAttempts: attempt,
|
|
569
|
+
});
|
|
570
|
+
this.logger.info?.(
|
|
571
|
+
`[MergeWatcher] re-arming once (api-race-other): ${classification.reason}`,
|
|
572
|
+
);
|
|
573
|
+
let reArmEmitSucceeded = false;
|
|
574
|
+
try {
|
|
575
|
+
await this.bus.emit('epic.merge.ready', {
|
|
576
|
+
prUrl,
|
|
577
|
+
reason: `must-land retry: ${classification.reason}`,
|
|
578
|
+
});
|
|
579
|
+
reArmEmitSucceeded = true;
|
|
580
|
+
} catch (err) {
|
|
581
|
+
this.logger.warn?.(
|
|
582
|
+
`[MergeWatcher] must-land re-arm epic.merge.ready emit failed: ${err?.message ?? err}`,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
if (reArmEmitSucceeded) {
|
|
586
|
+
// A successful re-arm re-emits epic.merge.armed (via
|
|
587
|
+
// AutomergeArmer's idempotent-probe short-circuit or a
|
|
588
|
+
// fresh arm), which re-triggers this watcher's handle()
|
|
589
|
+
// for a new watch cycle continuing the resume ledger's
|
|
590
|
+
// attempt count. Do NOT also emit epic.blocked here.
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
// The re-arm attempt itself failed to emit — the bounded
|
|
594
|
+
// retry is spent with nothing landed. Fall through to the
|
|
595
|
+
// terminal merge.unlanded + epic.blocked path below rather
|
|
596
|
+
// than returning silently (audit-quality Critical finding,
|
|
597
|
+
// Epic #4425): a swallowed re-arm failure must still
|
|
598
|
+
// surface as an explicit block, never a silent stall.
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Terminal: branch-protection-human-required, or both bounded
|
|
602
|
+
// retries already spent. Emit merge.unlanded before falling
|
|
603
|
+
// through to the existing single epic.blocked emit below —
|
|
604
|
+
// one blocked path, never a duplicate agent::blocked
|
|
605
|
+
// transition.
|
|
606
|
+
try {
|
|
607
|
+
this.emitMergeUnlandedFn({
|
|
608
|
+
scope: 'epic',
|
|
609
|
+
ticketId: this.epicId,
|
|
610
|
+
prNumber: view.prNumber ?? parsePrNumberFromUrl(prUrl),
|
|
611
|
+
blockClass: classification.blockClass,
|
|
612
|
+
reason: classification.reason,
|
|
613
|
+
elapsedSeconds,
|
|
614
|
+
});
|
|
615
|
+
} catch (err) {
|
|
616
|
+
this.logger.warn?.(
|
|
617
|
+
`[MergeWatcher] emitMergeUnlanded failed (swallowed): ${err?.message ?? err}`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
380
622
|
this.classifications.push({
|
|
381
623
|
event,
|
|
382
624
|
seqId,
|
|
@@ -416,6 +658,8 @@ export class MergeWatcher {
|
|
|
416
658
|
|
|
417
659
|
reset() {
|
|
418
660
|
this._seen.clear();
|
|
661
|
+
this._budgetExtended = false;
|
|
662
|
+
this._reArmed = false;
|
|
419
663
|
this.classifications = [];
|
|
420
664
|
}
|
|
421
665
|
}
|
|
@@ -147,6 +147,12 @@ export class NotifyDispatcher {
|
|
|
147
147
|
kind: 'notification.emitted',
|
|
148
148
|
seqId,
|
|
149
149
|
ts: new Date(this._now()).toISOString(),
|
|
150
|
+
// The common signal envelope requires `epicId` on the record
|
|
151
|
+
// itself — `hasCommonEnvelope` (lib/signals/schema.js) rejects
|
|
152
|
+
// rows without it and the read side silently drops them, so
|
|
153
|
+
// without this field the record was write-only (invisible to
|
|
154
|
+
// signals-view.js and every other reader).
|
|
155
|
+
epicId: this.epicId,
|
|
150
156
|
sourceEvent: event,
|
|
151
157
|
webhookEvent,
|
|
152
158
|
};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// .agents/scripts/lib/orchestration/merge-block-class.js
|
|
2
|
+
/**
|
|
3
|
+
* merge-block-class.js — Story #4426 (Epic #4425, slice 1: foundation).
|
|
4
|
+
*
|
|
5
|
+
* Shared block-class classifier consumed by BOTH delivery paths — the
|
|
6
|
+
* epic-path must-land terminal step and the standalone
|
|
7
|
+
* `single-story-close` must-land terminal step (the not-yet-landed
|
|
8
|
+
* follow-on Stories under Epic #4425) — so a headless delivery run that
|
|
9
|
+
* finishes its work without a confirmed merge is attributable to exactly
|
|
10
|
+
* one of four classes from the SAME decision logic, instead of each path
|
|
11
|
+
* inventing its own ad hoc diagnosis.
|
|
12
|
+
*
|
|
13
|
+
* Block classes (Epic #4425 Goal; `predicate-refused` added by #4472):
|
|
14
|
+
* - `checks-pending-timeout` The watch/poll budget was
|
|
15
|
+
* exhausted while required checks
|
|
16
|
+
* were still pending/running — not
|
|
17
|
+
* a hard block, the run simply ran
|
|
18
|
+
* out of time.
|
|
19
|
+
* - `branch-protection-human-required` GitHub reports the PR needs a
|
|
20
|
+
* human action: a required review
|
|
21
|
+
* that hasn't been granted, or a
|
|
22
|
+
* branch-protection rule the
|
|
23
|
+
* automation cannot satisfy on its
|
|
24
|
+
* own.
|
|
25
|
+
* - `arm-failure` The arm call itself (`gh pr merge
|
|
26
|
+
* --auto` or equivalent) failed for
|
|
27
|
+
* a reason that is NOT branch
|
|
28
|
+
* protection — auth, rate limit, an
|
|
29
|
+
* already-merged race, a network
|
|
30
|
+
* error.
|
|
31
|
+
* - `api-race-other` Fallback for anything that does
|
|
32
|
+
* not cleanly fit the above three —
|
|
33
|
+
* a transient GraphQL/API error, an
|
|
34
|
+
* ambiguous probe result, or a
|
|
35
|
+
* genuinely novel condition.
|
|
36
|
+
* - `predicate-refused` The AutomergePredicate refused to
|
|
37
|
+
* arm merge BEFORE any arm attempt —
|
|
38
|
+
* a red/pending required check, an
|
|
39
|
+
* unreadable check probe, a dirty
|
|
40
|
+
* structured-signal verdict, or a
|
|
41
|
+
* `delivery.ci.requireChecks` policy
|
|
42
|
+
* block on a checks-less repo (#4472).
|
|
43
|
+
* The must-land contract previously
|
|
44
|
+
* only covered post-arm poll
|
|
45
|
+
* exhaustion, so a predicate refusal
|
|
46
|
+
* in headless mode silently parked;
|
|
47
|
+
* this class makes it attributable.
|
|
48
|
+
*
|
|
49
|
+
* Pure function, no I/O: callers pass in the already-observed
|
|
50
|
+
* arm-result / PR-probe / budget signals (from `AutomergeArmer`,
|
|
51
|
+
* `MergeWatcher`, a raw `gh pr view` read, or the standalone
|
|
52
|
+
* `single-story-confirm-merge.js` poll) and get back a
|
|
53
|
+
* `{ blockClass, reason }` verdict ready to hand to `emitMergeUnlanded`
|
|
54
|
+
* (`emit-merge-unlanded.js`).
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The four block classes named in the Epic #4425 Goal. Order is the
|
|
59
|
+
* evaluation priority documented on `classifyMergeBlock` below, NOT an
|
|
60
|
+
* arbitrary listing — earlier entries are checked first when a real input
|
|
61
|
+
* happens to satisfy more than one heuristic.
|
|
62
|
+
*/
|
|
63
|
+
export const BLOCK_CLASSES = Object.freeze([
|
|
64
|
+
'checks-pending-timeout',
|
|
65
|
+
'branch-protection-human-required',
|
|
66
|
+
'arm-failure',
|
|
67
|
+
'api-race-other',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The full set of block-class values a `merge.unlanded` record may carry.
|
|
72
|
+
* This is the classifier's four outputs PLUS `predicate-refused` (#4472),
|
|
73
|
+
* which is emitted DIRECTLY by the AutomergePredicate / AutomergeArmer for a
|
|
74
|
+
* headless refusal that never reached the poll-exhaustion classifier — so it
|
|
75
|
+
* is a valid attribution value even though `classifyMergeBlock` never
|
|
76
|
+
* produces it. `isValidBlockClass` (and the `merge.unlanded` schema enum)
|
|
77
|
+
* validate against this broader set; the classifier's own reachability
|
|
78
|
+
* invariant stays scoped to `BLOCK_CLASSES`.
|
|
79
|
+
*/
|
|
80
|
+
export const MERGE_UNLANDED_BLOCK_CLASSES = Object.freeze([
|
|
81
|
+
...BLOCK_CLASSES,
|
|
82
|
+
'predicate-refused',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const BLOCK_CLASS_SET = new Set(MERGE_UNLANDED_BLOCK_CLASSES);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} value
|
|
89
|
+
* @returns {boolean} `true` iff `value` is a valid `merge.unlanded`
|
|
90
|
+
* block-class attribution (the four classifier outputs plus the directly-
|
|
91
|
+
* emitted `predicate-refused`).
|
|
92
|
+
*/
|
|
93
|
+
export function isValidBlockClass(value) {
|
|
94
|
+
return BLOCK_CLASS_SET.has(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Substrings that identify a branch-protection / human-review rejection
|
|
99
|
+
* surfaced through an arm call's stderr or reason text. Matched
|
|
100
|
+
* case-insensitively against the whole string.
|
|
101
|
+
*/
|
|
102
|
+
const BRANCH_PROTECTION_MARKERS = Object.freeze([
|
|
103
|
+
'review',
|
|
104
|
+
'required_status_checks',
|
|
105
|
+
'protected branch',
|
|
106
|
+
'branch protection',
|
|
107
|
+
'approval',
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
function textIncludesAny(text, markers) {
|
|
111
|
+
const lower = String(text ?? '').toLowerCase();
|
|
112
|
+
return markers.some((marker) => lower.includes(marker));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build the `api-race-other` fallback reason from whatever signal is
|
|
117
|
+
* available, so the emitted event still carries a specific-as-possible
|
|
118
|
+
* explanation rather than a bare "unknown".
|
|
119
|
+
*/
|
|
120
|
+
function describeApiRaceFallback(prProbe, budget) {
|
|
121
|
+
if (prProbe?.error) {
|
|
122
|
+
return `PR probe error: ${prProbe.error}`;
|
|
123
|
+
}
|
|
124
|
+
if (budget && budget.exhausted === true) {
|
|
125
|
+
return `watch budget exhausted with an unrecognised checks status (${prProbe?.checksStatus ?? 'unknown'})`;
|
|
126
|
+
}
|
|
127
|
+
return 'no definitive block signal observed; classified as a transient API race or other condition';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Classify why a delivery run finished without a confirmed merge.
|
|
132
|
+
*
|
|
133
|
+
* Evaluation order (first match wins):
|
|
134
|
+
* 1. Arm failure — the arm call itself did not succeed. A failed arm
|
|
135
|
+
* means there is no "armed but stuck" PR left to probe, so this is
|
|
136
|
+
* checked before any PR-probe or budget signal. A branch-protection
|
|
137
|
+
* rejection surfaced AT arm time still routes to
|
|
138
|
+
* `branch-protection-human-required` rather than the generic
|
|
139
|
+
* `arm-failure`.
|
|
140
|
+
* 2. Budget exhaustion while checks were still in flight —
|
|
141
|
+
* `checks-pending-timeout`. Evaluated BEFORE the human-required
|
|
142
|
+
* probe signals because on a protected branch GitHub reports
|
|
143
|
+
* `mergeStateStatus: 'BLOCKED'` for the entire time required checks
|
|
144
|
+
* are still running — a slow-CI timeout would otherwise always
|
|
145
|
+
* misclassify as `branch-protection-human-required` and the
|
|
146
|
+
* headless once-only budget extension could never engage.
|
|
147
|
+
* 3. PR-probe human-required signals — `reviewDecision` reporting a
|
|
148
|
+
* required review, or `mergeStateStatus: 'BLOCKED'` with checks NOT
|
|
149
|
+
* in flight (green/failed checks + BLOCKED = a genuinely human
|
|
150
|
+
* gate, e.g. a missing approval).
|
|
151
|
+
* 4. Fallback — `api-race-other`.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} input
|
|
154
|
+
* @param {object} [input.armResult] Outcome of the arm call.
|
|
155
|
+
* @param {boolean} [input.armResult.armed] `false` when the arm call
|
|
156
|
+
* itself failed (a non-zero `gh pr merge` exit, or arming was refused
|
|
157
|
+
* up-front).
|
|
158
|
+
* @param {string} [input.armResult.reason] Free-form failure detail (e.g.
|
|
159
|
+
* `gh` stderr) — inspected for branch-protection markers.
|
|
160
|
+
* @param {string} [input.armResult.error] Alternate free-form failure
|
|
161
|
+
* detail field, checked when `reason` is absent.
|
|
162
|
+
* @param {object} [input.prProbe] Latest `gh pr view` read.
|
|
163
|
+
* @param {string} [input.prProbe.reviewDecision] GitHub review decision
|
|
164
|
+
* (`REVIEW_REQUIRED`, `APPROVED`, …).
|
|
165
|
+
* @param {string} [input.prProbe.mergeStateStatus] GitHub merge-state
|
|
166
|
+
* status (`BLOCKED`, `BEHIND`, `CLEAN`, …).
|
|
167
|
+
* @param {string} [input.prProbe.checksStatus] Aggregate required-check
|
|
168
|
+
* status observed on the last probe (`success` | `pending` |
|
|
169
|
+
* `still-running` | `failure` | `unknown`).
|
|
170
|
+
* @param {string} [input.prProbe.error] Set when the probe call itself
|
|
171
|
+
* errored (network / API failure reading the PR).
|
|
172
|
+
* @param {object} [input.budget] Poll-budget accounting.
|
|
173
|
+
* @param {boolean} [input.budget.exhausted] `true` once the watch loop hit
|
|
174
|
+
* its budget without observing a confirmed merge.
|
|
175
|
+
* @param {number} [input.budget.elapsedSeconds] Elapsed watch time in
|
|
176
|
+
* seconds, folded into the `reason` text.
|
|
177
|
+
* @returns {{ blockClass: string, reason: string }}
|
|
178
|
+
*/
|
|
179
|
+
export function classifyMergeBlock(input) {
|
|
180
|
+
const { armResult, prProbe, budget } = input ?? {};
|
|
181
|
+
|
|
182
|
+
// 1. Arm call failure.
|
|
183
|
+
if (armResult && armResult.armed === false) {
|
|
184
|
+
const detail = armResult.reason ?? armResult.error ?? '';
|
|
185
|
+
if (textIncludesAny(detail, BRANCH_PROTECTION_MARKERS)) {
|
|
186
|
+
return {
|
|
187
|
+
blockClass: 'branch-protection-human-required',
|
|
188
|
+
reason:
|
|
189
|
+
detail ||
|
|
190
|
+
'arm call rejected: branch protection requires a human action',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
blockClass: 'arm-failure',
|
|
195
|
+
reason: detail || 'arm call failed for an unspecified reason',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Positive in-flight evidence from the latest probe. Only `pending` /
|
|
200
|
+
// `still-running` count — `unknown` (empty rollup: a checks-less repo
|
|
201
|
+
// or a probe race) routes to the api-race re-arm below, and
|
|
202
|
+
// `undefined` (no probe at all) keeps its budget-timeout mapping in
|
|
203
|
+
// step 2 without suppressing the step-3 human-required verdict.
|
|
204
|
+
const checksStatus = prProbe?.checksStatus;
|
|
205
|
+
const checksPendingEvidence =
|
|
206
|
+
checksStatus === 'pending' || checksStatus === 'still-running';
|
|
207
|
+
|
|
208
|
+
// 2. Budget exhausted while checks were still in flight. Ordered
|
|
209
|
+
// before the human-required probe signals: `mergeStateStatus:
|
|
210
|
+
// 'BLOCKED'` is the steady state on a protected branch while required
|
|
211
|
+
// checks run, so a slow-CI timeout must not read as human-required —
|
|
212
|
+
// it must consume the headless once-only budget extension instead.
|
|
213
|
+
if (
|
|
214
|
+
budget &&
|
|
215
|
+
budget.exhausted === true &&
|
|
216
|
+
(checksPendingEvidence || checksStatus === undefined)
|
|
217
|
+
) {
|
|
218
|
+
return {
|
|
219
|
+
blockClass: 'checks-pending-timeout',
|
|
220
|
+
reason: `watch budget exhausted after ${budget.elapsedSeconds ?? 'an unknown number of'} seconds with required checks still pending`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 3. PR-probe human-required signals. A BLOCKED merge state counts
|
|
225
|
+
// only without positive checks-in-flight evidence —
|
|
226
|
+
// BLOCKED-with-settled-checks is a genuinely human gate (e.g. a
|
|
227
|
+
// missing required approval), whereas BLOCKED-while-checks-run is the
|
|
228
|
+
// protected-branch steady state.
|
|
229
|
+
if (prProbe) {
|
|
230
|
+
if (
|
|
231
|
+
prProbe.reviewDecision === 'REVIEW_REQUIRED' ||
|
|
232
|
+
(prProbe.mergeStateStatus === 'BLOCKED' && !checksPendingEvidence)
|
|
233
|
+
) {
|
|
234
|
+
return {
|
|
235
|
+
blockClass: 'branch-protection-human-required',
|
|
236
|
+
reason: `PR requires human action (reviewDecision=${prProbe.reviewDecision ?? 'n/a'}, mergeStateStatus=${prProbe.mergeStateStatus ?? 'n/a'})`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 4. Fallback.
|
|
242
|
+
return {
|
|
243
|
+
blockClass: 'api-race-other',
|
|
244
|
+
reason: describeApiRaceFallback(prProbe, budget),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -24,7 +24,7 @@ const AUTO_PROCEED_MESSAGE =
|
|
|
24
24
|
'Planning risk is low — auto-proceeding to Phase 8 decomposition after spec validation. Context tickets remain open until Epic delivery finalizes.';
|
|
25
25
|
|
|
26
26
|
const REVIEW_REQUIRED_MESSAGE =
|
|
27
|
-
'Planning risk requires operator review — STOP before Phase 8. Review the
|
|
27
|
+
'Planning risk requires operator review — STOP before Phase 8. Review the Tech Spec sections and the Acceptance Table — both managed sections of the Epic body — on GitHub and confirm in this session before decomposition.';
|
|
28
28
|
|
|
29
29
|
const FORCE_REVIEW_MESSAGE =
|
|
30
30
|
'Operator override — forcing review stop before Phase 8 despite low planning risk.';
|
|
@@ -119,12 +119,12 @@ async function emitReapFailureFriction({
|
|
|
119
119
|
storyId: Number(storyId),
|
|
120
120
|
signal: {
|
|
121
121
|
kind: 'friction',
|
|
122
|
-
|
|
122
|
+
ts: new Date().toISOString(),
|
|
123
123
|
epicId: Number(epicId),
|
|
124
124
|
storyId: Number(storyId),
|
|
125
125
|
category: 'reap-failure',
|
|
126
|
-
|
|
127
|
-
details: `Worktree reap failed: ${reason}
|
|
126
|
+
emitter: { tool: 'story-close.js' },
|
|
127
|
+
details: { message: `Worktree reap failed: ${reason}` },
|
|
128
128
|
epicBranch,
|
|
129
129
|
worktreePath: wtPath,
|
|
130
130
|
reason,
|