mandrel 2.51.0 → 2.53.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/story-worker.md +4 -5
- package/.agents/docs/agentrc-reference.json +3 -1
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -0
- package/.agents/scripts/check-audit-attribution.js +245 -0
- package/.agents/scripts/check-pinned-override-notes.js +102 -0
- package/.agents/scripts/coverage-capture.js +36 -21
- package/.agents/scripts/lib/audit-attribution.js +112 -0
- package/.agents/scripts/lib/close-validation/commands.js +27 -1
- package/.agents/scripts/lib/close-validation/gates.js +33 -14
- package/.agents/scripts/lib/config/commands.js +14 -12
- package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
- package/.agents/scripts/lib/config-settings-schema.js +9 -2
- package/.agents/scripts/lib/coverage-capture.js +70 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +34 -0
- package/.agents/scripts/lib/observability/source-classifier.js +2 -0
- package/.agents/scripts/lib/orchestration/epic-container.js +27 -0
- package/.agents/scripts/lib/orchestration/epic-rollup.js +15 -4
- package/.agents/scripts/lib/orchestration/light-backstop.js +62 -6
- package/.agents/scripts/lib/orchestration/light-escalation.js +29 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +105 -19
- package/.agents/scripts/lib/orchestration/retro-proposals.js +36 -2
- package/.agents/scripts/lib/orchestration/worktree-dirty.js +80 -0
- package/.agents/scripts/lib/pinned-override-notes.js +100 -0
- package/.agents/scripts/resolve-stories.js +9 -3
- package/.agents/workflows/helpers/deliver-digest.md +4 -6
- package/.agents/workflows/helpers/deliver-light.md +13 -7
- package/docs/CHANGELOG.md +20 -0
- package/package.json +5 -5
|
@@ -95,6 +95,40 @@ export const RUNTIME_FRICTION_CATEGORIES = Object.freeze({
|
|
|
95
95
|
REVIEW_BLOCK_OVERRIDDEN: 'review-block-overridden',
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The friction category one light-path refusal files under.
|
|
100
|
+
*
|
|
101
|
+
* `LIGHT_SCOPE_REJECTED` alone used to be the category for every refusal, and
|
|
102
|
+
* the retro composer aggregates, de-duplicates and labels on the category
|
|
103
|
+
* string ALONE — so an empty-diff refusal and a `public-api` sensitive-path
|
|
104
|
+
* refusal landed in one bucket and filed one "recurred 2 times across 2
|
|
105
|
+
* Stories" follow-up whose two occurrences had nothing in common but the
|
|
106
|
+
* category (issue #5237; consumer Beestera/swarm-os#2408). The roll-up's shape
|
|
107
|
+
* fingerprint could not separate them either: it hashes detail KEYS, and every
|
|
108
|
+
* refusal carries an identical key set.
|
|
109
|
+
*
|
|
110
|
+
* Encoding the refusal class in the category is what splits them, and it
|
|
111
|
+
* splits them in every consumer at once — bucket aggregation, the graduator's
|
|
112
|
+
* idempotency marker and the `friction::<category>` label all read this one
|
|
113
|
+
* string. N refusals of the SAME class still coalesce, which is the recurrence
|
|
114
|
+
* evidence the light ceilings are recalibrated from. New categories need no
|
|
115
|
+
* seeding: the graduator mints missing `friction::*` labels before filing.
|
|
116
|
+
*
|
|
117
|
+
* A class-less refusal keeps the bare category unchanged — that is the
|
|
118
|
+
* suitability gate, which refuses a prompt before any diff exists and so has
|
|
119
|
+
* no diff-derived class to carry.
|
|
120
|
+
*
|
|
121
|
+
* @param {string|null} [refusalClass] A
|
|
122
|
+
* {@link module:lib/orchestration/light-suitability.LIGHT_REFUSAL_CLASSES}
|
|
123
|
+
* value, or nullish for the unclassified refusal.
|
|
124
|
+
* @returns {string}
|
|
125
|
+
*/
|
|
126
|
+
export function lightScopeRejectedCategory(refusalClass) {
|
|
127
|
+
const suffix = typeof refusalClass === 'string' ? refusalClass.trim() : '';
|
|
128
|
+
const base = RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED;
|
|
129
|
+
return suffix === '' ? base : `${base}-${suffix}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
98
132
|
/** Cap on free-form reason text copied into a signal's `details`. */
|
|
99
133
|
const REASON_PREVIEW_LIMIT = 500;
|
|
100
134
|
|
|
@@ -87,6 +87,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
87
87
|
'bootstrap.js',
|
|
88
88
|
'check-action-pinning.js',
|
|
89
89
|
'check-arch-cycles.js',
|
|
90
|
+
'check-audit-attribution.js',
|
|
90
91
|
'check-baseline-drift.js',
|
|
91
92
|
'check-baseline-scope.js',
|
|
92
93
|
'check-baselines.js',
|
|
@@ -98,6 +99,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
98
99
|
'check-gherkin-corpus.js',
|
|
99
100
|
'check-knip-entries.js',
|
|
100
101
|
'check-lifecycle-lint.js',
|
|
102
|
+
'check-pinned-override-notes.js',
|
|
101
103
|
'check-schema-references.js',
|
|
102
104
|
'check-test-temp-hygiene.js',
|
|
103
105
|
'check-windows-git-perf.js',
|
|
@@ -110,6 +110,33 @@ export function isEpicTicket(issue) {
|
|
|
110
110
|
return normalizeLabels(issue?.labels).includes(TYPE_LABELS.EPIC);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the GraphQL node id an Epic's native sub-issue read addresses it by.
|
|
115
|
+
*
|
|
116
|
+
* Both casings are accepted because the field name depends on which provider
|
|
117
|
+
* method produced the object, and neither caller can tell from the value it
|
|
118
|
+
* holds: `getTicket` (and every other single-issue read) runs through
|
|
119
|
+
* `issueToTicket`, which renames `node_id` to `nodeId`, while
|
|
120
|
+
* `listIssuesByLabel` returns the REST payload **verbatim** — six consumers
|
|
121
|
+
* read its raw shape, so mapping it there would be a far wider change than
|
|
122
|
+
* the two reads that actually need the id.
|
|
123
|
+
*
|
|
124
|
+
* Returns `null` when neither name carries one. That is the load-bearing
|
|
125
|
+
* half: an absent id reaches GraphQL as `$id: ID!` = `undefined`, which the
|
|
126
|
+
* API rejects and `classifyGithubError` calls `permanent` — so the gateway
|
|
127
|
+
* rethrows with no retry and no feature-disabled fallback, and the caller
|
|
128
|
+
* degrades to the body checklist while reporting a hard API failure it never
|
|
129
|
+
* really had. Callers skip the read on a `null` instead, the same clean
|
|
130
|
+
* no-op `providers/github/board-add.js` makes with `reason: 'no-node-id'`.
|
|
131
|
+
*
|
|
132
|
+
* @param {{ nodeId?: unknown, node_id?: unknown }} epic
|
|
133
|
+
* @returns {string|null}
|
|
134
|
+
*/
|
|
135
|
+
export function resolveEpicNodeId(epic) {
|
|
136
|
+
const nodeId = epic?.nodeId ?? epic?.node_id;
|
|
137
|
+
return typeof nodeId === 'string' && nodeId !== '' ? nodeId : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
113
140
|
/**
|
|
114
141
|
* Render a container Epic's body.
|
|
115
142
|
*
|
|
@@ -45,7 +45,11 @@
|
|
|
45
45
|
import { Logger } from '../Logger.js';
|
|
46
46
|
import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
|
|
47
47
|
import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
isEpicTicket,
|
|
50
|
+
readEpicChildIdsFrom,
|
|
51
|
+
resolveEpicNodeId,
|
|
52
|
+
} from './epic-container.js';
|
|
49
53
|
import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
|
|
50
54
|
import { deriveParentState } from './ticketing/bulk.js';
|
|
51
55
|
|
|
@@ -68,15 +72,22 @@ const IN_FLIGHT_STATES = new Set([
|
|
|
68
72
|
* borrow four lines would invert the dependency direction for no gain. What
|
|
69
73
|
* matters is that the *reader* handed to `readEpicChildIdsFrom` behaves the
|
|
70
74
|
* same on both paths, which is what keeps an Epic from being expandable but
|
|
71
|
-
* unclosable.
|
|
75
|
+
* unclosable. The shared `resolveEpicNodeId` is what makes "the same" true of
|
|
76
|
+
* the id itself: the Epics reaching this reader come from
|
|
77
|
+
* `listIssuesByLabel`, whose raw REST payload carries `node_id`, while the
|
|
78
|
+
* expansion path's come from `getTicket`, whose mapped ticket carries
|
|
79
|
+
* `nodeId`.
|
|
72
80
|
*
|
|
73
81
|
* @param {object} provider
|
|
74
82
|
* @returns {(epic: object) => Promise<number[]>}
|
|
75
83
|
*/
|
|
76
84
|
function nativeChildReader(provider) {
|
|
77
85
|
return async (epic) => {
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
const nodeId = resolveEpicNodeId(epic);
|
|
87
|
+
if (nodeId === null) return [];
|
|
88
|
+
return (
|
|
89
|
+
provider?._getNativeSubIssues?.(nodeId, epic?.number ?? epic?.id) ?? []
|
|
90
|
+
);
|
|
80
91
|
};
|
|
81
92
|
}
|
|
82
93
|
|
|
@@ -18,16 +18,26 @@
|
|
|
18
18
|
* other consumer are looking at the same change set.
|
|
19
19
|
* - `--numstat` gives per-file line counts, the only surface carrying them.
|
|
20
20
|
*
|
|
21
|
+
* Both read committed state, which is why a third read exists
|
|
22
|
+
* ({@link module:lib/orchestration/worktree-dirty.hasUncommittedWork}): an
|
|
23
|
+
* empty diff is ambiguous between "no work" and "work not committed yet", and
|
|
24
|
+
* only one of those is about scope.
|
|
25
|
+
*
|
|
21
26
|
* @module lib/orchestration/light-backstop
|
|
22
27
|
*/
|
|
23
28
|
|
|
29
|
+
import { gitSpawn } from '../git-utils.js';
|
|
24
30
|
import { computeChangeSet } from './change-set.js';
|
|
25
31
|
import { readNumstatRows, summarizeDiffMagnitude } from './diff-magnitude.js';
|
|
26
32
|
import {
|
|
27
33
|
handleBlockedBackstop,
|
|
28
34
|
preserveRefusedWork,
|
|
29
35
|
} from './light-escalation.js';
|
|
30
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
checkLightDiffBackstop,
|
|
38
|
+
LIGHT_REFUSAL_CLASSES,
|
|
39
|
+
} from './light-suitability.js';
|
|
40
|
+
import { hasUncommittedWork } from './worktree-dirty.js';
|
|
31
41
|
|
|
32
42
|
/** Exit code when the diff backstop blocked the land. */
|
|
33
43
|
const EXIT_BACKSTOP_BLOCKED = 3;
|
|
@@ -41,6 +51,8 @@ const EXIT_BACKSTOP_BLOCKED = 3;
|
|
|
41
51
|
* cwd?: string,
|
|
42
52
|
* computeFn?: typeof computeChangeSet,
|
|
43
53
|
* readRowsFn?: typeof readNumstatRows,
|
|
54
|
+
* dirtyProbeFn?: typeof hasUncommittedWork,
|
|
55
|
+
* gitFn?: typeof gitSpawn,
|
|
44
56
|
* injectedRules?: object,
|
|
45
57
|
* }} args
|
|
46
58
|
* @returns {ReturnType<typeof checkLightDiffBackstop>}
|
|
@@ -51,6 +63,8 @@ function runDiffBackstop({
|
|
|
51
63
|
cwd = process.cwd(),
|
|
52
64
|
computeFn = computeChangeSet,
|
|
53
65
|
readRowsFn = readNumstatRows,
|
|
66
|
+
dirtyProbeFn = hasUncommittedWork,
|
|
67
|
+
gitFn = gitSpawn,
|
|
54
68
|
injectedRules,
|
|
55
69
|
} = {}) {
|
|
56
70
|
const headRef = `story-${storyId}`;
|
|
@@ -61,9 +75,49 @@ function runDiffBackstop({
|
|
|
61
75
|
changedFiles: files,
|
|
62
76
|
magnitude,
|
|
63
77
|
injectedRules,
|
|
78
|
+
storyBranch: headRef,
|
|
79
|
+
// Only an ENUMERATED-empty diff can be explained by uncommitted work, so
|
|
80
|
+
// the probe's two git calls are spent only where they can change what the
|
|
81
|
+
// refusal tells the agent to do.
|
|
82
|
+
uncommittedWork:
|
|
83
|
+
Array.isArray(files) && files.length === 0
|
|
84
|
+
? dirtyProbeFn({ branch: headRef, cwd, gitFn })
|
|
85
|
+
: false,
|
|
64
86
|
});
|
|
65
87
|
}
|
|
66
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Is this refusal the one that is NOT about scope?
|
|
91
|
+
*
|
|
92
|
+
* @param {{ refusalClass?: string|null }} result
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
function isUncommittedRefusal(result) {
|
|
96
|
+
return result.refusalClass === LIGHT_REFUSAL_CLASSES.UNCOMMITTED_WORK;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Close the refusal log line: what became of the work, and what to run next.
|
|
101
|
+
*
|
|
102
|
+
* A `null` preservation is the uncommitted-work refusal by construction — that
|
|
103
|
+
* is the one path that does not push, because there is nothing a push could
|
|
104
|
+
* preserve: it would publish a branch at its base and then report uncommitted
|
|
105
|
+
* work as safe on `origin`, which is the opposite of true.
|
|
106
|
+
*
|
|
107
|
+
* @param {{
|
|
108
|
+
* storyId: number,
|
|
109
|
+
* preservation: { detail: string }|null,
|
|
110
|
+
* nextCommand: string,
|
|
111
|
+
* }} args
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function describeBlockedTail({ storyId, preservation, nextCommand }) {
|
|
115
|
+
return preservation === null
|
|
116
|
+
? `nothing is committed yet, so there is no work to preserve; ` +
|
|
117
|
+
`commit on story-${storyId}, then re-run: "${nextCommand}"`
|
|
118
|
+
: `${preservation.detail}; recycle the receipt with "${nextCommand}"`;
|
|
119
|
+
}
|
|
120
|
+
|
|
67
121
|
/**
|
|
68
122
|
* Resolve the backstop pass into everything the CLI needs to print and exit
|
|
69
123
|
* with: the verdict, the recycle command on a refusal (`null` when clean), the
|
|
@@ -82,8 +136,8 @@ function runDiffBackstop({
|
|
|
82
136
|
* handleBlockedFn?: typeof handleBlockedBackstop,
|
|
83
137
|
* preserveFn?: typeof preserveRefusedWork,
|
|
84
138
|
* }} args Any further keys (`baseRef`, `cwd`, `computeFn`, `readRowsFn`,
|
|
85
|
-
* `injectedRules`) forward to the backstop run, so
|
|
86
|
-
* drivable through this one entry point.
|
|
139
|
+
* `dirtyProbeFn`, `gitFn`, `injectedRules`) forward to the backstop run, so
|
|
140
|
+
* the git-surface join is drivable through this one entry point.
|
|
87
141
|
* @returns {Promise<{
|
|
88
142
|
* result: ReturnType<typeof checkLightDiffBackstop>,
|
|
89
143
|
* nextCommand: string|null,
|
|
@@ -109,7 +163,9 @@ export async function resolveBackstopOutcome({
|
|
|
109
163
|
message: `[deliver-light] diff backstop clean for Story #${storyId}.`,
|
|
110
164
|
};
|
|
111
165
|
}
|
|
112
|
-
const preservation =
|
|
166
|
+
const preservation = isUncommittedRefusal(result)
|
|
167
|
+
? null
|
|
168
|
+
: preserveFn({ storyId, cwd: seams.cwd });
|
|
113
169
|
const nextCommand = await handleBlockedFn({ storyId, result, preservation });
|
|
114
170
|
return {
|
|
115
171
|
result,
|
|
@@ -118,7 +174,7 @@ export async function resolveBackstopOutcome({
|
|
|
118
174
|
exitCode: EXIT_BACKSTOP_BLOCKED,
|
|
119
175
|
message:
|
|
120
176
|
`[deliver-light] diff backstop BLOCKED Story #${storyId}: ` +
|
|
121
|
-
`${result.reasons.join('; ')} —
|
|
122
|
-
|
|
177
|
+
`${result.reasons.join('; ')} — ` +
|
|
178
|
+
describeBlockedTail({ storyId, preservation, nextCommand }),
|
|
123
179
|
};
|
|
124
180
|
}
|
|
@@ -41,8 +41,10 @@
|
|
|
41
41
|
import { getStoryBranch, gitSpawn } from '../git-utils.js';
|
|
42
42
|
import {
|
|
43
43
|
emitRuntimeFriction,
|
|
44
|
+
lightScopeRejectedCategory,
|
|
44
45
|
RUNTIME_FRICTION_CATEGORIES,
|
|
45
46
|
} from '../observability/runtime-friction.js';
|
|
47
|
+
import { LIGHT_REFUSAL_CLASSES } from './light-suitability.js';
|
|
46
48
|
|
|
47
49
|
/**
|
|
48
50
|
* The `/mandrel-plan` invocation that owns a Story the light path could not land.
|
|
@@ -54,6 +56,21 @@ function buildRecycleCommand(storyId) {
|
|
|
54
56
|
return `/mandrel-plan ${storyId}`;
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The command that re-runs the backstop once the work is committed.
|
|
61
|
+
*
|
|
62
|
+
* The one refusal that is NOT about scope gets its own next step: an empty diff
|
|
63
|
+
* over a dirty worktree means the backstop ran before the commit, and handing
|
|
64
|
+
* that run to `/mandrel-plan` recycles a receipt whose implementation is fine
|
|
65
|
+
* — the wrong door, dressed as an escalation (issue #5237).
|
|
66
|
+
*
|
|
67
|
+
* @param {number} storyId
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function buildRerunBackstopCommand(storyId) {
|
|
71
|
+
return `node .agents/scripts/deliver-light.js --backstop --story ${storyId}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
/**
|
|
58
75
|
* Coerce an `--amends` argument (`#123` or `123`) into a positive integer issue
|
|
59
76
|
* number, or `null` when absent/malformed.
|
|
@@ -127,10 +144,12 @@ export async function handleBlockedBackstop({
|
|
|
127
144
|
emitFn,
|
|
128
145
|
recordFrictionFn = recordScopeFriction,
|
|
129
146
|
} = {}) {
|
|
147
|
+
const refusalClass = result?.refusalClass ?? null;
|
|
130
148
|
await recordFrictionFn({
|
|
131
149
|
emitFn,
|
|
132
150
|
storyId,
|
|
133
151
|
surface: 'diff-backstop',
|
|
152
|
+
category: lightScopeRejectedCategory(refusalClass),
|
|
134
153
|
reasons: result?.reasons ?? [],
|
|
135
154
|
details: {
|
|
136
155
|
fileCount: result?.fileCount ?? null,
|
|
@@ -142,9 +161,12 @@ export async function handleBlockedBackstop({
|
|
|
142
161
|
// than one whose branch reached origin — the roll-up must be able to
|
|
143
162
|
// tell them apart.
|
|
144
163
|
preserved: preservation?.preserved ?? null,
|
|
164
|
+
refusalClass,
|
|
145
165
|
},
|
|
146
166
|
});
|
|
147
|
-
return
|
|
167
|
+
return refusalClass === LIGHT_REFUSAL_CLASSES.UNCOMMITTED_WORK
|
|
168
|
+
? buildRerunBackstopCommand(storyId)
|
|
169
|
+
: buildRecycleCommand(storyId);
|
|
148
170
|
}
|
|
149
171
|
|
|
150
172
|
/**
|
|
@@ -221,15 +243,19 @@ export function preserveRefusedWork({
|
|
|
221
243
|
* @param {{
|
|
222
244
|
* storyId?: number|null,
|
|
223
245
|
* surface: string,
|
|
246
|
+
* category?: string,
|
|
224
247
|
* reasons?: string[],
|
|
225
248
|
* details?: object,
|
|
226
249
|
* emitFn?: typeof emitRuntimeFriction,
|
|
227
|
-
* }} args
|
|
250
|
+
* }} args `category` defaults to the unclassified light-refusal bucket, which
|
|
251
|
+
* is what the suitability gate emits: it refuses a prompt before any diff
|
|
252
|
+
* exists, so it carries no refusal class to encode.
|
|
228
253
|
* @returns {Promise<boolean>}
|
|
229
254
|
*/
|
|
230
255
|
async function recordScopeFriction({
|
|
231
256
|
storyId,
|
|
232
257
|
surface,
|
|
258
|
+
category = RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED,
|
|
233
259
|
reasons = [],
|
|
234
260
|
details = {},
|
|
235
261
|
emitFn,
|
|
@@ -238,7 +264,7 @@ async function recordScopeFriction({
|
|
|
238
264
|
try {
|
|
239
265
|
return await emit({
|
|
240
266
|
storyId,
|
|
241
|
-
category
|
|
267
|
+
category,
|
|
242
268
|
tool: 'deliver-light',
|
|
243
269
|
details: { surface, reasons, ...details },
|
|
244
270
|
});
|
|
@@ -524,6 +524,52 @@ export function resolveLightGateOutcome({
|
|
|
524
524
|
};
|
|
525
525
|
}
|
|
526
526
|
|
|
527
|
+
/**
|
|
528
|
+
* The refusal classes a blocked diff backstop can carry — the machine-readable
|
|
529
|
+
* half of a verdict whose `reasons[]` are prose (Story #5238).
|
|
530
|
+
*
|
|
531
|
+
* One value per blocked verdict, and the reason it exists is downstream: the
|
|
532
|
+
* refusal's friction category is derived from it
|
|
533
|
+
* ({@link module:lib/observability/runtime-friction.lightScopeRejectedCategory}),
|
|
534
|
+
* and the category is the ONLY key the retro composer separates buckets on.
|
|
535
|
+
* Under one bare category an empty-diff refusal and a `public-api` refusal
|
|
536
|
+
* aggregated into a single "recurred 2 times" follow-up with nothing in common
|
|
537
|
+
* (issue #5237) — the roll-up's shape fingerprint could not tell them apart
|
|
538
|
+
* either, because it hashes detail keys and every refusal carries the same set.
|
|
539
|
+
*
|
|
540
|
+
* Kept coarse on purpose: a class must be stable enough that N refusals of one
|
|
541
|
+
* cause still coalesce into the recurrence evidence the ceilings are
|
|
542
|
+
* recalibrated from.
|
|
543
|
+
*
|
|
544
|
+
* @typedef {{ reason: string, refusalClass: string }} Objection
|
|
545
|
+
*/
|
|
546
|
+
export const LIGHT_REFUSAL_CLASSES = Object.freeze({
|
|
547
|
+
/** The diff could not be enumerated, or enumerated to nothing. */
|
|
548
|
+
CHANGE_SET_UNKNOWN: 'change-set-unknown',
|
|
549
|
+
/** Enumerated-empty, but the worktree carries uncommitted changes. */
|
|
550
|
+
UNCOMMITTED_WORK: 'uncommitted-work',
|
|
551
|
+
/** The change set intersects a registered sensitive-path class. */
|
|
552
|
+
SENSITIVE_PATH: 'sensitive-path',
|
|
553
|
+
/** Sensitivity could not be classified, so non-sensitivity is unproven. */
|
|
554
|
+
SENSITIVITY_UNKNOWN: 'sensitivity-unknown',
|
|
555
|
+
/** The implementation magnitude could not be measured. */
|
|
556
|
+
MAGNITUDE_UNKNOWN: 'magnitude-unknown',
|
|
557
|
+
/** Measured magnitude exceeded a light ceiling. */
|
|
558
|
+
OVER_CEILING: 'over-ceiling',
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Name the branch a commit-first refusal tells the agent to commit on, with a
|
|
563
|
+
* generic stand-in when the caller supplied none. Pure.
|
|
564
|
+
*
|
|
565
|
+
* @param {unknown} storyBranch
|
|
566
|
+
* @returns {string}
|
|
567
|
+
*/
|
|
568
|
+
function describeStoryBranch(storyBranch) {
|
|
569
|
+
const name = typeof storyBranch === 'string' ? storyBranch.trim() : '';
|
|
570
|
+
return name === '' ? 'the Story branch' : name;
|
|
571
|
+
}
|
|
572
|
+
|
|
527
573
|
/**
|
|
528
574
|
* Diff-derived backstop (Story #4740 AC-4, re-based on magnitude by Story
|
|
529
575
|
* #4856): re-check the **actual** change set after implementation, because the
|
|
@@ -555,7 +601,14 @@ export function resolveLightGateOutcome({
|
|
|
555
601
|
* ceilings?: { maxImplLines?: number, maxImplFiles?: number },
|
|
556
602
|
* injectedRules?: object,
|
|
557
603
|
* selectSensitivePathClassesFn?: Function,
|
|
558
|
-
*
|
|
604
|
+
* storyBranch?: string,
|
|
605
|
+
* uncommittedWork?: boolean,
|
|
606
|
+
* }} [args] `uncommittedWork` is the caller's dirty-worktree probe result: the
|
|
607
|
+
* backstop reads COMMITTED state, so an implemented-but-uncommitted run
|
|
608
|
+
* measures an empty diff, and the door for that is `git commit` — not an
|
|
609
|
+
* escalation. It only ever refines an enumerated-empty verdict's guidance;
|
|
610
|
+
* the verdict itself still blocks. `storyBranch` names the branch that
|
|
611
|
+
* guidance points at.
|
|
559
612
|
* @returns {{
|
|
560
613
|
* blocked: boolean,
|
|
561
614
|
* level: 'low'|'high'|null,
|
|
@@ -563,8 +616,10 @@ export function resolveLightGateOutcome({
|
|
|
563
616
|
* fileCount: number|null,
|
|
564
617
|
* magnitude: { implFiles: number, implLines: number }|null,
|
|
565
618
|
* ceilings: { maxImplLines: number, maxImplFiles: number },
|
|
619
|
+
* refusalClass: string|null,
|
|
566
620
|
* reasons: string[],
|
|
567
|
-
* }}
|
|
621
|
+
* }} `refusalClass` is `null` on a clean verdict and exactly one
|
|
622
|
+
* {@link LIGHT_REFUSAL_CLASSES} value on every blocked one.
|
|
568
623
|
*/
|
|
569
624
|
export function checkLightDiffBackstop({
|
|
570
625
|
changedFiles,
|
|
@@ -572,6 +627,8 @@ export function checkLightDiffBackstop({
|
|
|
572
627
|
ceilings,
|
|
573
628
|
injectedRules,
|
|
574
629
|
selectSensitivePathClassesFn,
|
|
630
|
+
storyBranch,
|
|
631
|
+
uncommittedWork = false,
|
|
575
632
|
} = {}) {
|
|
576
633
|
const resolved = resolveDiffCeilings(ceilings);
|
|
577
634
|
const files = Array.isArray(changedFiles)
|
|
@@ -579,6 +636,12 @@ export function checkLightDiffBackstop({
|
|
|
579
636
|
: null;
|
|
580
637
|
|
|
581
638
|
if (files === null || files.length === 0) {
|
|
639
|
+
// An ENUMERATED-empty diff over a dirty worktree is a different event from
|
|
640
|
+
// an unverifiable one, and blocking is right for both — but only one of
|
|
641
|
+
// them is about scope. The caller's probe distinguishes them; `files ===
|
|
642
|
+
// null` never can, because a `git diff` that failed outright is exactly
|
|
643
|
+
// the case where nothing about the change is known.
|
|
644
|
+
const uncommitted = files !== null && uncommittedWork === true;
|
|
582
645
|
return {
|
|
583
646
|
blocked: true,
|
|
584
647
|
level: null,
|
|
@@ -586,8 +649,13 @@ export function checkLightDiffBackstop({
|
|
|
586
649
|
fileCount: files === null ? null : 0,
|
|
587
650
|
magnitude: null,
|
|
588
651
|
ceilings: resolved,
|
|
652
|
+
refusalClass: uncommitted
|
|
653
|
+
? LIGHT_REFUSAL_CLASSES.UNCOMMITTED_WORK
|
|
654
|
+
: LIGHT_REFUSAL_CLASSES.CHANGE_SET_UNKNOWN,
|
|
589
655
|
reasons: [
|
|
590
|
-
|
|
656
|
+
uncommitted
|
|
657
|
+
? `the change set is empty but the worktree has uncommitted changes — commit them on ${describeStoryBranch(storyBranch)}, then re-run the backstop; nothing here is over-scope, so do NOT escalate to /mandrel-plan`
|
|
658
|
+
: 'actual change set is unknown or empty — cannot verify the diff is light; escalate to /mandrel-plan',
|
|
591
659
|
],
|
|
592
660
|
};
|
|
593
661
|
}
|
|
@@ -599,12 +667,12 @@ export function checkLightDiffBackstop({
|
|
|
599
667
|
});
|
|
600
668
|
const measured = normalizeMagnitude(magnitude);
|
|
601
669
|
|
|
602
|
-
const
|
|
670
|
+
const objections = [
|
|
603
671
|
...describeSensitivity({ level, classes }),
|
|
604
672
|
...describeMagnitude(measured, resolved),
|
|
605
673
|
];
|
|
606
674
|
|
|
607
|
-
const blocked =
|
|
675
|
+
const blocked = objections.length > 0;
|
|
608
676
|
return {
|
|
609
677
|
blocked,
|
|
610
678
|
level,
|
|
@@ -612,8 +680,13 @@ export function checkLightDiffBackstop({
|
|
|
612
680
|
fileCount: files.length,
|
|
613
681
|
magnitude: measured,
|
|
614
682
|
ceilings: resolved,
|
|
683
|
+
// Objection ORDER is the class precedence: sensitivity is derived before
|
|
684
|
+
// magnitude, so a diff that is both sensitive and over-ceiling files as a
|
|
685
|
+
// sensitive-path refusal. That is the right way round — the ceiling is
|
|
686
|
+
// recalibratable, the sensitive path is not.
|
|
687
|
+
refusalClass: blocked ? objections[0].refusalClass : null,
|
|
615
688
|
reasons: blocked
|
|
616
|
-
?
|
|
689
|
+
? objections.map((objection) => objection.reason)
|
|
617
690
|
: [
|
|
618
691
|
`diff is light: ${measured.implLines} implementation line(s) ≤ ${resolved.maxImplLines} ` +
|
|
619
692
|
`across ${measured.implFiles} implementation file(s) ≤ ${resolved.maxImplFiles} ` +
|
|
@@ -641,17 +714,24 @@ function normalizeMagnitude(magnitude) {
|
|
|
641
714
|
* Sensitivity objections, over the **full** change set. Pure.
|
|
642
715
|
*
|
|
643
716
|
* @param {{ level: 'low'|'high'|null, classes: string[] }} derived
|
|
644
|
-
* @returns {
|
|
717
|
+
* @returns {Objection[]}
|
|
645
718
|
*/
|
|
646
719
|
function describeSensitivity({ level, classes }) {
|
|
647
720
|
if (classes.length > 0) {
|
|
648
721
|
return [
|
|
649
|
-
|
|
722
|
+
{
|
|
723
|
+
refusalClass: LIGHT_REFUSAL_CLASSES.SENSITIVE_PATH,
|
|
724
|
+
reason: `diff intersects sensitive-path class(es) ${classes.join(', ')} — escalate to /mandrel-plan (do not land light)`,
|
|
725
|
+
},
|
|
650
726
|
];
|
|
651
727
|
}
|
|
652
728
|
if (level !== 'low') {
|
|
653
729
|
return [
|
|
654
|
-
|
|
730
|
+
{
|
|
731
|
+
refusalClass: LIGHT_REFUSAL_CLASSES.SENSITIVITY_UNKNOWN,
|
|
732
|
+
reason:
|
|
733
|
+
'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /mandrel-plan',
|
|
734
|
+
},
|
|
655
735
|
];
|
|
656
736
|
}
|
|
657
737
|
return [];
|
|
@@ -662,26 +742,32 @@ function describeSensitivity({ level, classes }) {
|
|
|
662
742
|
*
|
|
663
743
|
* @param {{ implFiles: number, implLines: number }|null} measured
|
|
664
744
|
* @param {{ maxImplLines: number, maxImplFiles: number }} ceilings
|
|
665
|
-
* @returns {
|
|
745
|
+
* @returns {Objection[]}
|
|
666
746
|
*/
|
|
667
747
|
function describeMagnitude(measured, ceilings) {
|
|
668
748
|
if (measured === null) {
|
|
669
749
|
return [
|
|
670
|
-
|
|
750
|
+
{
|
|
751
|
+
refusalClass: LIGHT_REFUSAL_CLASSES.MAGNITUDE_UNKNOWN,
|
|
752
|
+
reason:
|
|
753
|
+
'change magnitude could not be measured (unreadable or unparseable numstat) — cannot verify the diff is light; escalate to /mandrel-plan',
|
|
754
|
+
},
|
|
671
755
|
];
|
|
672
756
|
}
|
|
673
|
-
const
|
|
757
|
+
const objections = [];
|
|
674
758
|
if (measured.implLines > ceilings.maxImplLines) {
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
759
|
+
objections.push({
|
|
760
|
+
refusalClass: LIGHT_REFUSAL_CLASSES.OVER_CEILING,
|
|
761
|
+
reason: `diff changes ${measured.implLines} implementation line(s) (> maxImplLines ${ceilings.maxImplLines}) — escalate to /mandrel-plan (do not land light)`,
|
|
762
|
+
});
|
|
678
763
|
}
|
|
679
764
|
if (measured.implFiles > ceilings.maxImplFiles) {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
765
|
+
objections.push({
|
|
766
|
+
refusalClass: LIGHT_REFUSAL_CLASSES.OVER_CEILING,
|
|
767
|
+
reason: `diff spans ${measured.implFiles} implementation file(s) (> maxImplFiles ${ceilings.maxImplFiles}) — escalate to /mandrel-plan (do not land light)`,
|
|
768
|
+
});
|
|
683
769
|
}
|
|
684
|
-
return
|
|
770
|
+
return objections;
|
|
685
771
|
}
|
|
686
772
|
|
|
687
773
|
/** Cap on a receipt slug's length — keep the branch/id readable. */
|
|
@@ -305,6 +305,39 @@ function fingerprintBucket(category, tools, detailKeys) {
|
|
|
305
305
|
.slice(0, 8);
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Every reason text one signal's `details` carries, read from BOTH shapes the
|
|
310
|
+
* emitters actually write.
|
|
311
|
+
*
|
|
312
|
+
* Two keys because two emitter conventions, and the divergence was silent: the
|
|
313
|
+
* degradation emitters write a singular `details.reason` string, while the
|
|
314
|
+
* light path's refusal emitter (`light-escalation.recordScopeFriction`) writes
|
|
315
|
+
* `details.reasons` — an **array**, because one backstop verdict can object on
|
|
316
|
+
* sensitivity and magnitude in the same pass. Reading only the singular key is
|
|
317
|
+
* why every `light-scope-rejected` follow-up rendered with no `Reason:` line
|
|
318
|
+
* at all (issue #5237), which defeated Story #4837's whole intent for that
|
|
319
|
+
* emitter: the filed issue named a count and a category, and the refusal text
|
|
320
|
+
* that would have told a reader which ceiling fired stayed in the ledger.
|
|
321
|
+
*
|
|
322
|
+
* Non-string members are skipped rather than coerced — `String(value)` would
|
|
323
|
+
* put `[object Object]` in a live issue body.
|
|
324
|
+
*
|
|
325
|
+
* @param {object} details
|
|
326
|
+
* @returns {string[]}
|
|
327
|
+
*/
|
|
328
|
+
function collectReasons(details) {
|
|
329
|
+
const out = [];
|
|
330
|
+
const single = asString(details.reason);
|
|
331
|
+
if (single.length > 0) out.push(single);
|
|
332
|
+
if (Array.isArray(details.reasons)) {
|
|
333
|
+
for (const raw of details.reasons) {
|
|
334
|
+
const text = asString(raw);
|
|
335
|
+
if (text.length > 0) out.push(text);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
308
341
|
/**
|
|
309
342
|
* Widen a bucket's first-to-last window to include one instant. A row with no
|
|
310
343
|
* usable `ts` widens nothing — it is counted in `total` but cannot date the
|
|
@@ -391,8 +424,9 @@ function aggregateByCategory(signals) {
|
|
|
391
424
|
// over keys, deliberately) is untouched by them.
|
|
392
425
|
const surface = asString(sig.details.surface);
|
|
393
426
|
if (surface.length > 0) entry.surfaces.add(surface);
|
|
394
|
-
const reason
|
|
395
|
-
|
|
427
|
+
for (const reason of collectReasons(sig.details)) {
|
|
428
|
+
entry.reasons.add(reason);
|
|
429
|
+
}
|
|
396
430
|
}
|
|
397
431
|
// An id that cannot be resolved to a real issue is tracked separately
|
|
398
432
|
// rather than dropped: it must never be counted or printed as recurrence
|