mandrel 2.24.0 → 2.25.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/agents/acceptance-critic.md +13 -19
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +5 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/scripts/acceptance-eval.js +35 -9
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-test-temp-hygiene.js +83 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/writer.js +7 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +97 -19
- package/.agents/scripts/lib/crap-utils.js +73 -13
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/crap-worker.js +6 -3
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/quality-preview.js +25 -0
- package/.agents/scripts/run-tests.js +10 -0
- package/.agents/scripts/single-story-close.js +6 -0
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +95 -10
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +81 -95
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/deliver-reference.md +18 -3
- package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +34 -0
- package/.agents/workflows/plan.md +60 -76
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +1 -1
|
@@ -132,6 +132,29 @@ export function normalizeCheckState(raw) {
|
|
|
132
132
|
*/
|
|
133
133
|
export const extractPrNumber = parsePrNumberFromUrl;
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The `gh --repo` flag pair for an optional `owner/repo` target, or an empty
|
|
137
|
+
* argv fragment when the repository is inferred from the cwd's remote.
|
|
138
|
+
*
|
|
139
|
+
* `gh` resolves a *cross-repository* PR reference only through this flag — it
|
|
140
|
+
* has no `<owner/repo>#<number>` argument form, and a caller that builds one
|
|
141
|
+
* gets it parsed as a **branch name** instead (every `--repo` invocation of the
|
|
142
|
+
* watch CLI failed on that, reported as a misleading `gh-checks-failed`). Pure
|
|
143
|
+
* — one place builds the fragment so no port can forget it.
|
|
144
|
+
*
|
|
145
|
+
* Module-private on purpose: the three ports below are the only callers, and
|
|
146
|
+
* the flag is asserted through them (a real-spawn argv probe), never by
|
|
147
|
+
* importing this helper — an export existing solely for a test is dead in the
|
|
148
|
+
* `--production` reachability ratchet.
|
|
149
|
+
*
|
|
150
|
+
* @param {string|null|undefined} repo `owner/repo`, or nullish to infer.
|
|
151
|
+
* @returns {string[]}
|
|
152
|
+
*/
|
|
153
|
+
function ghRepoFlag(repo) {
|
|
154
|
+
const trimmed = String(repo ?? '').trim();
|
|
155
|
+
return trimmed.length > 0 ? ['--repo', trimmed] : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
135
158
|
/**
|
|
136
159
|
* Default `gh pr checks` spawn. Always invokes with `--required` so the
|
|
137
160
|
* returned set is authoritative for branch-protection gating. The
|
|
@@ -139,7 +162,7 @@ export const extractPrNumber = parsePrNumberFromUrl;
|
|
|
139
162
|
*
|
|
140
163
|
* Exported so tests can stub.
|
|
141
164
|
*/
|
|
142
|
-
function ghPrChecks({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
165
|
+
function ghPrChecks({ prUrl, cwd, repo, spawnFn = spawnSync }) {
|
|
143
166
|
const result = spawnFn(
|
|
144
167
|
'gh',
|
|
145
168
|
[
|
|
@@ -149,6 +172,7 @@ function ghPrChecks({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
149
172
|
'--required',
|
|
150
173
|
'--json',
|
|
151
174
|
'name,state,bucket,workflow',
|
|
175
|
+
...ghRepoFlag(repo),
|
|
152
176
|
],
|
|
153
177
|
{ cwd, encoding: 'utf-8', shell: false },
|
|
154
178
|
);
|
|
@@ -164,10 +188,10 @@ function ghPrChecks({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
164
188
|
* can detect the BEHIND condition (PR head is behind its base branch)
|
|
165
189
|
* AFTER every required check is green. Exported so tests can stub.
|
|
166
190
|
*/
|
|
167
|
-
function ghPrView({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
191
|
+
function ghPrView({ prUrl, cwd, repo, spawnFn = spawnSync }) {
|
|
168
192
|
const result = spawnFn(
|
|
169
193
|
'gh',
|
|
170
|
-
['pr', 'view', prUrl, '--json', 'mergeStateStatus'],
|
|
194
|
+
['pr', 'view', prUrl, '--json', 'mergeStateStatus', ...ghRepoFlag(repo)],
|
|
171
195
|
{ cwd, encoding: 'utf-8', shell: false },
|
|
172
196
|
);
|
|
173
197
|
return {
|
|
@@ -202,12 +226,16 @@ function parseMergeStateStatus(stdout) {
|
|
|
202
226
|
* loop to fast-forward the PR head with its base branch. Exported so
|
|
203
227
|
* tests can stub and assert call counts.
|
|
204
228
|
*/
|
|
205
|
-
function ghPrUpdateBranch({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
206
|
-
const result = spawnFn(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
229
|
+
function ghPrUpdateBranch({ prUrl, cwd, repo, spawnFn = spawnSync }) {
|
|
230
|
+
const result = spawnFn(
|
|
231
|
+
'gh',
|
|
232
|
+
['pr', 'update-branch', prUrl, ...ghRepoFlag(repo)],
|
|
233
|
+
{
|
|
234
|
+
cwd,
|
|
235
|
+
encoding: 'utf-8',
|
|
236
|
+
shell: false,
|
|
237
|
+
},
|
|
238
|
+
);
|
|
211
239
|
return {
|
|
212
240
|
status: result.status ?? 1,
|
|
213
241
|
stdout: result.stdout ?? '',
|
|
@@ -355,6 +383,7 @@ function defaultSleep(ms) {
|
|
|
355
383
|
* @param {object} opts
|
|
356
384
|
* @param {string} opts.prUrl
|
|
357
385
|
* @param {string} opts.cwd
|
|
386
|
+
* @param {string|null} [opts.repo] `owner/repo` passed to `gh` as `--repo`.
|
|
358
387
|
* @param {object} opts.outcomes Initial `{ checkName: outcome }` map.
|
|
359
388
|
* @param {number} opts.polls Current poll counter (mutated in-place by caller).
|
|
360
389
|
* @param {number} opts.maxPolls Hard cap on total poll iterations.
|
|
@@ -367,6 +396,7 @@ function defaultSleep(ms) {
|
|
|
367
396
|
export async function pollUntilTerminal({
|
|
368
397
|
prUrl,
|
|
369
398
|
cwd,
|
|
399
|
+
repo = null,
|
|
370
400
|
outcomes,
|
|
371
401
|
polls,
|
|
372
402
|
maxPolls,
|
|
@@ -380,7 +410,7 @@ export async function pollUntilTerminal({
|
|
|
380
410
|
while (!allTerminal(currentOutcomes) && currentPolls < maxPolls) {
|
|
381
411
|
await sleepFn(pollIntervalMs);
|
|
382
412
|
currentPolls += 1;
|
|
383
|
-
const probe = ghPrChecksFn({ prUrl, cwd });
|
|
413
|
+
const probe = ghPrChecksFn({ prUrl, cwd, repo });
|
|
384
414
|
const entries = parseGhPrChecks(probe.stdout);
|
|
385
415
|
if (entries.length === 0 && probe.status !== 0 && probe.status !== 8) {
|
|
386
416
|
// Transient `gh` failure — log and continue. The outer
|
|
@@ -411,6 +441,9 @@ export async function pollUntilTerminal({
|
|
|
411
441
|
* @param {object} opts
|
|
412
442
|
* @param {string} opts.prUrl PR URL or number (passed to `gh` verbatim).
|
|
413
443
|
* @param {string} opts.cwd
|
|
444
|
+
* @param {string|null} [opts.repo] `owner/repo` target, threaded to every
|
|
445
|
+
* `gh` port as a real `--repo` flag (Story #4890). Nullish infers the
|
|
446
|
+
* repository from the cwd's remote — the behaviour every in-repo caller wants.
|
|
414
447
|
* @param {number} opts.maxPolls Hard cap on total poll iterations per arm.
|
|
415
448
|
* @param {number} opts.maxUpdates Cap on `gh pr update-branch` recovery calls.
|
|
416
449
|
* @param {number} [opts.maxResumes] Story #4358: after the poll cap fires with
|
|
@@ -444,17 +477,19 @@ export async function pollUntilTerminal({
|
|
|
444
477
|
* terminal: boolean,
|
|
445
478
|
* green: boolean,
|
|
446
479
|
* stillRunning: boolean,
|
|
480
|
+
* requiredChecksEmpty?: boolean,
|
|
447
481
|
* error?: string,
|
|
448
482
|
* }>}
|
|
449
483
|
* `outcomes` is schema-valid (no `'pending'` — leftover pending is
|
|
450
484
|
* promoted to `'still-running'` when the cap and resume budget are both
|
|
451
485
|
* exhausted with no failed check). `stillRunning` is true in exactly
|
|
452
|
-
* that case (slow CI, not red). `error`
|
|
453
|
-
*
|
|
486
|
+
* that case (slow CI, not red). `requiredChecksEmpty` / `error` are set
|
|
487
|
+
* only when the first probe resolved NO required-check names.
|
|
454
488
|
*/
|
|
455
489
|
export async function watchPrToTerminal({
|
|
456
490
|
prUrl,
|
|
457
491
|
cwd,
|
|
492
|
+
repo = null,
|
|
458
493
|
maxPolls,
|
|
459
494
|
maxUpdates,
|
|
460
495
|
maxResumes = 0,
|
|
@@ -469,15 +504,25 @@ export async function watchPrToTerminal({
|
|
|
469
504
|
// First probe: resolve the required-check name set at runtime. Reuse a
|
|
470
505
|
// caller-supplied probe (the listener already issued one to resolve the
|
|
471
506
|
// required check names) so we never double-spend the first `gh` call.
|
|
472
|
-
const first = firstProbe ?? ghPrChecksFn({ prUrl, cwd });
|
|
507
|
+
const first = firstProbe ?? ghPrChecksFn({ prUrl, cwd, repo });
|
|
473
508
|
// `gh` exits 8 when checks are still pending; this is expected and
|
|
474
509
|
// does not indicate failure. Any other non-zero status with no
|
|
475
510
|
// parseable JSON body is a genuine failure.
|
|
476
511
|
const firstEntries = parseGhPrChecks(first.stdout);
|
|
477
|
-
if (firstEntries.length === 0
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
512
|
+
if (firstEntries.length === 0) {
|
|
513
|
+
// NO required-check name resolved. Never enter the poll loop on that:
|
|
514
|
+
// `allTerminal({})` is vacuously true, so the loop would exit on its
|
|
515
|
+
// first evaluation and report a terminal-but-not-green arm — a red
|
|
516
|
+
// verdict with no failing check in it (Story #4890). The name set is
|
|
517
|
+
// resolved exactly once per call, so converging on a context that
|
|
518
|
+
// attaches later means calling this function again; return the
|
|
519
|
+
// empty-set signal and let the caller's attach window re-resolve it.
|
|
520
|
+
const ghFaulted = first.status !== 0 && first.status !== 8;
|
|
521
|
+
if (ghFaulted) {
|
|
522
|
+
logger.warn?.(
|
|
523
|
+
`[Watcher] gh pr checks failed (status=${first.status}): ${first.stderr}`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
481
526
|
return {
|
|
482
527
|
outcomes: {},
|
|
483
528
|
requiredChecks: [],
|
|
@@ -487,7 +532,12 @@ export async function watchPrToTerminal({
|
|
|
487
532
|
terminal: false,
|
|
488
533
|
green: false,
|
|
489
534
|
stillRunning: false,
|
|
490
|
-
|
|
535
|
+
requiredChecksEmpty: true,
|
|
536
|
+
// `gh` overloads a non-zero exit for "no required check is attached
|
|
537
|
+
// right now" AND for a genuine fault, and its stderr prose is not a
|
|
538
|
+
// contract — so the status is reported and the *classification* is the
|
|
539
|
+
// caller's, made against a structural probe of the PR itself.
|
|
540
|
+
error: `gh-checks-${ghFaulted ? 'failed' : 'empty'}:status=${first.status}`,
|
|
491
541
|
};
|
|
492
542
|
}
|
|
493
543
|
|
|
@@ -512,6 +562,7 @@ export async function watchPrToTerminal({
|
|
|
512
562
|
({ outcomes, polls } = await pollUntilTerminal({
|
|
513
563
|
prUrl,
|
|
514
564
|
cwd,
|
|
565
|
+
repo,
|
|
515
566
|
outcomes,
|
|
516
567
|
polls,
|
|
517
568
|
maxPolls,
|
|
@@ -529,7 +580,7 @@ export async function watchPrToTerminal({
|
|
|
529
580
|
// indefinitely.
|
|
530
581
|
if (!allTerminal(outcomes) || !allGreen(outcomes)) break;
|
|
531
582
|
if (updatesApplied >= maxUpdates) break;
|
|
532
|
-
const view = ghPrViewFn({ prUrl, cwd });
|
|
583
|
+
const view = ghPrViewFn({ prUrl, cwd, repo });
|
|
533
584
|
if (view.status !== 0) {
|
|
534
585
|
logger.warn?.(
|
|
535
586
|
`[Watcher] gh pr view failed (status=${view.status}): ${view.stderr}`,
|
|
@@ -538,7 +589,7 @@ export async function watchPrToTerminal({
|
|
|
538
589
|
}
|
|
539
590
|
const mergeStateStatus = parseMergeStateStatus(view.stdout);
|
|
540
591
|
if (mergeStateStatus !== 'BEHIND') break;
|
|
541
|
-
const update = ghPrUpdateBranchFn({ prUrl, cwd });
|
|
592
|
+
const update = ghPrUpdateBranchFn({ prUrl, cwd, repo });
|
|
542
593
|
if (update.status !== 0) {
|
|
543
594
|
logger.warn?.(
|
|
544
595
|
`[Watcher] gh pr update-branch failed (status=${update.status}): ${update.stderr}`,
|
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
|
|
24
24
|
import { computeChangeSet } from './change-set.js';
|
|
25
25
|
import { readNumstatRows, summarizeDiffMagnitude } from './diff-magnitude.js';
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
handleBlockedBackstop,
|
|
28
|
+
preserveRefusedWork,
|
|
29
|
+
} from './light-escalation.js';
|
|
27
30
|
import { checkLightDiffBackstop } from './light-suitability.js';
|
|
28
31
|
|
|
29
32
|
/** Exit code when the diff backstop blocked the land. */
|
|
@@ -66,16 +69,25 @@ function runDiffBackstop({
|
|
|
66
69
|
* with: the verdict, the recycle command on a refusal (`null` when clean), the
|
|
67
70
|
* exit code, and the log line.
|
|
68
71
|
*
|
|
72
|
+
* A refusal also **preserves** the work before it reports (Story #4875): the
|
|
73
|
+
* implementation is finished and the recycle command hands the receipt to
|
|
74
|
+
* `/plan`, so leaving it on an untracked local branch that routine cleanup may
|
|
75
|
+
* reap is not an acceptable end state. Preservation is best-effort and its
|
|
76
|
+
* outcome is reported either way — a failed push degrades the message, never
|
|
77
|
+
* the verdict or the exit code.
|
|
78
|
+
*
|
|
69
79
|
* @param {{
|
|
70
80
|
* storyId: number,
|
|
71
81
|
* runFn?: typeof runDiffBackstop,
|
|
72
82
|
* handleBlockedFn?: typeof handleBlockedBackstop,
|
|
83
|
+
* preserveFn?: typeof preserveRefusedWork,
|
|
73
84
|
* }} args Any further keys (`baseRef`, `cwd`, `computeFn`, `readRowsFn`,
|
|
74
85
|
* `injectedRules`) forward to the backstop run, so the git-surface join is
|
|
75
86
|
* drivable through this one entry point.
|
|
76
87
|
* @returns {Promise<{
|
|
77
88
|
* result: ReturnType<typeof checkLightDiffBackstop>,
|
|
78
89
|
* nextCommand: string|null,
|
|
90
|
+
* preservation: ReturnType<typeof preserveRefusedWork>|null,
|
|
79
91
|
* exitCode: number,
|
|
80
92
|
* message: string,
|
|
81
93
|
* }>}
|
|
@@ -84,6 +96,7 @@ export async function resolveBackstopOutcome({
|
|
|
84
96
|
storyId,
|
|
85
97
|
runFn = runDiffBackstop,
|
|
86
98
|
handleBlockedFn = handleBlockedBackstop,
|
|
99
|
+
preserveFn = preserveRefusedWork,
|
|
87
100
|
...seams
|
|
88
101
|
} = {}) {
|
|
89
102
|
const result = runFn({ storyId, ...seams });
|
|
@@ -91,17 +104,21 @@ export async function resolveBackstopOutcome({
|
|
|
91
104
|
return {
|
|
92
105
|
result,
|
|
93
106
|
nextCommand: null,
|
|
107
|
+
preservation: null,
|
|
94
108
|
exitCode: 0,
|
|
95
109
|
message: `[deliver-light] diff backstop clean for Story #${storyId}.`,
|
|
96
110
|
};
|
|
97
111
|
}
|
|
98
|
-
const
|
|
112
|
+
const preservation = preserveFn({ storyId, cwd: seams.cwd });
|
|
113
|
+
const nextCommand = await handleBlockedFn({ storyId, result, preservation });
|
|
99
114
|
return {
|
|
100
115
|
result,
|
|
101
116
|
nextCommand,
|
|
117
|
+
preservation,
|
|
102
118
|
exitCode: EXIT_BACKSTOP_BLOCKED,
|
|
103
119
|
message:
|
|
104
120
|
`[deliver-light] diff backstop BLOCKED Story #${storyId}: ` +
|
|
105
|
-
`${result.reasons.join('; ')} —
|
|
121
|
+
`${result.reasons.join('; ')} — ${preservation.detail}; ` +
|
|
122
|
+
`recycle the receipt with "${nextCommand}"`,
|
|
106
123
|
};
|
|
107
124
|
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* lib/orchestration/light-escalation.js — what the light path does when it
|
|
3
3
|
* refuses a scope (Story #4856).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* a verdict — the verdicts live in
|
|
5
|
+
* Three behaviors, each previously missing, and each about a refusal rather
|
|
6
|
+
* than a verdict — the verdicts live in
|
|
7
7
|
* {@link module:lib/orchestration/light-suitability}:
|
|
8
8
|
*
|
|
9
9
|
* 1. **Recycling the receipt.** A blocked diff backstop used to tell the
|
|
@@ -25,12 +25,20 @@
|
|
|
25
25
|
* roll-up aggregates by category, so recording these makes the ceilings
|
|
26
26
|
* recalibratable from evidence.
|
|
27
27
|
*
|
|
28
|
+
* 3. **Preserving the refused work** ({@link preserveRefusedWork}, Story
|
|
29
|
+
* #4875). A refusal used to leave a finished implementation on a local
|
|
30
|
+
* `story-<id>` branch with no remote ref — the one shape routine branch
|
|
31
|
+
* and worktree cleanup is entitled to delete. The branch is published to
|
|
32
|
+
* `origin` (no PR, no merge) so the recycle command has something to
|
|
33
|
+
* recycle.
|
|
34
|
+
*
|
|
28
35
|
* Telemetry is best-effort by construction: a signals-write failure must never
|
|
29
36
|
* change a gate's verdict.
|
|
30
37
|
*
|
|
31
38
|
* @module lib/orchestration/light-escalation
|
|
32
39
|
*/
|
|
33
40
|
|
|
41
|
+
import { getStoryBranch, gitSpawn } from '../git-utils.js';
|
|
34
42
|
import {
|
|
35
43
|
emitRuntimeFriction,
|
|
36
44
|
RUNTIME_FRICTION_CATEGORIES,
|
|
@@ -106,14 +114,16 @@ export async function recordGateRefusal({
|
|
|
106
114
|
* @param {{
|
|
107
115
|
* storyId: number,
|
|
108
116
|
* result: object,
|
|
117
|
+
* preservation?: ReturnType<typeof preserveRefusedWork>,
|
|
109
118
|
* recordFrictionFn?: typeof recordScopeFriction,
|
|
110
119
|
* }} args `result` is a {@link module:lib/orchestration/light-suitability.checkLightDiffBackstop}
|
|
111
|
-
* verdict.
|
|
120
|
+
* verdict; `preservation` is the {@link preserveRefusedWork} outcome.
|
|
112
121
|
* @returns {Promise<string>} The recycle command.
|
|
113
122
|
*/
|
|
114
123
|
export async function handleBlockedBackstop({
|
|
115
124
|
storyId,
|
|
116
125
|
result,
|
|
126
|
+
preservation,
|
|
117
127
|
emitFn,
|
|
118
128
|
recordFrictionFn = recordScopeFriction,
|
|
119
129
|
} = {}) {
|
|
@@ -128,11 +138,80 @@ export async function handleBlockedBackstop({
|
|
|
128
138
|
implLines: result?.magnitude?.implLines ?? null,
|
|
129
139
|
ceilings: result?.ceilings ?? null,
|
|
130
140
|
classes: result?.classes ?? [],
|
|
141
|
+
// A refusal whose work was NOT preserved is a different (worse) event
|
|
142
|
+
// than one whose branch reached origin — the roll-up must be able to
|
|
143
|
+
// tell them apart.
|
|
144
|
+
preserved: preservation?.preserved ?? null,
|
|
131
145
|
},
|
|
132
146
|
});
|
|
133
147
|
return buildRecycleCommand(storyId);
|
|
134
148
|
}
|
|
135
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Publish a refused light run's branch to `origin` so the finished work is
|
|
152
|
+
* recoverable (Story #4875).
|
|
153
|
+
*
|
|
154
|
+
* A blocked backstop refuses the *land*, not the *work*: the implementation is
|
|
155
|
+
* complete and the recycle command hands the receipt to `/plan`, which will
|
|
156
|
+
* want it. Before this, that work existed only as a local `story-<id>` branch
|
|
157
|
+
* with no remote ref — an untracked branch is exactly what the routine merged-
|
|
158
|
+
* branch sweeps and worktree reaping treat as disposable, so the only copy of a
|
|
159
|
+
* finished implementation sat one cleanup away from deletion.
|
|
160
|
+
*
|
|
161
|
+
* Pushing is deliberately **not** a landing: the branch gets a remote ref, no
|
|
162
|
+
* PR is opened, and nothing merges. Total by construction — a push failure
|
|
163
|
+
* (offline, no write access, no such branch) is reported and never changes the
|
|
164
|
+
* refusal verdict, because a preservation attempt must not be able to turn a
|
|
165
|
+
* blocked backstop into a crash.
|
|
166
|
+
*
|
|
167
|
+
* Idempotent: re-running against an already-pushed branch is an up-to-date
|
|
168
|
+
* no-op.
|
|
169
|
+
*
|
|
170
|
+
* @param {{
|
|
171
|
+
* storyId: number,
|
|
172
|
+
* cwd?: string,
|
|
173
|
+
* gitFn?: typeof gitSpawn,
|
|
174
|
+
* }} args
|
|
175
|
+
* @returns {{
|
|
176
|
+
* preserved: boolean,
|
|
177
|
+
* branch: string,
|
|
178
|
+
* remoteRef: string|null,
|
|
179
|
+
* detail: string,
|
|
180
|
+
* }}
|
|
181
|
+
*/
|
|
182
|
+
export function preserveRefusedWork({
|
|
183
|
+
storyId,
|
|
184
|
+
cwd = process.cwd(),
|
|
185
|
+
gitFn = gitSpawn,
|
|
186
|
+
} = {}) {
|
|
187
|
+
const branch = getStoryBranch(storyId);
|
|
188
|
+
const unpreserved = (detail) => ({
|
|
189
|
+
preserved: false,
|
|
190
|
+
branch,
|
|
191
|
+
remoteRef: null,
|
|
192
|
+
detail,
|
|
193
|
+
});
|
|
194
|
+
let result;
|
|
195
|
+
try {
|
|
196
|
+
result = gitFn(cwd, 'push', '--set-upstream', 'origin', branch);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
return unpreserved(
|
|
199
|
+
`could not publish ${branch}: ${err?.message ?? err} — the finished work is LOCAL ONLY; push it before any branch cleanup runs`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (result?.status !== 0) {
|
|
203
|
+
return unpreserved(
|
|
204
|
+
`could not publish ${branch}: ${result?.stderr || 'git push failed'} — the finished work is LOCAL ONLY; push it before any branch cleanup runs`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
preserved: true,
|
|
209
|
+
branch,
|
|
210
|
+
remoteRef: `origin/${branch}`,
|
|
211
|
+
detail: `refused work preserved on origin/${branch} — the branch is no longer the only copy, and no PR was opened`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
136
215
|
/**
|
|
137
216
|
* Record a light-path scope rejection as friction.
|
|
138
217
|
*
|
|
@@ -82,6 +82,72 @@ export const OVERRIDABLE_SHAPE_CODES = Object.freeze([
|
|
|
82
82
|
SHAPE_CODES.DEPLOYABLE_SPAN,
|
|
83
83
|
]);
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Detect the **un-waivable** risk rules a predicted footprint trips —
|
|
87
|
+
* `sensitive-path` and `migration-span`, the complement of
|
|
88
|
+
* {@link OVERRIDABLE_SHAPE_CODES} — **independent of which rule the shape
|
|
89
|
+
* decision happened to record** (Story #4875).
|
|
90
|
+
*
|
|
91
|
+
* No re-slicing, shrinking, or operator answer satisfies one: a footprint
|
|
92
|
+
* intersecting a sensitive-path class routes `full` however small the change,
|
|
93
|
+
* and the diff backstop refuses the same footprint again at the end. But
|
|
94
|
+
* {@link deriveStoryShape} reports only the **first** rule a shape trips and
|
|
95
|
+
* evaluates the ceiling rules first, so a prompt tripping both `change-kinds`
|
|
96
|
+
* and `sensitive-path` is reported as a size objection — which reads as
|
|
97
|
+
* appealable, is waivable by an attended operator, and sends the work all the
|
|
98
|
+
* way to an implementation the backstop then refuses.
|
|
99
|
+
*
|
|
100
|
+
* The recovery is that the shape decision attaches the built effort shape to
|
|
101
|
+
* every footprint it can judge at all, and that shape carries the risk facts
|
|
102
|
+
* (`sensitiveClasses`, `migrationSpan`) whether or not a risk rule fired.
|
|
103
|
+
* Reading them here surfaces the objection first-hit reporting hides — the
|
|
104
|
+
* difference between a wasted session and a redirected one.
|
|
105
|
+
*
|
|
106
|
+
* Pure and total.
|
|
107
|
+
*
|
|
108
|
+
* @param {{ shape?: { sensitiveClasses?: unknown, migrationSpan?: unknown } }} [decision]
|
|
109
|
+
* A {@link deriveStoryShape} return value.
|
|
110
|
+
* @returns {{
|
|
111
|
+
* present: boolean,
|
|
112
|
+
* code: string|null,
|
|
113
|
+
* classes: string[],
|
|
114
|
+
* reason: string|null,
|
|
115
|
+
* }}
|
|
116
|
+
*/
|
|
117
|
+
function deriveUnwaivableRisk(decision) {
|
|
118
|
+
const shape = decision?.shape ?? null;
|
|
119
|
+
const classes = Array.isArray(shape?.sensitiveClasses)
|
|
120
|
+
? shape.sensitiveClasses.filter(
|
|
121
|
+
(c) => typeof c === 'string' && c.trim() !== '',
|
|
122
|
+
)
|
|
123
|
+
: [];
|
|
124
|
+
if (classes.length > 0) {
|
|
125
|
+
return {
|
|
126
|
+
present: true,
|
|
127
|
+
code: SHAPE_CODES.SENSITIVE_PATH,
|
|
128
|
+
classes,
|
|
129
|
+
reason:
|
|
130
|
+
`un-waivable: the predicted footprint intersects sensitive-path ` +
|
|
131
|
+
`class(es) ${classes.join(', ')} — this is risk, not size, so no ` +
|
|
132
|
+
`re-slicing, shrinking, or operator override satisfies it and the ` +
|
|
133
|
+
`diff backstop would refuse the same footprint after the work is ` +
|
|
134
|
+
`finished; take this to /plan now`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (shape?.migrationSpan === true) {
|
|
138
|
+
return {
|
|
139
|
+
present: true,
|
|
140
|
+
code: SHAPE_CODES.MIGRATION_SPAN,
|
|
141
|
+
classes: [],
|
|
142
|
+
reason:
|
|
143
|
+
`un-waivable: the predicted footprint pairs a migration with its ` +
|
|
144
|
+
`consumers — this is risk, not size, so no re-slicing or operator ` +
|
|
145
|
+
`override satisfies it; take this to /plan now`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return { present: false, code: null, classes: [], reason: null };
|
|
149
|
+
}
|
|
150
|
+
|
|
85
151
|
/**
|
|
86
152
|
* Ceilings for the **actual landed** change set the diff backstop
|
|
87
153
|
* ({@link checkLightDiffBackstop}) enforces, measured on the change's
|
|
@@ -222,9 +288,12 @@ export function resolveLedgeredVerdict({ route, reason } = {}) {
|
|
|
222
288
|
* route: 'lite'|'full',
|
|
223
289
|
* shape: ReturnType<typeof deriveStoryShape>,
|
|
224
290
|
* ledger: ReturnType<typeof resolveLedgeredVerdict>,
|
|
291
|
+
* unwaivable: ReturnType<typeof deriveUnwaivableRisk>,
|
|
225
292
|
* ceilings: typeof STORY_SHAPE_CEILINGS,
|
|
226
293
|
* reasons: string[],
|
|
227
|
-
* }}
|
|
294
|
+
* }} `unwaivable` names an absolute risk rule the predicted footprint trips
|
|
295
|
+
* even when the recorded `shape.code` is a size prediction (Story #4875), so
|
|
296
|
+
* the operator learns at prediction time that no override can help.
|
|
228
297
|
*/
|
|
229
298
|
export function deriveLightSuitability({
|
|
230
299
|
predictedChanges,
|
|
@@ -246,14 +315,24 @@ export function deriveLightSuitability({
|
|
|
246
315
|
injectedRules,
|
|
247
316
|
selectSensitivePathClassesFn,
|
|
248
317
|
});
|
|
249
|
-
const
|
|
318
|
+
const unwaivable = deriveUnwaivableRisk(shape);
|
|
319
|
+
// A tripped risk rule is decisive on its own: the shape decision may have
|
|
320
|
+
// recorded an earlier ceiling rule, but a sensitive footprint can never be
|
|
321
|
+
// lite, so the conjunction must not be able to read `suitable` from a shape
|
|
322
|
+
// whose recorded code was waived downstream.
|
|
323
|
+
const suitable =
|
|
324
|
+
shape.route === 'lite' && ledger.route === 'lite' && !unwaivable.present;
|
|
325
|
+
const reasons = [`shape: ${shape.reasons[0]}`];
|
|
326
|
+
if (unwaivable.present) reasons.push(unwaivable.reason);
|
|
327
|
+
reasons.push(`verdict: ${ledger.note}`);
|
|
250
328
|
return {
|
|
251
329
|
suitable,
|
|
252
330
|
route: suitable ? 'lite' : 'full',
|
|
253
331
|
shape,
|
|
254
332
|
ledger,
|
|
333
|
+
unwaivable,
|
|
255
334
|
ceilings: STORY_SHAPE_CEILINGS,
|
|
256
|
-
reasons
|
|
335
|
+
reasons,
|
|
257
336
|
};
|
|
258
337
|
}
|
|
259
338
|
|
|
@@ -274,8 +353,11 @@ export function deriveLightSuitability({
|
|
|
274
353
|
* terminal). Checked here as well as at the CLI, so the pure core carries
|
|
275
354
|
* the guarantee rather than the shell.
|
|
276
355
|
* 3. **The objection is a size prediction** — a code in
|
|
277
|
-
* {@link OVERRIDABLE_SHAPE_CODES}
|
|
278
|
-
* risk
|
|
356
|
+
* {@link OVERRIDABLE_SHAPE_CODES}, **and** the footprint trips no
|
|
357
|
+
* un-waivable risk rule ({@link deriveUnwaivableRisk}, Story #4875).
|
|
358
|
+
* Sensitivity and migration
|
|
359
|
+
* span are risk and stay absolute however small the change — including
|
|
360
|
+
* when an earlier ceiling rule is the one the shape recorded.
|
|
279
361
|
* 4. **The ledgered verdict is already `lite`.** The override substitutes for
|
|
280
362
|
* the *shape* half of the conjunction only; an unaudited "trust me, it's
|
|
281
363
|
* small" buys nothing it did not buy before.
|
|
@@ -317,6 +399,22 @@ export function resolveOperatorOverride({
|
|
|
317
399
|
}
|
|
318
400
|
|
|
319
401
|
const code = suitability?.shape?.code ?? null;
|
|
402
|
+
// Checked BEFORE the overridable-code test on purpose (Story #4875): when a
|
|
403
|
+
// footprint trips both a ceiling rule and a risk rule, the shape records the
|
|
404
|
+
// ceiling rule, which IS overridable — so testing the recorded code alone
|
|
405
|
+
// would apply the override and send un-landable work to the backstop.
|
|
406
|
+
const unwaivable = suitability?.unwaivable;
|
|
407
|
+
if (unwaivable?.present === true) {
|
|
408
|
+
return refuse(
|
|
409
|
+
`operator override refused — the predicted footprint also trips the ` +
|
|
410
|
+
`un-waivable "${unwaivable.code}" rule${
|
|
411
|
+
unwaivable.classes.length > 0
|
|
412
|
+
? ` (${unwaivable.classes.join(', ')})`
|
|
413
|
+
: ''
|
|
414
|
+
}; waiving the size prediction cannot make this land light, and the ` +
|
|
415
|
+
`diff backstop would refuse the finished work. Escalate to /plan.`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
320
418
|
if (!OVERRIDABLE_SHAPE_CODES.includes(code)) {
|
|
321
419
|
return refuse(
|
|
322
420
|
`operator override refused — "${code ?? 'unknown'}" is not an overridable size prediction (overridable: ${OVERRIDABLE_SHAPE_CODES.join(', ')}); risk rules and unknown footprints are non-negotiable`,
|
|
@@ -451,6 +451,7 @@ export async function runPlanPersist({
|
|
|
451
451
|
stories: rawStories = null,
|
|
452
452
|
techSpecContent = null,
|
|
453
453
|
planAcceptance = null,
|
|
454
|
+
planContextEnvelope = null,
|
|
454
455
|
} = artifacts ?? {};
|
|
455
456
|
const {
|
|
456
457
|
forceReview = false,
|
|
@@ -538,6 +539,12 @@ export async function runPlanPersist({
|
|
|
538
539
|
sharedSpec: techSpecContent,
|
|
539
540
|
planAcceptance: planAcceptance ?? undefined,
|
|
540
541
|
sourceTicketIds,
|
|
542
|
+
// The seed this plan was authored from is the provenance source: an audit
|
|
543
|
+
// sweep's Single-plan seed carries the `audit-fingerprints` /
|
|
544
|
+
// `audit-semantic-keys` footers, which assembly copies into every persisted
|
|
545
|
+
// Story body so the next sweep recognises what it already planned
|
|
546
|
+
// (Story #4877). Empty for a `--tickets` run, which is a no-op.
|
|
547
|
+
provenanceSource: planContextEnvelope?.seed?.content ?? '',
|
|
541
548
|
});
|
|
542
549
|
|
|
543
550
|
// Effective complexity route (Story #4722): the planner's authored lite
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
19
|
import { applyBlockedByDependencies } from '../../../providers/github/blocked-by-add.js';
|
|
20
|
+
import { carryProvenanceFooters } from '../../findings/route-finding.js';
|
|
20
21
|
import { Logger } from '../../Logger.js';
|
|
21
22
|
import { AGENT_LABELS, TYPE_LABELS } from '../../label-constants.js';
|
|
22
23
|
import {
|
|
@@ -392,7 +393,18 @@ function assembleOnePlanStory(ticket, opts) {
|
|
|
392
393
|
});
|
|
393
394
|
// Body first: the fingerprint is an identity over the *assembled* content,
|
|
394
395
|
// so it cannot be computed until that content exists.
|
|
395
|
-
const
|
|
396
|
+
const serialized = serializeStoryBody({ ...folded, depends_on });
|
|
397
|
+
// Carry audit dedup provenance out of the seed this plan was authored from
|
|
398
|
+
// (Story #4877). The audit sweep's Single-plan path stamps the
|
|
399
|
+
// `audit-fingerprints` / `audit-semantic-keys` footers into the seed it hands
|
|
400
|
+
// `/plan`; without this the persisted Story carries no provenance and the
|
|
401
|
+
// next sweep re-files work it already planned. Mechanical on purpose — the
|
|
402
|
+
// authoring agent is not asked to notice HTML comments in a one-pager. A
|
|
403
|
+
// non-audit seed carries no footers, so this is a no-op there.
|
|
404
|
+
const { body } = carryProvenanceFooters({
|
|
405
|
+
from: opts.provenanceSource ?? '',
|
|
406
|
+
into: serialized,
|
|
407
|
+
});
|
|
396
408
|
const fingerprint = planStoryFingerprint({ slug, title, body });
|
|
397
409
|
return {
|
|
398
410
|
story: {
|
|
Binary file
|