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.
Files changed (88) hide show
  1. package/.agents/agents/acceptance-critic.md +13 -19
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +2 -1
  7. package/.agents/docs/configuration.md +2 -1
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/rules/ci-remediation.md +68 -3
  10. package/.agents/schemas/agentrc.schema.json +6 -1
  11. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  12. package/.agents/schemas/baselines/crap.schema.json +5 -1
  13. package/.agents/schemas/crap-report.schema.json +37 -0
  14. package/.agents/scripts/acceptance-eval.js +35 -9
  15. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  16. package/.agents/scripts/audit-to-stories.js +33 -3
  17. package/.agents/scripts/check-context-budget.js +95 -14
  18. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  19. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  20. package/.agents/scripts/deliver-light.js +3 -4
  21. package/.agents/scripts/deliver-recover.js +13 -0
  22. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  23. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  24. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  25. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  27. package/.agents/scripts/lib/baseline-loader.js +0 -0
  28. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  29. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  30. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  31. package/.agents/scripts/lib/baselines/reader.js +10 -0
  32. package/.agents/scripts/lib/baselines/writer.js +7 -3
  33. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  34. package/.agents/scripts/lib/crap-engine.js +97 -19
  35. package/.agents/scripts/lib/crap-utils.js +73 -13
  36. package/.agents/scripts/lib/env-loader.js +46 -16
  37. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  38. package/.agents/scripts/lib/findings/severity.js +80 -2
  39. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  40. package/.agents/scripts/lib/label-constants.js +17 -0
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  43. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  44. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  45. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  46. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  47. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  48. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  49. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  50. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  51. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  53. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  55. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
  56. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  57. package/.agents/scripts/lib/story-plan.js +137 -42
  58. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  59. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  60. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  61. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  62. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  63. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  64. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  65. package/.agents/scripts/pr-watch-with-update.js +568 -213
  66. package/.agents/scripts/quality-preview.js +25 -0
  67. package/.agents/scripts/run-tests.js +10 -0
  68. package/.agents/scripts/single-story-close.js +6 -0
  69. package/.agents/scripts/single-story-init.js +7 -0
  70. package/.agents/scripts/stories-wave-tick.js +95 -10
  71. package/.agents/scripts/story-plan.js +65 -9
  72. package/.agents/scripts/update-crap-baseline.js +42 -4
  73. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  74. package/.agents/templates/single-story-body.md +16 -8
  75. package/.agents/workflows/audit-architecture.md +69 -13
  76. package/.agents/workflows/audit-quality.md +26 -0
  77. package/.agents/workflows/audit-to-stories.md +30 -4
  78. package/.agents/workflows/deliver.md +81 -95
  79. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  80. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  81. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  82. package/.agents/workflows/helpers/deliver-story.md +104 -119
  83. package/.agents/workflows/helpers/plan-reference.md +34 -0
  84. package/.agents/workflows/plan.md +60 -76
  85. package/docs/CHANGELOG.md +29 -0
  86. package/lib/cli/update.js +14 -11
  87. package/lib/cli/version-check.js +9 -1
  88. 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('gh', ['pr', 'update-branch', prUrl], {
207
- cwd,
208
- encoding: 'utf-8',
209
- shell: false,
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` is set only when the first
453
- * probe could not resolve the required-check set.
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 && first.status !== 0 && first.status !== 8) {
478
- logger.warn?.(
479
- `[Watcher] gh pr checks failed (status=${first.status}): ${first.stderr}`,
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
- error: `gh-checks-failed:status=${first.status}`,
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 { handleBlockedBackstop } from './light-escalation.js';
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 nextCommand = await handleBlockedFn({ storyId, result });
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('; ')} — recycle the receipt with "${nextCommand}"`,
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
- * Two behaviors, both previously missing, and both about a refusal rather than
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 suitable = shape.route === 'lite' && ledger.route === 'lite';
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: [`shape: ${shape.reasons[0]}`, `verdict: ${ledger.note}`],
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}. Sensitivity and migration span are
278
- * risk and stay absolute however small the change.
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 body = serializeStoryBody({ ...folded, depends_on });
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: {