mandrel 2.22.0 → 2.24.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/docs/configuration.md +1 -0
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/deliver-light.js +23 -45
- package/.agents/scripts/diagnose-friction.js +95 -4
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
- package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
- package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
- package/.agents/scripts/lib/maintainability-engine.js +83 -11
- package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
- package/.agents/scripts/lib/maintainability-utils.js +14 -5
- package/.agents/scripts/lib/observability/runtime-friction.js +37 -1
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/light-backstop.js +107 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +169 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +151 -46
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +18 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +23 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +76 -4
- package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
- package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
- package/.agents/workflows/helpers/deliver-light.md +21 -4
- package/.agents/workflows/helpers/plan-reference.md +40 -0
- package/.agents/workflows/plan.md +21 -16
- package/docs/CHANGELOG.md +23 -0
- 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
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
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 { handleBlockedBackstop } from './light-escalation.js';
|
|
27
|
+
import { checkLightDiffBackstop } from './light-suitability.js';
|
|
28
|
+
|
|
29
|
+
/** Exit code when the diff backstop blocked the land. */
|
|
30
|
+
const EXIT_BACKSTOP_BLOCKED = 3;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run the diff backstop against a Story branch's actual change set.
|
|
34
|
+
*
|
|
35
|
+
* @param {{
|
|
36
|
+
* storyId: number,
|
|
37
|
+
* baseRef?: string,
|
|
38
|
+
* cwd?: string,
|
|
39
|
+
* computeFn?: typeof computeChangeSet,
|
|
40
|
+
* readRowsFn?: typeof readNumstatRows,
|
|
41
|
+
* injectedRules?: object,
|
|
42
|
+
* }} args
|
|
43
|
+
* @returns {ReturnType<typeof checkLightDiffBackstop>}
|
|
44
|
+
*/
|
|
45
|
+
function runDiffBackstop({
|
|
46
|
+
storyId,
|
|
47
|
+
baseRef = 'main',
|
|
48
|
+
cwd = process.cwd(),
|
|
49
|
+
computeFn = computeChangeSet,
|
|
50
|
+
readRowsFn = readNumstatRows,
|
|
51
|
+
injectedRules,
|
|
52
|
+
} = {}) {
|
|
53
|
+
const headRef = `story-${storyId}`;
|
|
54
|
+
const { files } = computeFn({ baseRef, headRef, cwd });
|
|
55
|
+
const rows = readRowsFn({ baseRef, headRef, cwd });
|
|
56
|
+
const magnitude = summarizeDiffMagnitude({ changedFiles: files, rows });
|
|
57
|
+
return checkLightDiffBackstop({
|
|
58
|
+
changedFiles: files,
|
|
59
|
+
magnitude,
|
|
60
|
+
injectedRules,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the backstop pass into everything the CLI needs to print and exit
|
|
66
|
+
* with: the verdict, the recycle command on a refusal (`null` when clean), the
|
|
67
|
+
* exit code, and the log line.
|
|
68
|
+
*
|
|
69
|
+
* @param {{
|
|
70
|
+
* storyId: number,
|
|
71
|
+
* runFn?: typeof runDiffBackstop,
|
|
72
|
+
* handleBlockedFn?: typeof handleBlockedBackstop,
|
|
73
|
+
* }} args Any further keys (`baseRef`, `cwd`, `computeFn`, `readRowsFn`,
|
|
74
|
+
* `injectedRules`) forward to the backstop run, so the git-surface join is
|
|
75
|
+
* drivable through this one entry point.
|
|
76
|
+
* @returns {Promise<{
|
|
77
|
+
* result: ReturnType<typeof checkLightDiffBackstop>,
|
|
78
|
+
* nextCommand: string|null,
|
|
79
|
+
* exitCode: number,
|
|
80
|
+
* message: string,
|
|
81
|
+
* }>}
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveBackstopOutcome({
|
|
84
|
+
storyId,
|
|
85
|
+
runFn = runDiffBackstop,
|
|
86
|
+
handleBlockedFn = handleBlockedBackstop,
|
|
87
|
+
...seams
|
|
88
|
+
} = {}) {
|
|
89
|
+
const result = runFn({ storyId, ...seams });
|
|
90
|
+
if (!result.blocked) {
|
|
91
|
+
return {
|
|
92
|
+
result,
|
|
93
|
+
nextCommand: null,
|
|
94
|
+
exitCode: 0,
|
|
95
|
+
message: `[deliver-light] diff backstop clean for Story #${storyId}.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const nextCommand = await handleBlockedFn({ storyId, result });
|
|
99
|
+
return {
|
|
100
|
+
result,
|
|
101
|
+
nextCommand,
|
|
102
|
+
exitCode: EXIT_BACKSTOP_BLOCKED,
|
|
103
|
+
message:
|
|
104
|
+
`[deliver-light] diff backstop BLOCKED Story #${storyId}: ` +
|
|
105
|
+
`${result.reasons.join('; ')} — recycle the receipt with "${nextCommand}"`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/light-escalation.js — what the light path does when it
|
|
3
|
+
* refuses a scope (Story #4856).
|
|
4
|
+
*
|
|
5
|
+
* Two behaviors, both previously missing, and both about a refusal rather than
|
|
6
|
+
* a verdict — the verdicts live in
|
|
7
|
+
* {@link module:lib/orchestration/light-suitability}:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Recycling the receipt.** A blocked diff backstop used to tell the
|
|
10
|
+
* operator to "escalate to `/plan`", which authored a brand-new Story and
|
|
11
|
+
* left the receipt open with no successor — orphaning its branch, its
|
|
12
|
+
* worktree, and a finished implementation. Naming the receipt as `/plan`'s
|
|
13
|
+
* *input* recycles it instead: tickets mode already fetches a ticket,
|
|
14
|
+
* rewrites it into properly-planned Stories, and closes the source as
|
|
15
|
+
* superseded.
|
|
16
|
+
*
|
|
17
|
+
* Deferring receipt creation until after the backstop would be the other
|
|
18
|
+
* fix, and is deliberately not taken: the issue id is load-bearing in
|
|
19
|
+
* `single-story-init.js` (the assignee lease, the `story-<id>` branch, the
|
|
20
|
+
* label state machine) and in the `(refs #<id>)` commit subject.
|
|
21
|
+
*
|
|
22
|
+
* 2. **Telemetering the refusal.** Neither light-path rejection emitted any
|
|
23
|
+
* signal, so an over-tight ceiling could only reach the framework as
|
|
24
|
+
* anecdote — which is exactly how the `maxFiles: 4` defect surfaced. The
|
|
25
|
+
* roll-up aggregates by category, so recording these makes the ceilings
|
|
26
|
+
* recalibratable from evidence.
|
|
27
|
+
*
|
|
28
|
+
* Telemetry is best-effort by construction: a signals-write failure must never
|
|
29
|
+
* change a gate's verdict.
|
|
30
|
+
*
|
|
31
|
+
* @module lib/orchestration/light-escalation
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
emitRuntimeFriction,
|
|
36
|
+
RUNTIME_FRICTION_CATEGORIES,
|
|
37
|
+
} from '../observability/runtime-friction.js';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The `/plan` invocation that owns a Story the light path could not land.
|
|
41
|
+
*
|
|
42
|
+
* @param {number} storyId
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function buildRecycleCommand(storyId) {
|
|
46
|
+
return `/plan ${storyId}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Coerce an `--amends` argument (`#123` or `123`) into a positive integer issue
|
|
51
|
+
* number, or `null` when absent/malformed.
|
|
52
|
+
*
|
|
53
|
+
* This is the only Story context a **gate-stage** rejection can legitimately
|
|
54
|
+
* claim: the signals stream is keyed on a Story id, and an `ask-operator` gate
|
|
55
|
+
* has authored no receipt yet — deliberately, since not creating one is the
|
|
56
|
+
* point of that outcome. A bare prompt's rejection therefore has no stream to
|
|
57
|
+
* land in, and attributing it to a fabricated id would be worse than recording
|
|
58
|
+
* nothing.
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} amends
|
|
61
|
+
* @returns {number|null}
|
|
62
|
+
*/
|
|
63
|
+
function normalizeAmendsId(amends) {
|
|
64
|
+
const match = /^#?(\d+)$/.exec(String(amends ?? '').trim());
|
|
65
|
+
if (!match) return null;
|
|
66
|
+
const n = Number.parseInt(match[1], 10);
|
|
67
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Record a suitability-gate refusal (`ask-operator`) as friction, attributed to
|
|
72
|
+
* the `--amends` target when there is one.
|
|
73
|
+
*
|
|
74
|
+
* @param {{
|
|
75
|
+
* gate: object,
|
|
76
|
+
* amends?: unknown,
|
|
77
|
+
* recordFrictionFn?: typeof recordScopeFriction,
|
|
78
|
+
* }} args
|
|
79
|
+
* @returns {Promise<boolean>}
|
|
80
|
+
*/
|
|
81
|
+
export async function recordGateRefusal({
|
|
82
|
+
gate,
|
|
83
|
+
amends,
|
|
84
|
+
emitFn,
|
|
85
|
+
recordFrictionFn = recordScopeFriction,
|
|
86
|
+
} = {}) {
|
|
87
|
+
return recordFrictionFn({
|
|
88
|
+
emitFn,
|
|
89
|
+
storyId: normalizeAmendsId(amends),
|
|
90
|
+
surface: 'suitability-gate',
|
|
91
|
+
reasons: gate?.outcome?.reasons ?? [],
|
|
92
|
+
details: {
|
|
93
|
+
action: gate?.action ?? null,
|
|
94
|
+
code: gate?.suitability?.shape?.code ?? null,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Handle a blocked diff backstop: record the refusal as friction and return the
|
|
101
|
+
* `/plan` invocation that recycles the receipt.
|
|
102
|
+
*
|
|
103
|
+
* Lives here rather than in the CLI so the shell stays a shell — the backstop
|
|
104
|
+
* mode's job is to branch and print, not to decide what a refusal means.
|
|
105
|
+
*
|
|
106
|
+
* @param {{
|
|
107
|
+
* storyId: number,
|
|
108
|
+
* result: object,
|
|
109
|
+
* recordFrictionFn?: typeof recordScopeFriction,
|
|
110
|
+
* }} args `result` is a {@link module:lib/orchestration/light-suitability.checkLightDiffBackstop}
|
|
111
|
+
* verdict.
|
|
112
|
+
* @returns {Promise<string>} The recycle command.
|
|
113
|
+
*/
|
|
114
|
+
export async function handleBlockedBackstop({
|
|
115
|
+
storyId,
|
|
116
|
+
result,
|
|
117
|
+
emitFn,
|
|
118
|
+
recordFrictionFn = recordScopeFriction,
|
|
119
|
+
} = {}) {
|
|
120
|
+
await recordFrictionFn({
|
|
121
|
+
emitFn,
|
|
122
|
+
storyId,
|
|
123
|
+
surface: 'diff-backstop',
|
|
124
|
+
reasons: result?.reasons ?? [],
|
|
125
|
+
details: {
|
|
126
|
+
fileCount: result?.fileCount ?? null,
|
|
127
|
+
implFiles: result?.magnitude?.implFiles ?? null,
|
|
128
|
+
implLines: result?.magnitude?.implLines ?? null,
|
|
129
|
+
ceilings: result?.ceilings ?? null,
|
|
130
|
+
classes: result?.classes ?? [],
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
return buildRecycleCommand(storyId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Record a light-path scope rejection as friction.
|
|
138
|
+
*
|
|
139
|
+
* Total: never throws, and returns `false` rather than propagating when the
|
|
140
|
+
* signals surface is unavailable.
|
|
141
|
+
*
|
|
142
|
+
* @param {{
|
|
143
|
+
* storyId?: number|null,
|
|
144
|
+
* surface: string,
|
|
145
|
+
* reasons?: string[],
|
|
146
|
+
* details?: object,
|
|
147
|
+
* emitFn?: typeof emitRuntimeFriction,
|
|
148
|
+
* }} args
|
|
149
|
+
* @returns {Promise<boolean>}
|
|
150
|
+
*/
|
|
151
|
+
async function recordScopeFriction({
|
|
152
|
+
storyId,
|
|
153
|
+
surface,
|
|
154
|
+
reasons = [],
|
|
155
|
+
details = {},
|
|
156
|
+
emitFn,
|
|
157
|
+
} = {}) {
|
|
158
|
+
const emit = emitFn ?? emitRuntimeFriction;
|
|
159
|
+
try {
|
|
160
|
+
return await emit({
|
|
161
|
+
storyId,
|
|
162
|
+
category: RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED,
|
|
163
|
+
tool: 'deliver-light',
|
|
164
|
+
details: { surface, reasons, ...details },
|
|
165
|
+
});
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|