mandrel 2.23.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 (101) 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/schemas/story-deliver-terminal.schema.json +6 -1
  15. package/.agents/scripts/acceptance-eval.js +35 -9
  16. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  17. package/.agents/scripts/audit-to-stories.js +33 -3
  18. package/.agents/scripts/check-context-budget.js +95 -14
  19. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  20. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  21. package/.agents/scripts/deliver-light.js +22 -45
  22. package/.agents/scripts/deliver-recover.js +13 -0
  23. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
  24. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  25. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  26. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  27. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  28. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  29. package/.agents/scripts/lib/baseline-loader.js +0 -0
  30. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  31. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  32. package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
  33. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  34. package/.agents/scripts/lib/baselines/reader.js +10 -0
  35. package/.agents/scripts/lib/baselines/writer.js +7 -3
  36. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  37. package/.agents/scripts/lib/crap-engine.js +97 -19
  38. package/.agents/scripts/lib/crap-utils.js +73 -13
  39. package/.agents/scripts/lib/env-loader.js +46 -16
  40. package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
  41. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  42. package/.agents/scripts/lib/findings/severity.js +80 -2
  43. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  44. package/.agents/scripts/lib/label-constants.js +17 -0
  45. package/.agents/scripts/lib/maintainability-engine.js +83 -11
  46. package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
  47. package/.agents/scripts/lib/maintainability-utils.js +14 -5
  48. package/.agents/scripts/lib/observability/runtime-friction.js +8 -0
  49. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  50. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  51. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  52. package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
  53. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  54. package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
  55. package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
  57. package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
  58. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  59. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  60. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  61. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  63. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  64. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
  65. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  66. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
  67. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  68. package/.agents/scripts/lib/story-plan.js +137 -42
  69. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  70. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  71. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  72. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  73. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  74. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  75. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  76. package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
  77. package/.agents/scripts/pr-watch-with-update.js +568 -213
  78. package/.agents/scripts/quality-preview.js +25 -0
  79. package/.agents/scripts/run-tests.js +10 -0
  80. package/.agents/scripts/single-story-close.js +6 -0
  81. package/.agents/scripts/single-story-init.js +7 -0
  82. package/.agents/scripts/stories-wave-tick.js +95 -10
  83. package/.agents/scripts/story-plan.js +65 -9
  84. package/.agents/scripts/update-crap-baseline.js +42 -4
  85. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  86. package/.agents/templates/single-story-body.md +16 -8
  87. package/.agents/workflows/audit-architecture.md +69 -13
  88. package/.agents/workflows/audit-quality.md +26 -0
  89. package/.agents/workflows/audit-to-stories.md +30 -4
  90. package/.agents/workflows/deliver.md +81 -95
  91. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  92. package/.agents/workflows/helpers/deliver-light.md +21 -4
  93. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  94. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  95. package/.agents/workflows/helpers/deliver-story.md +104 -119
  96. package/.agents/workflows/helpers/plan-reference.md +34 -0
  97. package/.agents/workflows/plan.md +60 -76
  98. package/docs/CHANGELOG.md +38 -0
  99. package/lib/cli/update.js +14 -11
  100. package/lib/cli/version-check.js +9 -1
  101. package/package.json +1 -1
@@ -0,0 +1,283 @@
1
+ /**
2
+ * lib/orchestration/diff-magnitude.js — the changed-line magnitude of a diff,
3
+ * split into implementation and mandated-companion halves (Story #4856).
4
+ *
5
+ * ## Why magnitude, and why the split
6
+ *
7
+ * The light path's diff backstop used to bound scope with a single
8
+ * `maxFiles: 4` ceiling. Measured against this repository's own history that
9
+ * ceiling was wrong in both directions:
10
+ *
11
+ * - **Too tight.** Of 33 real-work squash merges on `main` (excluding
12
+ * release-please and `chore(baselines)` automation) only 7 — 21% — touch
13
+ * four files or fewer; the median is 8. The framework's *own* narrow-diff
14
+ * scale, `DEFAULT_DIFF_WIDTH.softFiles` in `review-depth.js`, is 15.
15
+ * - **Blind.** A three-file, 323-line rewrite passed while a 190-file change
16
+ * was rejected 47× over — even though 186 of those files were tests and its
17
+ * implementation was 7 files.
18
+ *
19
+ * So the axis is **changed lines over implementation files**, and the companion
20
+ * classes the framework itself mandates are exempt from the count: obeying
21
+ * `rules/testing-standards.md` (test-first), the `delivery.docsFreshness` gate,
22
+ * and the baseline ratchets must not inflate the number that then rejects the
23
+ * change. Re-counting those same 33 merges on implementation files alone moves
24
+ * the four-file pass rate from 21% to 58%.
25
+ *
26
+ * ## Three contracts worth not rediscovering
27
+ *
28
+ * 1. **Additions plus deletions, never net.** A modified line counts twice
29
+ * (one `+`, one `-`), which is the intended weighting. Net is actively
30
+ * broken as a size signal: the merge retiring the planner snapshot is
31
+ * 1119 add+del but **−803** net, so a large deletion would measure as
32
+ * trivial.
33
+ * 2. **A pure rename is free.** `git diff --numstat` reports `0\t0` for one,
34
+ * and its path arrives in `old => new` form — normalized here to the
35
+ * destination so the file still counts toward the implementation tally.
36
+ * 3. **Exemption is from *counting*, never from *risk*.** Nothing here
37
+ * touches sensitive-path derivation, which every caller runs over the
38
+ * full changed set including companions.
39
+ *
40
+ * Companion matching runs through the audit suite's picomatch seam as a
41
+ * **positive** glob list negated by the caller. A `!`-prefixed picomatch
42
+ * pattern widens a match rather than narrowing it, so expressing the
43
+ * behavior-bearing exceptions as negations would silently exempt more than
44
+ * intended.
45
+ *
46
+ * Every export is total: no throws. {@link readNumstatRows} owns the one git
47
+ * read; everything else is pure.
48
+ *
49
+ * The public surface is deliberately just those two functions. The numstat
50
+ * parse, the rename normalization, and the companion classifier are internal:
51
+ * each is fully observable through them (a stubbed `gitSpawnFn` drives the
52
+ * parse, an `isCompanionFn` seam drives the classification), so exporting them
53
+ * would widen the module's contract for no caller.
54
+ *
55
+ * @module lib/orchestration/diff-magnitude
56
+ */
57
+
58
+ import { matchesAnyFilePattern } from '../audit-suite/selector.js';
59
+ import { gitSpawn } from '../git-utils.js';
60
+
61
+ /**
62
+ * Paths whose churn is a **mandated companion** of a change rather than the
63
+ * change itself, exempt from the implementation line and file counts.
64
+ *
65
+ * Deliberately absent, and load-bearing in their absence: `.agentrc.json`,
66
+ * `.agents/schemas/**`, `.github/workflows/**`, and `package.json`. Those are
67
+ * "config" by file type but behavior by effect, and `.agents/schemas/audit-rules.json`
68
+ * is the sensitive-path SSOT — exempting it would let a change widen the very
69
+ * allowlist that decides whether it is risky.
70
+ *
71
+ * Note the anchoring: `baselines/**` is the generated baseline **data** at the
72
+ * repository root. The baseline *schemas* under `.agents/schemas/baselines/`
73
+ * are unanchored by this pattern and therefore still count as implementation.
74
+ *
75
+ * Markdown is exempt wholesale, which includes `.agents/workflows/**` and
76
+ * `.agents/rules/**`. That is intended: rewriting a workflow contract is not
77
+ * *effort* the way a module rewrite is, and its real guards are the close-time
78
+ * context-budget, doc-link, and docs-reference-sync gates — none of which this
79
+ * ceiling replaces.
80
+ */
81
+ const COMPANION_PATH_GLOBS = Object.freeze([
82
+ // Tests — mandated by rules/testing-standards.md's test-first discipline.
83
+ '**/__tests__/**',
84
+ '**/*.test.js',
85
+ '**/*.test.mjs',
86
+ '**/*.test.cjs',
87
+ '**/*.test.ts',
88
+ '**/*.test.tsx',
89
+ 'tests/**',
90
+ 'features/**',
91
+ // Documentation — mandated by the delivery.docsFreshness gate.
92
+ 'docs/**',
93
+ '**/*.md',
94
+ // Generated baseline data — written by the ratchets, not hand-authored.
95
+ 'baselines/**',
96
+ // Lockfiles — regenerated wholesale; their line count means nothing.
97
+ 'package-lock.json',
98
+ 'pnpm-lock.yaml',
99
+ 'yarn.lock',
100
+ ]);
101
+
102
+ /**
103
+ * Normalize a `git diff --numstat` path field to the single file it names.
104
+ * Rename rows arrive as `old => new` or with a braced infix
105
+ * (`dir/{old => new}/file.js`); both resolve to the destination, so a renamed
106
+ * implementation file still counts as one implementation file.
107
+ *
108
+ * Pure and total.
109
+ *
110
+ * @param {string} raw
111
+ * @returns {string}
112
+ */
113
+ function normalizeNumstatPath(raw) {
114
+ const value = typeof raw === 'string' ? raw.trim() : '';
115
+ if (value === '') return '';
116
+ const braced = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(value);
117
+ if (braced) {
118
+ const [, prefix, , to, suffix] = braced;
119
+ return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/');
120
+ }
121
+ const arrow = value.split(' => ');
122
+ return (arrow.length > 1 ? arrow[arrow.length - 1] : value).trim();
123
+ }
124
+
125
+ /**
126
+ * Parse `git diff --numstat` output into per-file rows.
127
+ *
128
+ * Binary rows (`-\t-\tpath`) contribute zero text lines without poisoning the
129
+ * parse. Any line that does not match the numstat shape makes the whole result
130
+ * untrustworthy, so the function returns `null` — the "magnitude unknown"
131
+ * signal every caller fails closed (or fails open) on deliberately.
132
+ *
133
+ * Pure and total.
134
+ *
135
+ * @param {unknown} stdout
136
+ * @returns {Array<{ additions: number, deletions: number, path: string }>|null}
137
+ */
138
+ function parseNumstatRows(stdout) {
139
+ if (typeof stdout !== 'string') return null;
140
+ const rows = [];
141
+ for (const line of stdout.split('\n')) {
142
+ const trimmedEnd = line.replace(/\s+$/, '');
143
+ if (trimmedEnd.length === 0) continue;
144
+ const match = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(trimmedEnd);
145
+ if (!match) return null;
146
+ rows.push({
147
+ additions: match[1] === '-' ? 0 : Number(match[1]),
148
+ deletions: match[2] === '-' ? 0 : Number(match[2]),
149
+ path: normalizeNumstatPath(match[3]),
150
+ });
151
+ }
152
+ return rows;
153
+ }
154
+
155
+ /**
156
+ * True when `file` is a mandated companion rather than implementation.
157
+ *
158
+ * Pure and total — a throwing matcher resolves to `false`, which counts the
159
+ * file as implementation. That is the conservative direction: a
160
+ * classification failure must never shrink the measured magnitude.
161
+ *
162
+ * @param {unknown} file
163
+ * @param {{ matchFn?: typeof matchesAnyFilePattern }} [deps]
164
+ * @returns {boolean}
165
+ */
166
+ function isCompanionPath(file, { matchFn = matchesAnyFilePattern } = {}) {
167
+ if (typeof file !== 'string' || file.trim() === '') return false;
168
+ try {
169
+ return matchFn(COMPANION_PATH_GLOBS, [file.trim()]) === true;
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Read the per-file numstat rows for the `baseRef...headRef` diff. The one
177
+ * side-effecting function in this module.
178
+ *
179
+ * Total — never throws; returns `null` on any git failure or unparseable
180
+ * output.
181
+ *
182
+ * @param {{
183
+ * baseRef?: string,
184
+ * headRef?: string,
185
+ * cwd?: string,
186
+ * gitSpawnFn?: typeof gitSpawn,
187
+ * }} [args]
188
+ * @returns {Array<{ additions: number, deletions: number, path: string }>|null}
189
+ */
190
+ export function readNumstatRows({
191
+ baseRef,
192
+ headRef,
193
+ cwd = process.cwd(),
194
+ gitSpawnFn = gitSpawn,
195
+ } = {}) {
196
+ if (typeof baseRef !== 'string' || baseRef.length === 0) return null;
197
+ if (typeof headRef !== 'string' || headRef.length === 0) return null;
198
+ try {
199
+ const result = gitSpawnFn(
200
+ cwd,
201
+ 'diff',
202
+ '--numstat',
203
+ `${baseRef}...${headRef}`,
204
+ );
205
+ if (!result || result.status !== 0) return null;
206
+ return parseNumstatRows(result.stdout);
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Summarize a diff's magnitude, splitting implementation from mandated
214
+ * companions.
215
+ *
216
+ * `implFiles` is counted from `changedFiles` — the canonical
217
+ * `git diff --name-only` enumeration produced by `change-set.js` — rather than
218
+ * from the numstat rows, so file counting uses clean paths from the one
219
+ * enumerator every other consumer reads. `implLines` comes from the numstat
220
+ * rows, which is the only surface carrying line counts.
221
+ *
222
+ * Returns `null` when either input is unusable: the magnitude is then *unknown*,
223
+ * which is deliberately distinct from *zero* so a caller can fail closed on the
224
+ * absence of evidence.
225
+ *
226
+ * Pure and total.
227
+ *
228
+ * @param {{
229
+ * changedFiles?: unknown,
230
+ * rows?: unknown,
231
+ * isCompanionFn?: typeof isCompanionPath,
232
+ * }} [args]
233
+ * @returns {{
234
+ * implFiles: number,
235
+ * implLines: number,
236
+ * companionFiles: number,
237
+ * companionLines: number,
238
+ * totalFiles: number,
239
+ * }|null}
240
+ */
241
+ export function summarizeDiffMagnitude({
242
+ changedFiles,
243
+ rows,
244
+ isCompanionFn = isCompanionPath,
245
+ } = {}) {
246
+ if (!Array.isArray(changedFiles) || !Array.isArray(rows)) return null;
247
+ const files = changedFiles.filter(
248
+ (f) => typeof f === 'string' && f.trim() !== '',
249
+ );
250
+
251
+ // A classification failure resolves to "implementation" — the conservative
252
+ // direction, since counting a companion as implementation can only ever make
253
+ // the measured magnitude larger. Guarded here as well as inside the default
254
+ // classifier so an injected one cannot break this function's totality.
255
+ const isCompanion = (file) => {
256
+ try {
257
+ return isCompanionFn(file) === true;
258
+ } catch {
259
+ return false;
260
+ }
261
+ };
262
+
263
+ let implFiles = 0;
264
+ for (const file of files) {
265
+ if (!isCompanion(file)) implFiles += 1;
266
+ }
267
+
268
+ let implLines = 0;
269
+ let companionLines = 0;
270
+ for (const row of rows) {
271
+ const lines = (row?.additions ?? 0) + (row?.deletions ?? 0);
272
+ if (isCompanion(row?.path)) companionLines += lines;
273
+ else implLines += lines;
274
+ }
275
+
276
+ return {
277
+ implFiles,
278
+ implLines,
279
+ companionFiles: files.length - implFiles,
280
+ companionLines,
281
+ totalFiles: files.length,
282
+ };
283
+ }
@@ -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}`,
@@ -0,0 +1,124 @@
1
+ /**
2
+ * lib/orchestration/light-backstop.js — the light path's diff-backstop pass
3
+ * (Story #4856).
4
+ *
5
+ * The backstop is invariant 3 of the light path: after implementation the
6
+ * **actual** change set is re-checked, because the diff — not the prompt — is
7
+ * the real scope signal. This module owns that pass end to end so
8
+ * `deliver-light.js` stays the thin CLI shell it claims to be: it reads the two
9
+ * git surfaces, applies
10
+ * {@link module:lib/orchestration/light-suitability.checkLightDiffBackstop},
11
+ * and resolves what a refusal means.
12
+ *
13
+ * ## Two git surfaces, each used for what it reports reliably
14
+ *
15
+ * - `--name-only`, via the one canonical `computeChangeSet` enumerator, gives
16
+ * the clean full file list. Sensitive-path derivation and
17
+ * implementation-file counting both read it, so the backstop and every
18
+ * other consumer are looking at the same change set.
19
+ * - `--numstat` gives per-file line counts, the only surface carrying them.
20
+ *
21
+ * @module lib/orchestration/light-backstop
22
+ */
23
+
24
+ import { computeChangeSet } from './change-set.js';
25
+ import { readNumstatRows, summarizeDiffMagnitude } from './diff-magnitude.js';
26
+ import {
27
+ handleBlockedBackstop,
28
+ preserveRefusedWork,
29
+ } from './light-escalation.js';
30
+ import { checkLightDiffBackstop } from './light-suitability.js';
31
+
32
+ /** Exit code when the diff backstop blocked the land. */
33
+ const EXIT_BACKSTOP_BLOCKED = 3;
34
+
35
+ /**
36
+ * Run the diff backstop against a Story branch's actual change set.
37
+ *
38
+ * @param {{
39
+ * storyId: number,
40
+ * baseRef?: string,
41
+ * cwd?: string,
42
+ * computeFn?: typeof computeChangeSet,
43
+ * readRowsFn?: typeof readNumstatRows,
44
+ * injectedRules?: object,
45
+ * }} args
46
+ * @returns {ReturnType<typeof checkLightDiffBackstop>}
47
+ */
48
+ function runDiffBackstop({
49
+ storyId,
50
+ baseRef = 'main',
51
+ cwd = process.cwd(),
52
+ computeFn = computeChangeSet,
53
+ readRowsFn = readNumstatRows,
54
+ injectedRules,
55
+ } = {}) {
56
+ const headRef = `story-${storyId}`;
57
+ const { files } = computeFn({ baseRef, headRef, cwd });
58
+ const rows = readRowsFn({ baseRef, headRef, cwd });
59
+ const magnitude = summarizeDiffMagnitude({ changedFiles: files, rows });
60
+ return checkLightDiffBackstop({
61
+ changedFiles: files,
62
+ magnitude,
63
+ injectedRules,
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Resolve the backstop pass into everything the CLI needs to print and exit
69
+ * with: the verdict, the recycle command on a refusal (`null` when clean), the
70
+ * exit code, and the log line.
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
+ *
79
+ * @param {{
80
+ * storyId: number,
81
+ * runFn?: typeof runDiffBackstop,
82
+ * handleBlockedFn?: typeof handleBlockedBackstop,
83
+ * preserveFn?: typeof preserveRefusedWork,
84
+ * }} args Any further keys (`baseRef`, `cwd`, `computeFn`, `readRowsFn`,
85
+ * `injectedRules`) forward to the backstop run, so the git-surface join is
86
+ * drivable through this one entry point.
87
+ * @returns {Promise<{
88
+ * result: ReturnType<typeof checkLightDiffBackstop>,
89
+ * nextCommand: string|null,
90
+ * preservation: ReturnType<typeof preserveRefusedWork>|null,
91
+ * exitCode: number,
92
+ * message: string,
93
+ * }>}
94
+ */
95
+ export async function resolveBackstopOutcome({
96
+ storyId,
97
+ runFn = runDiffBackstop,
98
+ handleBlockedFn = handleBlockedBackstop,
99
+ preserveFn = preserveRefusedWork,
100
+ ...seams
101
+ } = {}) {
102
+ const result = runFn({ storyId, ...seams });
103
+ if (!result.blocked) {
104
+ return {
105
+ result,
106
+ nextCommand: null,
107
+ preservation: null,
108
+ exitCode: 0,
109
+ message: `[deliver-light] diff backstop clean for Story #${storyId}.`,
110
+ };
111
+ }
112
+ const preservation = preserveFn({ storyId, cwd: seams.cwd });
113
+ const nextCommand = await handleBlockedFn({ storyId, result, preservation });
114
+ return {
115
+ result,
116
+ nextCommand,
117
+ preservation,
118
+ exitCode: EXIT_BACKSTOP_BLOCKED,
119
+ message:
120
+ `[deliver-light] diff backstop BLOCKED Story #${storyId}: ` +
121
+ `${result.reasons.join('; ')} — ${preservation.detail}; ` +
122
+ `recycle the receipt with "${nextCommand}"`,
123
+ };
124
+ }