mandrel 1.87.0 → 1.89.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 +63 -16
- package/.agents/docs/configuration.md +5 -3
- 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 +16 -4
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -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/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/config/explain.js +4 -0
- package/.agents/scripts/lib/config/runners.js +21 -2
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
- 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 +66 -20
- 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/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 +188 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
- 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 +218 -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/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/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 +193 -44
- package/.agents/workflows/helpers/deliver-epic.md +128 -39
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -283
- 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 -207
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* emit-merge-unlanded.js — Story #4426 (Epic #4425, slice 1: foundation).
|
|
3
|
+
*
|
|
4
|
+
* Programmatic helper that appends a single `merge.unlanded` NDJSON
|
|
5
|
+
* record to the lifecycle ledger whenever a headless delivery run
|
|
6
|
+
* finishes its work without a confirmed merge. Pattern mirrors
|
|
7
|
+
* `emit-story-heartbeat.js`: direct schema validation via Ajv followed by
|
|
8
|
+
* a synchronous `appendFileSync` — this event is NOT routed through the
|
|
9
|
+
* bus (unlike `emit-loop-tick.js`) because it can fire from either the
|
|
10
|
+
* epic-path finalize flow (which already owns an Epic-scoped bus
|
|
11
|
+
* instance for its own run) or the standalone `single-story-close` flow,
|
|
12
|
+
* which has no bus at all. A bare append keeps both call sites simple
|
|
13
|
+
* and dependency-free.
|
|
14
|
+
*
|
|
15
|
+
* Ledger destination is scope-driven (Story #4426 AC4):
|
|
16
|
+
* - `scope: 'epic'` → `epicLedgerPath(ticketId)` — the same
|
|
17
|
+
* `temp/epic-<id>/lifecycle.ndjson` every other Epic-scoped event
|
|
18
|
+
* lands in. `ticketId` is the epicId.
|
|
19
|
+
* - `scope: 'story'` → `storyLedgerPath(null, ticketId)` — the
|
|
20
|
+
* standalone story-scope destination
|
|
21
|
+
* `temp/standalone/stories/story-<id>/lifecycle.ndjson`. `ticketId`
|
|
22
|
+
* is the storyId. The standalone `single-story-close` path has no
|
|
23
|
+
* parent Epic to anchor a `temp/epic-<id>/` directory to, mirroring
|
|
24
|
+
* the `eid === null` standalone convention `signalsFile` already
|
|
25
|
+
* uses for Story-level signals.
|
|
26
|
+
*
|
|
27
|
+
* A caller may always override the destination via `ledgerPath` (tests,
|
|
28
|
+
* or a future caller with a non-default temp layout).
|
|
29
|
+
*
|
|
30
|
+
* Distinct from:
|
|
31
|
+
* - `epic.merge.blocked` — AutomergePredicate's "not safe to arm yet"
|
|
32
|
+
* signal, evaluated BEFORE arming. `merge.unlanded` fires AFTER a
|
|
33
|
+
* delivery flow has already finished trying and gives up.
|
|
34
|
+
* - `epic.blocked` / `story.blocked` — the generic `agent::blocked`
|
|
35
|
+
* transition signal. `merge.unlanded` is the merge-specific
|
|
36
|
+
* diagnosis a `*.blocked` transition is typically paired with, not a
|
|
37
|
+
* replacement for it.
|
|
38
|
+
*
|
|
39
|
+
* The emit is best-effort in the sense that a failure to append MUST NOT
|
|
40
|
+
* mask the underlying blocked-state transition the caller is already
|
|
41
|
+
* driving — callers should treat this the same way
|
|
42
|
+
* `emitStoryHeartbeat` documents: catch, log, and proceed with the label
|
|
43
|
+
* flip / friction comment regardless.
|
|
44
|
+
*
|
|
45
|
+
* Schema contract (merge.unlanded.schema.json):
|
|
46
|
+
* { event, scope, ticketId, prNumber, blockClass, reason,
|
|
47
|
+
* elapsedSeconds, timestamp? }
|
|
48
|
+
*
|
|
49
|
+
* The schema declares `additionalProperties: false`, so this emitter's
|
|
50
|
+
* signature is deliberately narrow: only the schema-allowed fields are
|
|
51
|
+
* accepted. `blockClass` MUST be one of the four classes named in
|
|
52
|
+
* `merge-block-class.js` — pass the classifier's verdict straight
|
|
53
|
+
* through (`classifyMergeBlock(...)` returns `{ blockClass, reason }`).
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
57
|
+
import path from 'node:path';
|
|
58
|
+
import { fileURLToPath } from 'node:url';
|
|
59
|
+
|
|
60
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
61
|
+
import addFormats from 'ajv-formats';
|
|
62
|
+
|
|
63
|
+
import { epicLedgerPath, storyLedgerPath } from '../../config/temp-paths.js';
|
|
64
|
+
import { isValidBlockClass } from '../merge-block-class.js';
|
|
65
|
+
|
|
66
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
67
|
+
const SCHEMA_PATH = path.resolve(
|
|
68
|
+
__dirname,
|
|
69
|
+
'..',
|
|
70
|
+
'..',
|
|
71
|
+
'..',
|
|
72
|
+
'..',
|
|
73
|
+
'schemas',
|
|
74
|
+
'lifecycle',
|
|
75
|
+
'merge.unlanded.schema.json',
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const VALID_SCOPES = new Set(['epic', 'story']);
|
|
79
|
+
|
|
80
|
+
let _validator;
|
|
81
|
+
|
|
82
|
+
function getValidator() {
|
|
83
|
+
if (_validator) return _validator;
|
|
84
|
+
const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
|
|
85
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
86
|
+
addFormats(ajv);
|
|
87
|
+
_validator = ajv.compile(schema);
|
|
88
|
+
return _validator;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Append exactly one `merge.unlanded` NDJSON record to the resolved
|
|
93
|
+
* lifecycle ledger.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} opts
|
|
96
|
+
* @param {'epic'|'story'} opts.scope Which delivery path is reporting the
|
|
97
|
+
* unlanded merge.
|
|
98
|
+
* @param {number} opts.ticketId epicId when `scope === 'epic'`,
|
|
99
|
+
* storyId when `scope === 'story'`.
|
|
100
|
+
* @param {number} opts.prNumber The PR number that did not land.
|
|
101
|
+
* @param {string} opts.blockClass One of the four
|
|
102
|
+
* `merge-block-class.js` classes.
|
|
103
|
+
* @param {string} opts.reason Free-form diagnosis detail — pass
|
|
104
|
+
* the classifier's `reason`.
|
|
105
|
+
* @param {number} opts.elapsedSeconds Elapsed watch/poll time when the
|
|
106
|
+
* run gave up.
|
|
107
|
+
* @param {string} [opts.timestamp] ISO-8601 wall clock. Defaults to
|
|
108
|
+
* now().
|
|
109
|
+
* @param {object} [opts.config] Optional resolved config for
|
|
110
|
+
* tempRoot.
|
|
111
|
+
* @param {string} [opts.ledgerPath] Override for tests / non-default
|
|
112
|
+
* layouts.
|
|
113
|
+
* @returns {{ ledgerPath: string, record: object }}
|
|
114
|
+
*/
|
|
115
|
+
export function emitMergeUnlanded(opts) {
|
|
116
|
+
const {
|
|
117
|
+
scope,
|
|
118
|
+
ticketId,
|
|
119
|
+
prNumber,
|
|
120
|
+
blockClass,
|
|
121
|
+
reason,
|
|
122
|
+
elapsedSeconds,
|
|
123
|
+
timestamp = new Date().toISOString(),
|
|
124
|
+
config,
|
|
125
|
+
ledgerPath: ledgerPathOverride,
|
|
126
|
+
} = opts ?? {};
|
|
127
|
+
|
|
128
|
+
if (!VALID_SCOPES.has(scope)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`emitMergeUnlanded: scope "${scope}" must be one of: ${[...VALID_SCOPES].join(', ')}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (!Number.isInteger(ticketId) || ticketId < 1) {
|
|
134
|
+
throw new Error('emitMergeUnlanded: ticketId must be a positive integer');
|
|
135
|
+
}
|
|
136
|
+
if (!Number.isInteger(prNumber) || prNumber < 1) {
|
|
137
|
+
throw new Error('emitMergeUnlanded: prNumber must be a positive integer');
|
|
138
|
+
}
|
|
139
|
+
if (!isValidBlockClass(blockClass)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`emitMergeUnlanded: blockClass "${blockClass}" is not a recognised merge-block-class value`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (typeof reason !== 'string' || reason.length === 0) {
|
|
145
|
+
throw new Error('emitMergeUnlanded: reason must be a non-empty string');
|
|
146
|
+
}
|
|
147
|
+
if (typeof elapsedSeconds !== 'number' || elapsedSeconds < 0) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'emitMergeUnlanded: elapsedSeconds must be a non-negative number',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const payload = {
|
|
154
|
+
event: 'merge.unlanded',
|
|
155
|
+
scope,
|
|
156
|
+
ticketId,
|
|
157
|
+
prNumber,
|
|
158
|
+
blockClass,
|
|
159
|
+
reason,
|
|
160
|
+
elapsedSeconds,
|
|
161
|
+
timestamp,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const validator = getValidator();
|
|
165
|
+
if (!validator(payload)) {
|
|
166
|
+
const detail = (validator.errors ?? [])
|
|
167
|
+
.map((e) => `${e.instancePath || '/'} ${e.message}`)
|
|
168
|
+
.join('; ');
|
|
169
|
+
throw new Error(
|
|
170
|
+
`emitMergeUnlanded: payload failed schema validation: ${detail}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const ledgerPath =
|
|
175
|
+
ledgerPathOverride ??
|
|
176
|
+
(scope === 'epic'
|
|
177
|
+
? epicLedgerPath(ticketId, config)
|
|
178
|
+
: storyLedgerPath(null, ticketId, config));
|
|
179
|
+
mkdirSync(path.dirname(ledgerPath), { recursive: true });
|
|
180
|
+
const record = {
|
|
181
|
+
kind: 'emitted',
|
|
182
|
+
ts: timestamp,
|
|
183
|
+
event: 'merge.unlanded',
|
|
184
|
+
payload,
|
|
185
|
+
};
|
|
186
|
+
appendFileSync(ledgerPath, `${JSON.stringify(record)}\n`, 'utf8');
|
|
187
|
+
return { ledgerPath, record };
|
|
188
|
+
}
|
|
@@ -22,6 +22,12 @@ entrypoint the standalone `lifecycle-emit.js` CLI shells in for
|
|
|
22
22
|
the PR's `mergeCommit` is observed, then emits `epic.merge.confirmed`.
|
|
23
23
|
- `cleaner.js` — archives `temp/epic-<id>/` and emits the terminal
|
|
24
24
|
`epic.cleanup.* → epic.complete` sequence on `epic.merge.confirmed`.
|
|
25
|
+
- `label-transitioner.js` — flips the Epic ticket to `agent::done` (and
|
|
26
|
+
closes it as completed, idempotently) on `epic.complete`. Skipped when
|
|
27
|
+
no provider is wired. Re-homed onto this chain after the 2026-07-11
|
|
28
|
+
incident (merged Epics stranding at `agent::executing`): the original
|
|
29
|
+
LabelTransitioner died with the in-process runner stratum (#3936) and
|
|
30
|
+
the flip had no owner.
|
|
25
31
|
- `checkpoint-pointer-writer.js` — persists `{ lastCompletedSeqId, phase }`
|
|
26
32
|
on every `*.end` event.
|
|
27
33
|
|
|
@@ -64,7 +64,7 @@ import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
|
|
|
64
64
|
export function ghPrViewAutoMerge({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
65
65
|
const result = spawnFn(
|
|
66
66
|
'gh',
|
|
67
|
-
['pr', 'view', prUrl, '--json', 'autoMergeRequest'],
|
|
67
|
+
['pr', 'view', prUrl, '--json', 'autoMergeRequest,mergeCommit'],
|
|
68
68
|
{ cwd, encoding: 'utf-8', shell: false },
|
|
69
69
|
);
|
|
70
70
|
return {
|
|
@@ -110,9 +110,10 @@ export function ghPrMergeAuto({
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
* Pure: parse `gh pr view --json autoMergeRequest` output.
|
|
114
|
-
* is `null` when auto-merge is NOT armed; a non-null
|
|
115
|
-
* prior arm is in place. Returns `true` iff already
|
|
113
|
+
* Pure: parse `gh pr view --json autoMergeRequest,mergeCommit` output.
|
|
114
|
+
* `autoMergeRequest` is `null` when auto-merge is NOT armed; a non-null
|
|
115
|
+
* object means a prior arm is in place. Returns `true` iff already
|
|
116
|
+
* armed.
|
|
116
117
|
*
|
|
117
118
|
* Exported for tests so the JSON shape pin is reviewable.
|
|
118
119
|
*/
|
|
@@ -134,6 +135,36 @@ export function parseAutoMergeArmed(stdout) {
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Pure: parse the same probe output for a non-null `mergeCommit` — the
|
|
140
|
+
* PR has ALREADY merged. `gh pr merge --auto` on an already-green PR
|
|
141
|
+
* merges immediately (there is nothing to queue behind), and its exit
|
|
142
|
+
* code then reflects the post-merge local housekeeping: on 2026-07-11
|
|
143
|
+
* (Epic #4454, PR #4459) the merge itself succeeded but the trailing
|
|
144
|
+
* local `--delete-branch` failed because a harness worktree held
|
|
145
|
+
* `epic/4454`, so `gh` exited 1 and the armer misreported a successful
|
|
146
|
+
* merge as `arm-failed` — stranding the run at `agent::blocked` with
|
|
147
|
+
* the whole must-land chain (MergeWatcher → Cleaner → LabelTransitioner)
|
|
148
|
+
* never engaging. The arm-failure re-probe below uses this parser to
|
|
149
|
+
* distinguish "merge landed, housekeeping grumbled" from a genuine arm
|
|
150
|
+
* failure.
|
|
151
|
+
*/
|
|
152
|
+
export function parsePrMerged(stdout) {
|
|
153
|
+
const trimmed = String(stdout ?? '').trim();
|
|
154
|
+
if (trimmed.length === 0) return false;
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(trimmed);
|
|
157
|
+
if (!parsed || typeof parsed !== 'object') return false;
|
|
158
|
+
return (
|
|
159
|
+
parsed.mergeCommit !== null &&
|
|
160
|
+
parsed.mergeCommit !== undefined &&
|
|
161
|
+
typeof parsed.mergeCommit === 'object'
|
|
162
|
+
);
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
137
168
|
/**
|
|
138
169
|
* AutomergeArmer listener.
|
|
139
170
|
*/
|
|
@@ -208,13 +239,17 @@ export class AutomergeArmer {
|
|
|
208
239
|
}
|
|
209
240
|
|
|
210
241
|
// Layer 2 idempotency — cross-process probe. If auto-merge is
|
|
211
|
-
// already armed on the PR
|
|
212
|
-
//
|
|
242
|
+
// already armed on the PR (or the PR already merged — a prior arm
|
|
243
|
+
// completed the merge before this run re-entered), emit
|
|
244
|
+
// `epic.merge.armed` and bail without re-issuing the merge command.
|
|
213
245
|
const probe = this.ghPrViewAutoMergeFn({ prUrl, cwd: this.cwd });
|
|
214
|
-
if (
|
|
246
|
+
if (
|
|
247
|
+
probe.status === 0 &&
|
|
248
|
+
(parseAutoMergeArmed(probe.stdout) || parsePrMerged(probe.stdout))
|
|
249
|
+
) {
|
|
215
250
|
this.classifications.push({ event, seqId, outcome: 'existing', prUrl });
|
|
216
251
|
this.logger.info?.(
|
|
217
|
-
`[AutomergeArmer] auto-merge already armed on ${prUrl} — short-circuiting.`,
|
|
252
|
+
`[AutomergeArmer] auto-merge already armed (or PR already merged) on ${prUrl} — short-circuiting.`,
|
|
218
253
|
);
|
|
219
254
|
await this._emitArmed(prUrl);
|
|
220
255
|
return;
|
|
@@ -232,6 +267,32 @@ export class AutomergeArmer {
|
|
|
232
267
|
// entire codebase (see check-lifecycle-lint.js).
|
|
233
268
|
const arm = this.ghPrMergeAutoFn({ prUrl, cwd: this.cwd });
|
|
234
269
|
if (arm.status !== 0) {
|
|
270
|
+
// A non-zero arm exit does NOT necessarily mean the arm failed:
|
|
271
|
+
// `gh pr merge --auto --squash --delete-branch` on an
|
|
272
|
+
// already-green PR merges immediately and then runs local
|
|
273
|
+
// branch-delete housekeeping whose failure (e.g. the epic branch
|
|
274
|
+
// held by another worktree) surfaces as exit 1 AFTER the merge
|
|
275
|
+
// landed (2026-07-11 incident, Epic #4454 / PR #4459). Re-probe
|
|
276
|
+
// before classifying: merged or armed → proceed as success so the
|
|
277
|
+
// MergeWatcher → Cleaner → LabelTransitioner chain engages.
|
|
278
|
+
const recheck = this.ghPrViewAutoMergeFn({ prUrl, cwd: this.cwd });
|
|
279
|
+
if (
|
|
280
|
+
recheck.status === 0 &&
|
|
281
|
+
(parsePrMerged(recheck.stdout) || parseAutoMergeArmed(recheck.stdout))
|
|
282
|
+
) {
|
|
283
|
+
this.classifications.push({
|
|
284
|
+
event,
|
|
285
|
+
seqId,
|
|
286
|
+
outcome: 'armed',
|
|
287
|
+
prUrl,
|
|
288
|
+
note: `arm exit ${arm.status} but re-probe shows merged/armed (post-merge housekeeping failure): ${arm.stderr}`,
|
|
289
|
+
});
|
|
290
|
+
this.logger.warn?.(
|
|
291
|
+
`[AutomergeArmer] gh pr merge exited ${arm.status} but the PR is merged/armed — treating as success (housekeeping stderr: ${arm.stderr})`,
|
|
292
|
+
);
|
|
293
|
+
await this._emitArmed(prUrl);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
235
296
|
this.classifications.push({
|
|
236
297
|
event,
|
|
237
298
|
seqId,
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
|
|
67
67
|
import { spawnSync } from 'node:child_process';
|
|
68
68
|
|
|
69
|
+
import { hasSurvivingCritical } from '../../../audit-suite/findings.js';
|
|
69
70
|
import { getCiDelivery } from '../../../config/ci.js';
|
|
70
71
|
import * as epicRunStateStore from '../../epic-run-state-store.js';
|
|
71
72
|
import { findStructuredComment } from '../../ticketing.js';
|
|
@@ -441,7 +442,11 @@ function evaluateCodeReviewSignals(codeReview, reasons) {
|
|
|
441
442
|
if (codeReviewUnparseable) {
|
|
442
443
|
return { codeReviewFound, codeReviewUnparseable, severity };
|
|
443
444
|
}
|
|
444
|
-
|
|
445
|
+
// Route the halt-on-critical decision through the single halting rule of
|
|
446
|
+
// the unified verification-results contract (Story #4411) rather than a
|
|
447
|
+
// re-derived `critical > 0` expression. The unparseable branch above has
|
|
448
|
+
// already returned, so `severity.critical` is a concrete number here.
|
|
449
|
+
if (hasSurvivingCritical(severity)) {
|
|
445
450
|
pushReason(
|
|
446
451
|
reasons,
|
|
447
452
|
REASON_CATEGORY.CRITICAL_REVIEW,
|
|
@@ -648,7 +653,11 @@ export async function evaluateAutoMergePredicate({
|
|
|
648
653
|
// only IO, but the rule is directory-scoped; sequencing here is a
|
|
649
654
|
// cheap concession for living inside the listener tree.
|
|
650
655
|
const state = await readRunStateFn({ provider, epicId });
|
|
651
|
-
const codeReview = await findCommentFn(
|
|
656
|
+
const codeReview = await findCommentFn(
|
|
657
|
+
provider,
|
|
658
|
+
epicId,
|
|
659
|
+
'verification-results',
|
|
660
|
+
);
|
|
652
661
|
let retro = await findCommentFn(provider, epicId, 'retro');
|
|
653
662
|
if (!retro) {
|
|
654
663
|
retro = await findCommentFn(provider, epicId, 'retro-partial');
|
|
@@ -16,8 +16,15 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Side effects executed inside `handle()`:
|
|
18
18
|
* 1. Emit `epic.finalize.start`.
|
|
19
|
-
* 2. Auto-graduate non-blocking
|
|
20
|
-
* (best-effort; never throws).
|
|
19
|
+
* 2. Auto-graduate non-blocking findings in a SINGLE pass over the
|
|
20
|
+
* unified `verification-results` comment (best-effort; never throws).
|
|
21
|
+
* Story #4411 folded the former code-review + audit-results
|
|
22
|
+
* structured-comment contracts into one comment; the lens-aware
|
|
23
|
+
* audit-results graduator is the single canonical reader (its
|
|
24
|
+
* 🟢→`suggestion` severity vocabulary matches `findings-renderer.js`).
|
|
25
|
+
* Running a second code-review pass over the same comment would file
|
|
26
|
+
* every non-blocking finding twice, so only the audit-results
|
|
27
|
+
* graduation runs here.
|
|
21
28
|
* 3. Idempotency probe — `gh pr list --head epic/<id>` returns any
|
|
22
29
|
* existing PR URL. If one exists, short-circuit to `pr.created`
|
|
23
30
|
* + `epic.finalize.end` carrying the existing URL.
|
|
@@ -69,7 +76,6 @@ import {
|
|
|
69
76
|
graduateAuditResults as defaultGraduateAuditResults,
|
|
70
77
|
isAutoFileEnabled as isAuditResultsAutoFileEnabled,
|
|
71
78
|
} from '../../../feedback-loop/audit-results-graduator.js';
|
|
72
|
-
import { graduateFindings as defaultGraduateFindings } from '../../../feedback-loop/code-review-graduator.js';
|
|
73
79
|
import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
74
80
|
import {
|
|
75
81
|
markPrReady as defaultMarkPrReady,
|
|
@@ -235,17 +241,39 @@ export function extractPrUrl(stdout) {
|
|
|
235
241
|
return match ? match[0] : null;
|
|
236
242
|
}
|
|
237
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Default bounded timeout for the finalize idempotency probe. Story #4415
|
|
246
|
+
* (Epic #4406) — a hung `gh pr list` spawn previously blocked finalize
|
|
247
|
+
* forever; the probe now SIGKILLs the child at this bound so a stalled
|
|
248
|
+
* `gh` cannot park the merge gate.
|
|
249
|
+
*/
|
|
250
|
+
export const GH_PR_LIST_TIMEOUT_MS = 30000;
|
|
251
|
+
|
|
238
252
|
/**
|
|
239
253
|
* Default `gh` spawn used by the listener's idempotency probe.
|
|
240
254
|
* Mirrors the `shell: false` contract `openOrLocatePr` and the other
|
|
241
255
|
* listener helpers use so a future Windows audit doesn't have to grep
|
|
242
|
-
* across two modules.
|
|
256
|
+
* across two modules. Bounded by `timeoutMs` (default
|
|
257
|
+
* {@link GH_PR_LIST_TIMEOUT_MS}) + `killSignal: 'SIGKILL'` so a hung `gh`
|
|
258
|
+
* spawn cannot block finalize indefinitely (Story #4415). Exported so
|
|
259
|
+
* tests can stub.
|
|
243
260
|
*/
|
|
244
|
-
export function ghPrListHead({
|
|
261
|
+
export function ghPrListHead({
|
|
262
|
+
epicBranch,
|
|
263
|
+
cwd,
|
|
264
|
+
spawnFn = spawnSync,
|
|
265
|
+
timeoutMs = GH_PR_LIST_TIMEOUT_MS,
|
|
266
|
+
}) {
|
|
245
267
|
const result = spawnFn(
|
|
246
268
|
'gh',
|
|
247
269
|
['pr', 'list', '--head', epicBranch, '--json', 'url', '--jq', '.[0].url'],
|
|
248
|
-
{
|
|
270
|
+
{
|
|
271
|
+
cwd,
|
|
272
|
+
encoding: 'utf-8',
|
|
273
|
+
shell: false,
|
|
274
|
+
timeout: timeoutMs,
|
|
275
|
+
killSignal: 'SIGKILL',
|
|
276
|
+
},
|
|
249
277
|
);
|
|
250
278
|
return {
|
|
251
279
|
status: result.status ?? 1,
|
|
@@ -276,7 +304,6 @@ export class Finalizer {
|
|
|
276
304
|
* the graduators.
|
|
277
305
|
* @param {{owner:string,repo:string}} [opts.currentRepo]
|
|
278
306
|
* @param {{owner:string,repo:string}} [opts.frameworkRepo]
|
|
279
|
-
* @param {Function} [opts.graduateFindingsFn]
|
|
280
307
|
* @param {Function} [opts.graduateAuditResultsFn]
|
|
281
308
|
* @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
|
|
282
309
|
*/
|
|
@@ -314,8 +341,6 @@ export class Finalizer {
|
|
|
314
341
|
this.config = opts.config ?? null;
|
|
315
342
|
this.currentRepo = opts.currentRepo ?? null;
|
|
316
343
|
this.frameworkRepo = opts.frameworkRepo ?? null;
|
|
317
|
-
this.graduateFindingsFn =
|
|
318
|
-
opts.graduateFindingsFn ?? defaultGraduateFindings;
|
|
319
344
|
this.graduateAuditResultsFn =
|
|
320
345
|
opts.graduateAuditResultsFn ?? defaultGraduateAuditResults;
|
|
321
346
|
this.logger = opts.logger ?? console;
|
|
@@ -373,10 +398,11 @@ export class Finalizer {
|
|
|
373
398
|
return;
|
|
374
399
|
}
|
|
375
400
|
|
|
376
|
-
// 1b. Auto-graduate non-blocking
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
//
|
|
401
|
+
// 1b. Auto-graduate non-blocking findings in a SINGLE pass over the
|
|
402
|
+
// unified `verification-results` comment (Story #2615; unified in
|
|
403
|
+
// Story #4411). The lens-aware audit-results graduator is the sole
|
|
404
|
+
// canonical reader — a second code-review pass over the same comment
|
|
405
|
+
// would file every non-blocking finding twice.
|
|
380
406
|
await this._runAuditResultsGraduation();
|
|
381
407
|
|
|
382
408
|
// 2. Idempotency probe — does a PR already exist on the head
|
|
@@ -498,54 +524,14 @@ export class Finalizer {
|
|
|
498
524
|
}
|
|
499
525
|
|
|
500
526
|
/**
|
|
501
|
-
* Invoke the
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
* is
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (!this.provider || !this.currentRepo) {
|
|
510
|
-
this.logger.debug?.(
|
|
511
|
-
'[Finalizer] code-review graduation skipped: provider or currentRepo not wired',
|
|
512
|
-
);
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
try {
|
|
516
|
-
const summary = await this.graduateFindingsFn({
|
|
517
|
-
epicId: this.epicId,
|
|
518
|
-
provider: this.provider,
|
|
519
|
-
config: this.config,
|
|
520
|
-
currentRepo: this.currentRepo,
|
|
521
|
-
frameworkRepo: this.frameworkRepo,
|
|
522
|
-
cwd: this.cwd,
|
|
523
|
-
logger: this.logger,
|
|
524
|
-
});
|
|
525
|
-
const filed = Array.isArray(summary?.filed) ? summary.filed.length : 0;
|
|
526
|
-
const skipped = Array.isArray(summary?.skipped)
|
|
527
|
-
? summary.skipped.length
|
|
528
|
-
: 0;
|
|
529
|
-
const errors = Array.isArray(summary?.errors) ? summary.errors.length : 0;
|
|
530
|
-
this.logger.info?.(
|
|
531
|
-
`[Finalizer] code-review graduation: filed=${filed} skipped=${skipped} errors=${errors}`,
|
|
532
|
-
);
|
|
533
|
-
if (errors > 0) {
|
|
534
|
-
this.logger.warn?.(
|
|
535
|
-
`[Finalizer] code-review graduator errors: ${summary.errors.join('; ')}`,
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
} catch (err) {
|
|
539
|
-
this.logger.warn?.(
|
|
540
|
-
`[Finalizer] code-review graduator threw (swallowed): ${err?.message ?? err}`,
|
|
541
|
-
);
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Invoke the audit-results graduator best-effort. Wired into finalize
|
|
547
|
-
* so that non-blocking audit findings (high/medium/low/suggestion) get
|
|
548
|
-
* auto-filed as routed follow-up issues — Story #2615 / Epic #2586.
|
|
527
|
+
* Invoke the audit-results graduator best-effort — the SINGLE canonical
|
|
528
|
+
* graduation pass over the unified `verification-results` comment. Wired
|
|
529
|
+
* into finalize so that non-blocking findings (high/medium/low/suggestion)
|
|
530
|
+
* get auto-filed as routed follow-up issues — Story #2615 / Epic #2586,
|
|
531
|
+
* unified into one pass in Story #4411 / Epic #4405. The lens-aware
|
|
532
|
+
* audit-results graduator is canonical because its 🟢→`suggestion`
|
|
533
|
+
* severity vocabulary matches `findings-renderer.js`; running a parallel
|
|
534
|
+
* code-review pass over the same comment would double-file every finding.
|
|
549
535
|
*/
|
|
550
536
|
async _runAuditResultsGraduation() {
|
|
551
537
|
if (!this.provider || !this.currentRepo) {
|
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
* 4. AutomergeArmer (epic.merge.ready → epic.merge.armed)
|
|
19
19
|
* 5. AutomergePredicate (epic.watch.end → epic.merge.{ready,blocked})
|
|
20
20
|
* 6. BranchCleaner (epic.cleanup.start → branch reap)
|
|
21
|
-
* 7.
|
|
22
|
-
* 8.
|
|
21
|
+
* 7. MergeWatcher (epic.merge.armed → epic.merge.confirmed)
|
|
22
|
+
* 8. Cleaner (epic.merge.confirmed → epic.cleanup.* / epic.complete)
|
|
23
|
+
* 9. LabelTransitioner (epic.complete → Epic ticket flips to agent::done)
|
|
24
|
+
* 10. CheckpointPointerWriter (every *.end → checkpoint.json)
|
|
23
25
|
*
|
|
24
26
|
* The bus contract requires LedgerWriter first: its `onEmitted` hook
|
|
25
27
|
* lands the `emitted` ledger record on disk BEFORE any listener body
|
|
@@ -60,6 +62,7 @@ import { BranchCleaner } from './branch-cleaner.js';
|
|
|
60
62
|
import { CheckpointPointerWriter } from './checkpoint-pointer-writer.js';
|
|
61
63
|
import { Cleaner } from './cleaner.js';
|
|
62
64
|
import { Finalizer } from './finalizer.js';
|
|
65
|
+
import { LabelTransitioner } from './label-transitioner.js';
|
|
63
66
|
import { MergeWatcher } from './merge-watcher.js';
|
|
64
67
|
|
|
65
68
|
/**
|
|
@@ -118,6 +121,11 @@ export function parseLedgerPath(ledgerPath) {
|
|
|
118
121
|
* @param {object} [opts.checkpointer] Epic-run-state checkpoint reader.
|
|
119
122
|
* When omitted, BranchCleaner is skipped.
|
|
120
123
|
* @param {object} [opts.logger] Logger surface (`debug`/`warn`/`error`).
|
|
124
|
+
* @param {boolean} [opts.headless] Explicit must-land signal (Story
|
|
125
|
+
* #4427), threaded straight through to `MergeWatcher`. Defaults to
|
|
126
|
+
* `false` (attended-mode behavior unchanged). `lifecycle-emit.js`
|
|
127
|
+
* resolves this from its own `--headless` runtime flag — an explicit
|
|
128
|
+
* input, never an ambient global.
|
|
121
129
|
*
|
|
122
130
|
* @returns {Promise<{
|
|
123
131
|
* ledgerWriter: object,
|
|
@@ -140,6 +148,7 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
140
148
|
config = null,
|
|
141
149
|
checkpointer = null,
|
|
142
150
|
logger = console,
|
|
151
|
+
headless = false,
|
|
143
152
|
} = opts;
|
|
144
153
|
if (
|
|
145
154
|
!bus ||
|
|
@@ -288,6 +297,7 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
288
297
|
cwd: repoRoot,
|
|
289
298
|
intervalSeconds: mergeWatchConfig.intervalSeconds,
|
|
290
299
|
maxBudgetSeconds: mergeWatchConfig.maxBudgetSeconds,
|
|
300
|
+
headless,
|
|
291
301
|
logger,
|
|
292
302
|
});
|
|
293
303
|
mergeWatcher.register();
|
|
@@ -306,7 +316,32 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
306
316
|
cleaner.register();
|
|
307
317
|
order.push('Cleaner');
|
|
308
318
|
|
|
309
|
-
//
|
|
319
|
+
// 9. LabelTransitioner — flips the Epic ticket to `agent::done` (and
|
|
320
|
+
// closes it as completed, idempotently) on the terminal
|
|
321
|
+
// `epic.complete` event. Requires a truthy `provider`; skip
|
|
322
|
+
// cleanly when the caller omitted one — the same guard pattern as
|
|
323
|
+
// AutomergePredicate. Restores the contract the Cleaner /
|
|
324
|
+
// BranchCleaner / MergeWatcher docstrings have referenced since
|
|
325
|
+
// the original listener was deleted with the epic-runner stratum
|
|
326
|
+
// (#3936): without this registration the flip had NO owner and
|
|
327
|
+
// cleanly-merged Epics stranded at `agent::executing`.
|
|
328
|
+
let labelTransitioner = null;
|
|
329
|
+
if (provider) {
|
|
330
|
+
labelTransitioner = new LabelTransitioner({
|
|
331
|
+
bus,
|
|
332
|
+
epicId,
|
|
333
|
+
provider,
|
|
334
|
+
logger,
|
|
335
|
+
});
|
|
336
|
+
labelTransitioner.register();
|
|
337
|
+
order.push('LabelTransitioner');
|
|
338
|
+
} else {
|
|
339
|
+
logger?.debug?.(
|
|
340
|
+
'[lifecycle] buildDefaultListenerChain: skipping LabelTransitioner (no provider)',
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 10. CheckpointPointerWriter — persists `{ lastCompletedSeqId, phase }`
|
|
310
345
|
// on every `*.end` event.
|
|
311
346
|
const checkpointPointerWriter = new CheckpointPointerWriter({
|
|
312
347
|
bus,
|
|
@@ -330,6 +365,7 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
330
365
|
branchCleaner,
|
|
331
366
|
mergeWatcher,
|
|
332
367
|
cleaner,
|
|
368
|
+
labelTransitioner,
|
|
333
369
|
checkpointPointerWriter,
|
|
334
370
|
order,
|
|
335
371
|
};
|