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.
- package/.agents/agents/acceptance-critic.md +13 -19
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +5 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +35 -9
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-test-temp-hygiene.js +83 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +22 -45
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/writer.js +7 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +97 -19
- package/.agents/scripts/lib/crap-utils.js +73 -13
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/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 +8 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/quality-preview.js +25 -0
- package/.agents/scripts/run-tests.js +10 -0
- package/.agents/scripts/single-story-close.js +6 -0
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +95 -10
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +81 -95
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/deliver-light.md +21 -4
- package/.agents/workflows/helpers/deliver-reference.md +18 -3
- package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +34 -0
- package/.agents/workflows/plan.md +60 -76
- package/docs/CHANGELOG.md +38 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +1 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/light-escalation.js — what the light path does when it
|
|
3
|
+
* refuses a scope (Story #4856).
|
|
4
|
+
*
|
|
5
|
+
* Three behaviors, each previously missing, and each about a refusal rather
|
|
6
|
+
* than 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
|
+
* 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
|
+
*
|
|
35
|
+
* Telemetry is best-effort by construction: a signals-write failure must never
|
|
36
|
+
* change a gate's verdict.
|
|
37
|
+
*
|
|
38
|
+
* @module lib/orchestration/light-escalation
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { getStoryBranch, gitSpawn } from '../git-utils.js';
|
|
42
|
+
import {
|
|
43
|
+
emitRuntimeFriction,
|
|
44
|
+
RUNTIME_FRICTION_CATEGORIES,
|
|
45
|
+
} from '../observability/runtime-friction.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The `/plan` invocation that owns a Story the light path could not land.
|
|
49
|
+
*
|
|
50
|
+
* @param {number} storyId
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function buildRecycleCommand(storyId) {
|
|
54
|
+
return `/plan ${storyId}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Coerce an `--amends` argument (`#123` or `123`) into a positive integer issue
|
|
59
|
+
* number, or `null` when absent/malformed.
|
|
60
|
+
*
|
|
61
|
+
* This is the only Story context a **gate-stage** rejection can legitimately
|
|
62
|
+
* claim: the signals stream is keyed on a Story id, and an `ask-operator` gate
|
|
63
|
+
* has authored no receipt yet — deliberately, since not creating one is the
|
|
64
|
+
* point of that outcome. A bare prompt's rejection therefore has no stream to
|
|
65
|
+
* land in, and attributing it to a fabricated id would be worse than recording
|
|
66
|
+
* nothing.
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} amends
|
|
69
|
+
* @returns {number|null}
|
|
70
|
+
*/
|
|
71
|
+
function normalizeAmendsId(amends) {
|
|
72
|
+
const match = /^#?(\d+)$/.exec(String(amends ?? '').trim());
|
|
73
|
+
if (!match) return null;
|
|
74
|
+
const n = Number.parseInt(match[1], 10);
|
|
75
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Record a suitability-gate refusal (`ask-operator`) as friction, attributed to
|
|
80
|
+
* the `--amends` target when there is one.
|
|
81
|
+
*
|
|
82
|
+
* @param {{
|
|
83
|
+
* gate: object,
|
|
84
|
+
* amends?: unknown,
|
|
85
|
+
* recordFrictionFn?: typeof recordScopeFriction,
|
|
86
|
+
* }} args
|
|
87
|
+
* @returns {Promise<boolean>}
|
|
88
|
+
*/
|
|
89
|
+
export async function recordGateRefusal({
|
|
90
|
+
gate,
|
|
91
|
+
amends,
|
|
92
|
+
emitFn,
|
|
93
|
+
recordFrictionFn = recordScopeFriction,
|
|
94
|
+
} = {}) {
|
|
95
|
+
return recordFrictionFn({
|
|
96
|
+
emitFn,
|
|
97
|
+
storyId: normalizeAmendsId(amends),
|
|
98
|
+
surface: 'suitability-gate',
|
|
99
|
+
reasons: gate?.outcome?.reasons ?? [],
|
|
100
|
+
details: {
|
|
101
|
+
action: gate?.action ?? null,
|
|
102
|
+
code: gate?.suitability?.shape?.code ?? null,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handle a blocked diff backstop: record the refusal as friction and return the
|
|
109
|
+
* `/plan` invocation that recycles the receipt.
|
|
110
|
+
*
|
|
111
|
+
* Lives here rather than in the CLI so the shell stays a shell — the backstop
|
|
112
|
+
* mode's job is to branch and print, not to decide what a refusal means.
|
|
113
|
+
*
|
|
114
|
+
* @param {{
|
|
115
|
+
* storyId: number,
|
|
116
|
+
* result: object,
|
|
117
|
+
* preservation?: ReturnType<typeof preserveRefusedWork>,
|
|
118
|
+
* recordFrictionFn?: typeof recordScopeFriction,
|
|
119
|
+
* }} args `result` is a {@link module:lib/orchestration/light-suitability.checkLightDiffBackstop}
|
|
120
|
+
* verdict; `preservation` is the {@link preserveRefusedWork} outcome.
|
|
121
|
+
* @returns {Promise<string>} The recycle command.
|
|
122
|
+
*/
|
|
123
|
+
export async function handleBlockedBackstop({
|
|
124
|
+
storyId,
|
|
125
|
+
result,
|
|
126
|
+
preservation,
|
|
127
|
+
emitFn,
|
|
128
|
+
recordFrictionFn = recordScopeFriction,
|
|
129
|
+
} = {}) {
|
|
130
|
+
await recordFrictionFn({
|
|
131
|
+
emitFn,
|
|
132
|
+
storyId,
|
|
133
|
+
surface: 'diff-backstop',
|
|
134
|
+
reasons: result?.reasons ?? [],
|
|
135
|
+
details: {
|
|
136
|
+
fileCount: result?.fileCount ?? null,
|
|
137
|
+
implFiles: result?.magnitude?.implFiles ?? null,
|
|
138
|
+
implLines: result?.magnitude?.implLines ?? null,
|
|
139
|
+
ceilings: result?.ceilings ?? null,
|
|
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,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
return buildRecycleCommand(storyId);
|
|
148
|
+
}
|
|
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
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Record a light-path scope rejection as friction.
|
|
217
|
+
*
|
|
218
|
+
* Total: never throws, and returns `false` rather than propagating when the
|
|
219
|
+
* signals surface is unavailable.
|
|
220
|
+
*
|
|
221
|
+
* @param {{
|
|
222
|
+
* storyId?: number|null,
|
|
223
|
+
* surface: string,
|
|
224
|
+
* reasons?: string[],
|
|
225
|
+
* details?: object,
|
|
226
|
+
* emitFn?: typeof emitRuntimeFriction,
|
|
227
|
+
* }} args
|
|
228
|
+
* @returns {Promise<boolean>}
|
|
229
|
+
*/
|
|
230
|
+
async function recordScopeFriction({
|
|
231
|
+
storyId,
|
|
232
|
+
surface,
|
|
233
|
+
reasons = [],
|
|
234
|
+
details = {},
|
|
235
|
+
emitFn,
|
|
236
|
+
} = {}) {
|
|
237
|
+
const emit = emitFn ?? emitRuntimeFriction;
|
|
238
|
+
try {
|
|
239
|
+
return await emit({
|
|
240
|
+
storyId,
|
|
241
|
+
category: RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED,
|
|
242
|
+
tool: 'deliver-light',
|
|
243
|
+
details: { surface, reasons, ...details },
|
|
244
|
+
});
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -36,9 +36,13 @@
|
|
|
36
36
|
* Under `--yes` (unattended) it fails closed to recommending `/plan`.
|
|
37
37
|
* 3. **Diff-derived backstop ({@link checkLightDiffBackstop}).** After
|
|
38
38
|
* implementation the **actual** change set is re-checked with
|
|
39
|
-
* {@link module:lib/orchestration/review-depth.deriveChangeLevel} plus
|
|
40
|
-
*
|
|
41
|
-
* over-ceiling diff is blocked
|
|
39
|
+
* {@link module:lib/orchestration/review-depth.deriveChangeLevel} plus the
|
|
40
|
+
* implementation-only magnitude ceilings of {@link LIGHT_DIFF_CEILINGS} —
|
|
41
|
+
* the diff is the real scope signal — and an over-ceiling diff is blocked
|
|
42
|
+
* rather than landed silently. Story #4856 moved this from a `maxFiles: 4`
|
|
43
|
+
* cardinality ceiling to changed lines over implementation files, and made
|
|
44
|
+
* a block **recycle** its receipt Story through `/plan` tickets mode
|
|
45
|
+
* instead of orphaning it.
|
|
42
46
|
* 4. **Minimal receipt Story ({@link buildReceiptStoryTicket}).** A
|
|
43
47
|
* `type::story` ticket is authored inline so `refs #`, history, telemetry,
|
|
44
48
|
* and the `agent::executing -> agent::done` state machine survive.
|
|
@@ -79,35 +83,135 @@ export const OVERRIDABLE_SHAPE_CODES = Object.freeze([
|
|
|
79
83
|
]);
|
|
80
84
|
|
|
81
85
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Ceilings for the **actual landed** change set the diff backstop
|
|
153
|
+
* ({@link checkLightDiffBackstop}) enforces, measured on the change's
|
|
154
|
+
* implementation half (Story #4856 — see
|
|
155
|
+
* {@link module:lib/orchestration/diff-magnitude} for the measured case and the
|
|
156
|
+
* companion-class boundary).
|
|
157
|
+
*
|
|
158
|
+
* The backstop reads ground truth, so it is where size is genuinely enforced;
|
|
159
|
+
* the prediction gate above it is a declaration and stays coarse (Story #4764).
|
|
160
|
+
* What changed is the **axis**: this used to be `maxFiles: 4`, a cardinality
|
|
161
|
+
* ceiling that rejected 79% of this repository's real merged work while passing
|
|
162
|
+
* a three-file 323-line rewrite.
|
|
163
|
+
*
|
|
164
|
+
* - `maxImplLines` — additions plus deletions across implementation files.
|
|
165
|
+
* Simulated over 41 merges, 1000 admits 83% of real work
|
|
166
|
+
* and rejects exactly the genuinely large changes.
|
|
167
|
+
* - `maxImplFiles` — implementation files touched, a *sprawl* tripwire rather
|
|
168
|
+
* than a size gate. Set to `DEFAULT_DIFF_WIDTH.softFiles`
|
|
169
|
+
* so the light path and `review-depth.js` stop holding two
|
|
170
|
+
* different definitions of a narrow diff.
|
|
171
|
+
*
|
|
172
|
+
* Framework constants, not knobs: a ceiling an operator could widen past what a
|
|
173
|
+
* single session safely absorbs is a ceiling that fails silently.
|
|
90
174
|
*/
|
|
91
175
|
export const LIGHT_DIFF_CEILINGS = Object.freeze({
|
|
92
|
-
|
|
176
|
+
maxImplLines: 1000,
|
|
177
|
+
maxImplFiles: 15,
|
|
93
178
|
});
|
|
94
179
|
|
|
95
180
|
/**
|
|
96
|
-
* Coerce a candidate
|
|
97
|
-
*
|
|
98
|
-
*
|
|
181
|
+
* Coerce a candidate ceiling into a positive integer, falling back to the
|
|
182
|
+
* framework default for anything malformed — a stray `0`, `-1`, or `NaN` must
|
|
183
|
+
* never widen (or zero out) a light diff ceiling.
|
|
99
184
|
*
|
|
100
185
|
* @param {unknown} value
|
|
101
186
|
* @param {number} fallback
|
|
102
187
|
* @returns {number}
|
|
103
188
|
*/
|
|
104
|
-
function
|
|
189
|
+
function normalizeCeiling(value, fallback) {
|
|
105
190
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {
|
|
106
191
|
return fallback;
|
|
107
192
|
}
|
|
108
193
|
return Math.floor(value);
|
|
109
194
|
}
|
|
110
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Resolve the effective diff ceilings from a caller-supplied partial override.
|
|
198
|
+
*
|
|
199
|
+
* @param {{ maxImplLines?: unknown, maxImplFiles?: unknown }} [ceilings]
|
|
200
|
+
* @returns {{ maxImplLines: number, maxImplFiles: number }}
|
|
201
|
+
*/
|
|
202
|
+
function resolveDiffCeilings(ceilings) {
|
|
203
|
+
return {
|
|
204
|
+
maxImplLines: normalizeCeiling(
|
|
205
|
+
ceilings?.maxImplLines,
|
|
206
|
+
LIGHT_DIFF_CEILINGS.maxImplLines,
|
|
207
|
+
),
|
|
208
|
+
maxImplFiles: normalizeCeiling(
|
|
209
|
+
ceilings?.maxImplFiles,
|
|
210
|
+
LIGHT_DIFF_CEILINGS.maxImplFiles,
|
|
211
|
+
),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
111
215
|
/**
|
|
112
216
|
* Resolve the model's trivial-vs-standard verdict, held to the same ledgering
|
|
113
217
|
* contract the planner's authored verdict is
|
|
@@ -184,9 +288,12 @@ export function resolveLedgeredVerdict({ route, reason } = {}) {
|
|
|
184
288
|
* route: 'lite'|'full',
|
|
185
289
|
* shape: ReturnType<typeof deriveStoryShape>,
|
|
186
290
|
* ledger: ReturnType<typeof resolveLedgeredVerdict>,
|
|
291
|
+
* unwaivable: ReturnType<typeof deriveUnwaivableRisk>,
|
|
187
292
|
* ceilings: typeof STORY_SHAPE_CEILINGS,
|
|
188
293
|
* reasons: string[],
|
|
189
|
-
* }}
|
|
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.
|
|
190
297
|
*/
|
|
191
298
|
export function deriveLightSuitability({
|
|
192
299
|
predictedChanges,
|
|
@@ -208,14 +315,24 @@ export function deriveLightSuitability({
|
|
|
208
315
|
injectedRules,
|
|
209
316
|
selectSensitivePathClassesFn,
|
|
210
317
|
});
|
|
211
|
-
const
|
|
318
|
+
const unwaivable = deriveUnwaivableRisk(shape);
|
|
319
|
+
// A tripped risk rule is decisive on its own: the shape decision may have
|
|
320
|
+
// recorded an earlier ceiling rule, but a sensitive footprint can never be
|
|
321
|
+
// lite, so the conjunction must not be able to read `suitable` from a shape
|
|
322
|
+
// whose recorded code was waived downstream.
|
|
323
|
+
const suitable =
|
|
324
|
+
shape.route === 'lite' && ledger.route === 'lite' && !unwaivable.present;
|
|
325
|
+
const reasons = [`shape: ${shape.reasons[0]}`];
|
|
326
|
+
if (unwaivable.present) reasons.push(unwaivable.reason);
|
|
327
|
+
reasons.push(`verdict: ${ledger.note}`);
|
|
212
328
|
return {
|
|
213
329
|
suitable,
|
|
214
330
|
route: suitable ? 'lite' : 'full',
|
|
215
331
|
shape,
|
|
216
332
|
ledger,
|
|
333
|
+
unwaivable,
|
|
217
334
|
ceilings: STORY_SHAPE_CEILINGS,
|
|
218
|
-
reasons
|
|
335
|
+
reasons,
|
|
219
336
|
};
|
|
220
337
|
}
|
|
221
338
|
|
|
@@ -236,8 +353,11 @@ export function deriveLightSuitability({
|
|
|
236
353
|
* terminal). Checked here as well as at the CLI, so the pure core carries
|
|
237
354
|
* the guarantee rather than the shell.
|
|
238
355
|
* 3. **The objection is a size prediction** — a code in
|
|
239
|
-
* {@link OVERRIDABLE_SHAPE_CODES}
|
|
240
|
-
* risk
|
|
356
|
+
* {@link OVERRIDABLE_SHAPE_CODES}, **and** the footprint trips no
|
|
357
|
+
* un-waivable risk rule ({@link deriveUnwaivableRisk}, Story #4875).
|
|
358
|
+
* Sensitivity and migration
|
|
359
|
+
* span are risk and stay absolute however small the change — including
|
|
360
|
+
* when an earlier ceiling rule is the one the shape recorded.
|
|
241
361
|
* 4. **The ledgered verdict is already `lite`.** The override substitutes for
|
|
242
362
|
* the *shape* half of the conjunction only; an unaudited "trust me, it's
|
|
243
363
|
* small" buys nothing it did not buy before.
|
|
@@ -279,6 +399,22 @@ export function resolveOperatorOverride({
|
|
|
279
399
|
}
|
|
280
400
|
|
|
281
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
|
+
}
|
|
282
418
|
if (!OVERRIDABLE_SHAPE_CODES.includes(code)) {
|
|
283
419
|
return refuse(
|
|
284
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`,
|
|
@@ -389,11 +525,23 @@ export function resolveLightGateOutcome({
|
|
|
389
525
|
}
|
|
390
526
|
|
|
391
527
|
/**
|
|
392
|
-
* Diff-derived backstop (Story #4740 AC-4
|
|
393
|
-
*
|
|
394
|
-
* signal. Blocks (rather than landing)
|
|
395
|
-
*
|
|
396
|
-
* result is the only path that lands
|
|
528
|
+
* Diff-derived backstop (Story #4740 AC-4, re-based on magnitude by Story
|
|
529
|
+
* #4856): re-check the **actual** change set after implementation, because the
|
|
530
|
+
* diff — not the prompt — is the real scope signal. Blocks (rather than landing)
|
|
531
|
+
* when the diff intersects a sensitive-path class, exceeds an implementation
|
|
532
|
+
* ceiling, or cannot be measured. A clean result is the only path that lands
|
|
533
|
+
* light.
|
|
534
|
+
*
|
|
535
|
+
* Two inputs, two different scopes, and the difference is load-bearing:
|
|
536
|
+
*
|
|
537
|
+
* - `changedFiles` is the **full** change set, companions included, and is
|
|
538
|
+
* what sensitive-path derivation reads. Exempting a companion from the
|
|
539
|
+
* *count* must never exempt it from *risk* — a test file under a registered
|
|
540
|
+
* sensitive class still blocks.
|
|
541
|
+
* - `magnitude` is the implementation-only summary from
|
|
542
|
+
* {@link module:lib/orchestration/diff-magnitude.summarizeDiffMagnitude}.
|
|
543
|
+
* `null` means the magnitude could not be measured, which blocks: absence
|
|
544
|
+
* of evidence is not evidence the diff is small.
|
|
397
545
|
*
|
|
398
546
|
* Reuses close's own {@link module:lib/orchestration/review-depth.deriveChangeLevel}
|
|
399
547
|
* — one taxonomy, applied to the predicted shape at the gate and the actual
|
|
@@ -403,7 +551,8 @@ export function resolveLightGateOutcome({
|
|
|
403
551
|
*
|
|
404
552
|
* @param {{
|
|
405
553
|
* changedFiles?: unknown,
|
|
406
|
-
*
|
|
554
|
+
* magnitude?: { implFiles?: number, implLines?: number }|null,
|
|
555
|
+
* ceilings?: { maxImplLines?: number, maxImplFiles?: number },
|
|
407
556
|
* injectedRules?: object,
|
|
408
557
|
* selectSensitivePathClassesFn?: Function,
|
|
409
558
|
* }} [args]
|
|
@@ -412,20 +561,19 @@ export function resolveLightGateOutcome({
|
|
|
412
561
|
* level: 'low'|'high'|null,
|
|
413
562
|
* classes: string[],
|
|
414
563
|
* fileCount: number|null,
|
|
415
|
-
*
|
|
564
|
+
* magnitude: { implFiles: number, implLines: number }|null,
|
|
565
|
+
* ceilings: { maxImplLines: number, maxImplFiles: number },
|
|
416
566
|
* reasons: string[],
|
|
417
567
|
* }}
|
|
418
568
|
*/
|
|
419
569
|
export function checkLightDiffBackstop({
|
|
420
570
|
changedFiles,
|
|
571
|
+
magnitude,
|
|
421
572
|
ceilings,
|
|
422
573
|
injectedRules,
|
|
423
574
|
selectSensitivePathClassesFn,
|
|
424
575
|
} = {}) {
|
|
425
|
-
const
|
|
426
|
-
ceilings?.maxFiles,
|
|
427
|
-
LIGHT_DIFF_CEILINGS.maxFiles,
|
|
428
|
-
);
|
|
576
|
+
const resolved = resolveDiffCeilings(ceilings);
|
|
429
577
|
const files = Array.isArray(changedFiles)
|
|
430
578
|
? changedFiles.filter((f) => typeof f === 'string' && f.trim() !== '')
|
|
431
579
|
: null;
|
|
@@ -436,7 +584,8 @@ export function checkLightDiffBackstop({
|
|
|
436
584
|
level: null,
|
|
437
585
|
classes: [],
|
|
438
586
|
fileCount: files === null ? null : 0,
|
|
439
|
-
|
|
587
|
+
magnitude: null,
|
|
588
|
+
ceilings: resolved,
|
|
440
589
|
reasons: [
|
|
441
590
|
'actual change set is unknown or empty — cannot verify the diff is light; escalate to /plan',
|
|
442
591
|
],
|
|
@@ -448,23 +597,12 @@ export function checkLightDiffBackstop({
|
|
|
448
597
|
injectedRules,
|
|
449
598
|
selectSensitivePathClassesFn,
|
|
450
599
|
});
|
|
600
|
+
const measured = normalizeMagnitude(magnitude);
|
|
451
601
|
|
|
452
|
-
const reasons = [
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
);
|
|
457
|
-
}
|
|
458
|
-
if (files.length > maxFiles) {
|
|
459
|
-
reasons.push(
|
|
460
|
-
`diff touches ${files.length} file(s) (> maxFiles ${maxFiles}) — escalate to /plan (do not land light)`,
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
if (level !== 'low' && classes.length === 0) {
|
|
464
|
-
reasons.push(
|
|
465
|
-
'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /plan',
|
|
466
|
-
);
|
|
467
|
-
}
|
|
602
|
+
const reasons = [
|
|
603
|
+
...describeSensitivity({ level, classes }),
|
|
604
|
+
...describeMagnitude(measured, resolved),
|
|
605
|
+
];
|
|
468
606
|
|
|
469
607
|
const blocked = reasons.length > 0;
|
|
470
608
|
return {
|
|
@@ -472,15 +610,80 @@ export function checkLightDiffBackstop({
|
|
|
472
610
|
level,
|
|
473
611
|
classes,
|
|
474
612
|
fileCount: files.length,
|
|
475
|
-
|
|
613
|
+
magnitude: measured,
|
|
614
|
+
ceilings: resolved,
|
|
476
615
|
reasons: blocked
|
|
477
616
|
? reasons
|
|
478
617
|
: [
|
|
479
|
-
`diff is light: ${
|
|
618
|
+
`diff is light: ${measured.implLines} implementation line(s) ≤ ${resolved.maxImplLines} ` +
|
|
619
|
+
`across ${measured.implFiles} implementation file(s) ≤ ${resolved.maxImplFiles} ` +
|
|
620
|
+
`(${files.length} file(s) total, companions exempt), no sensitive-path class — safe to land`,
|
|
480
621
|
],
|
|
481
622
|
};
|
|
482
623
|
}
|
|
483
624
|
|
|
625
|
+
/**
|
|
626
|
+
* Coerce a magnitude summary into non-negative integer counts, or `null` when
|
|
627
|
+
* it was not measurable. Pure.
|
|
628
|
+
*
|
|
629
|
+
* @param {unknown} magnitude
|
|
630
|
+
* @returns {{ implFiles: number, implLines: number }|null}
|
|
631
|
+
*/
|
|
632
|
+
function normalizeMagnitude(magnitude) {
|
|
633
|
+
const implFiles = magnitude?.implFiles;
|
|
634
|
+
const implLines = magnitude?.implLines;
|
|
635
|
+
if (!Number.isFinite(implFiles) || !Number.isFinite(implLines)) return null;
|
|
636
|
+
if (implFiles < 0 || implLines < 0) return null;
|
|
637
|
+
return { implFiles: Math.floor(implFiles), implLines: Math.floor(implLines) };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Sensitivity objections, over the **full** change set. Pure.
|
|
642
|
+
*
|
|
643
|
+
* @param {{ level: 'low'|'high'|null, classes: string[] }} derived
|
|
644
|
+
* @returns {string[]}
|
|
645
|
+
*/
|
|
646
|
+
function describeSensitivity({ level, classes }) {
|
|
647
|
+
if (classes.length > 0) {
|
|
648
|
+
return [
|
|
649
|
+
`diff intersects sensitive-path class(es) ${classes.join(', ')} — escalate to /plan (do not land light)`,
|
|
650
|
+
];
|
|
651
|
+
}
|
|
652
|
+
if (level !== 'low') {
|
|
653
|
+
return [
|
|
654
|
+
'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /plan',
|
|
655
|
+
];
|
|
656
|
+
}
|
|
657
|
+
return [];
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Magnitude objections, over the implementation half only. Pure.
|
|
662
|
+
*
|
|
663
|
+
* @param {{ implFiles: number, implLines: number }|null} measured
|
|
664
|
+
* @param {{ maxImplLines: number, maxImplFiles: number }} ceilings
|
|
665
|
+
* @returns {string[]}
|
|
666
|
+
*/
|
|
667
|
+
function describeMagnitude(measured, ceilings) {
|
|
668
|
+
if (measured === null) {
|
|
669
|
+
return [
|
|
670
|
+
'change magnitude could not be measured (unreadable or unparseable numstat) — cannot verify the diff is light; escalate to /plan',
|
|
671
|
+
];
|
|
672
|
+
}
|
|
673
|
+
const reasons = [];
|
|
674
|
+
if (measured.implLines > ceilings.maxImplLines) {
|
|
675
|
+
reasons.push(
|
|
676
|
+
`diff changes ${measured.implLines} implementation line(s) (> maxImplLines ${ceilings.maxImplLines}) — escalate to /plan (do not land light)`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
if (measured.implFiles > ceilings.maxImplFiles) {
|
|
680
|
+
reasons.push(
|
|
681
|
+
`diff spans ${measured.implFiles} implementation file(s) (> maxImplFiles ${ceilings.maxImplFiles}) — escalate to /plan (do not land light)`,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
return reasons;
|
|
685
|
+
}
|
|
686
|
+
|
|
484
687
|
/** Cap on a receipt slug's length — keep the branch/id readable. */
|
|
485
688
|
const RECEIPT_SLUG_MAX = 48;
|
|
486
689
|
|