mandrel 2.47.0 → 2.49.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 +49 -49
- package/.agents/docs/configuration.md +1 -0
- package/.agents/docs/quality-gates.md +48 -0
- package/.agents/scripts/lib/baselines/kernel.js +19 -0
- package/.agents/scripts/lib/baselines/kinds/bundle-size.js +12 -0
- package/.agents/scripts/lib/baselines/kinds/coverage.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +21 -5
- package/.agents/scripts/lib/baselines/kinds/duplication.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/kind-factory.js +26 -1
- package/.agents/scripts/lib/baselines/kinds/lighthouse.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/lint.js +12 -0
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/mutation.js +1 -0
- package/.agents/scripts/lib/baselines/merge-envelopes.js +272 -0
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +175 -0
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +8 -2
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/epic-container.js +48 -21
- package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
- package/.agents/scripts/lib/orchestration/epic-rollup.js +66 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +32 -61
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +171 -0
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +483 -0
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/merge-baseline.js +238 -0
- package/.agents/scripts/providers/github/errors.js +66 -10
- package/.agents/scripts/providers/github/sub-issues.js +8 -1
- package/.agents/workflows/helpers/deliver-digest.md +30 -26
- package/.agents/workflows/helpers/parallel-tooling.md +17 -0
- package/docs/CHANGELOG.md +25 -0
- package/lib/cli/registry.js +63 -0
- package/package.json +1 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* merge-baseline.js — git merge driver for `baselines/*.json` (Story #5215).
|
|
5
|
+
*
|
|
6
|
+
* ## The failure it replaces
|
|
7
|
+
*
|
|
8
|
+
* Every baseline write stamps `generatedAt` on line 4, so two branches that
|
|
9
|
+
* each refresh a baseline ALWAYS differ there — even when they moved
|
|
10
|
+
* completely disjoint rows. Git merges JSON as text, so whether it can
|
|
11
|
+
* separate that hunk from the moved rows is an accident of proximity:
|
|
12
|
+
*
|
|
13
|
+
* - it cannot → a conflict on work that never overlapped (the observed
|
|
14
|
+
* `coverage.json` / `maintainability.json` "always conflicts" pattern);
|
|
15
|
+
* - it can → it splices both sides' row lines into a row set neither side
|
|
16
|
+
* scored, and the ratchet then guards a number no scorer produced (the
|
|
17
|
+
* observed `crap.json` "silently auto-merges" pattern).
|
|
18
|
+
*
|
|
19
|
+
* The quiet one is the worse one. A baseline is a set of rows keyed by
|
|
20
|
+
* identity plus a rollup derived from them, so this driver merges it as
|
|
21
|
+
* that — see `lib/baselines/merge-envelopes.js` for the semantics.
|
|
22
|
+
*
|
|
23
|
+
* ## Contract
|
|
24
|
+
*
|
|
25
|
+
* node .agents/scripts/merge-baseline.js %O %A %B %P
|
|
26
|
+
*
|
|
27
|
+
* git's merge-driver calling convention: `%O` ancestor, `%A` ours (and the
|
|
28
|
+
* file the driver MUST leave its result in), `%B` theirs, `%P` the real
|
|
29
|
+
* pathname being merged. Exit 0 merged clean, non-zero conflicted.
|
|
30
|
+
*
|
|
31
|
+
* Registered per clone (registration is per-clone, so `mandrel doctor` is
|
|
32
|
+
* the guard that it happened, not `mandrel sync`):
|
|
33
|
+
*
|
|
34
|
+
* .gitattributes: baselines/*.json merge=mandrel-baseline
|
|
35
|
+
* git config: merge.mandrel-baseline.driver
|
|
36
|
+
*
|
|
37
|
+
* ## Not every `baselines/*.json` is an envelope
|
|
38
|
+
*
|
|
39
|
+
* That glob also matches arch-cycles, cyclomatic, dead-exports, audit-ledger,
|
|
40
|
+
* context-budget and workflow-citations — files with their own shapes and no
|
|
41
|
+
* row identity. Anything whose `$schema` is not a known per-kind envelope is
|
|
42
|
+
* handed straight back to `git merge-file`, so registering the driver cannot
|
|
43
|
+
* change their behaviour.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import fs from 'node:fs';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
|
|
49
|
+
import { assertEnvelope } from './lib/baselines/envelope.js';
|
|
50
|
+
import {
|
|
51
|
+
kindFromEnvelope,
|
|
52
|
+
mergeEnvelopes,
|
|
53
|
+
} from './lib/baselines/merge-envelopes.js';
|
|
54
|
+
import { writeFile as writeEnvelopeFile } from './lib/baselines/writer.js';
|
|
55
|
+
import { spawnChild } from './lib/child-exec.js';
|
|
56
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
57
|
+
|
|
58
|
+
/** Indent one row's canonical JSON to its position inside `rows`. */
|
|
59
|
+
function rowBlock(row) {
|
|
60
|
+
return JSON.stringify(row, null, 2)
|
|
61
|
+
.split('\n')
|
|
62
|
+
.map((line) => ` ${line}`)
|
|
63
|
+
.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wrap each conflicting row in git conflict markers, leaving every other row
|
|
68
|
+
* merged. Operates on the canonical text the writer already produced, so the
|
|
69
|
+
* non-conflicting remainder of the file is byte-identical to what a clean
|
|
70
|
+
* merge would have written.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} text Canonical serialization of the merged envelope.
|
|
73
|
+
* @param {Array<object>} conflicts Row-scoped conflict records.
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function renderConflictMarkers(text, conflicts) {
|
|
77
|
+
let out = text;
|
|
78
|
+
for (const conflict of conflicts) {
|
|
79
|
+
const placed = conflict.ours ?? conflict.theirs;
|
|
80
|
+
if (placed === undefined) continue;
|
|
81
|
+
const block = rowBlock(placed);
|
|
82
|
+
// The row may or may not be the last element of `rows`; keep whichever
|
|
83
|
+
// separator follows it on both sides so the markers wrap whole lines.
|
|
84
|
+
const withComma = `${block},`;
|
|
85
|
+
const [needle, suffix] = out.includes(withComma)
|
|
86
|
+
? [withComma, ',']
|
|
87
|
+
: [block, ''];
|
|
88
|
+
if (!out.includes(needle)) continue;
|
|
89
|
+
const ourSide =
|
|
90
|
+
conflict.ours === undefined
|
|
91
|
+
? ''
|
|
92
|
+
: `${rowBlock(conflict.ours)}${suffix}\n`;
|
|
93
|
+
const theirSide =
|
|
94
|
+
conflict.theirs === undefined
|
|
95
|
+
? ''
|
|
96
|
+
: `${rowBlock(conflict.theirs)}${suffix}\n`;
|
|
97
|
+
out = out.replace(
|
|
98
|
+
needle,
|
|
99
|
+
`<<<<<<< ours\n${ourSide}=======\n${theirSide}>>>>>>> theirs`.replace(
|
|
100
|
+
/\n$/,
|
|
101
|
+
'',
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read and parse a merge input; a missing or empty side is `null`. */
|
|
109
|
+
function readSide(file) {
|
|
110
|
+
if (!file || !fs.existsSync(file)) return null;
|
|
111
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
112
|
+
if (raw.trim() === '') return null;
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(raw);
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined; // present but unparseable — caller falls back to git
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Hand the merge back to git's own text merge. Used for every
|
|
122
|
+
* `baselines/*.json` that is not a known per-kind envelope, and for one that
|
|
123
|
+
* is too damaged to parse — in both cases the driver must not invent a
|
|
124
|
+
* result, and git's behaviour is exactly what the repo had before.
|
|
125
|
+
*
|
|
126
|
+
* @returns {number} git merge-file's own exit code.
|
|
127
|
+
*/
|
|
128
|
+
function delegateToGit(basePath, oursPath, theirsPath) {
|
|
129
|
+
// `stdio: 'inherit'` so git's own conflict reporting reaches the operator
|
|
130
|
+
// exactly as it would have with no driver registered. `spawnChild` returns
|
|
131
|
+
// the RAW result deliberately: a `status` of null means the child was
|
|
132
|
+
// killed, and that must never be read as a clean merge.
|
|
133
|
+
const result = spawnChild(
|
|
134
|
+
'git',
|
|
135
|
+
['merge-file', oursPath, basePath, theirsPath],
|
|
136
|
+
{ stdio: 'inherit' },
|
|
137
|
+
);
|
|
138
|
+
if (result.error) {
|
|
139
|
+
process.stderr.write(
|
|
140
|
+
`merge-baseline: could not run git merge-file: ${result.error.message}\n`,
|
|
141
|
+
);
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
return result.status ?? 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string[]} argv Positional arguments: %O %A %B [%P].
|
|
149
|
+
* @returns {number} Process exit code.
|
|
150
|
+
*/
|
|
151
|
+
export function runMergeBaseline(argv) {
|
|
152
|
+
const [baseArg, oursArg, theirsArg, mergedPath] = argv;
|
|
153
|
+
if (!baseArg || !oursArg || !theirsArg) {
|
|
154
|
+
process.stderr.write(
|
|
155
|
+
'merge-baseline: expected the git merge-driver arguments %O %A %B %P\n',
|
|
156
|
+
);
|
|
157
|
+
return 2;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Git hands the driver temp filenames RELATIVE to the worktree root it
|
|
161
|
+
// invokes us from (`.merge_file_xxxxxx`), so every path is resolved before
|
|
162
|
+
// use — the shared writer refuses a relative path, and that refusal only
|
|
163
|
+
// shows up under a real `git merge`, never when the driver is called
|
|
164
|
+
// directly with absolute paths.
|
|
165
|
+
const [basePath, oursPath, theirsPath] = [baseArg, oursArg, theirsArg].map(
|
|
166
|
+
(p) => path.resolve(p),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const ours = readSide(oursPath);
|
|
170
|
+
const theirs = readSide(theirsPath);
|
|
171
|
+
const base = readSide(basePath);
|
|
172
|
+
|
|
173
|
+
const kind = kindFromEnvelope(ours) ?? kindFromEnvelope(theirs);
|
|
174
|
+
if (!kind || ours === undefined || theirs === undefined) {
|
|
175
|
+
return delegateToGit(basePath, oursPath, theirsPath);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let merged;
|
|
179
|
+
try {
|
|
180
|
+
merged = mergeEnvelopes({ base, ours, theirs, kind });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
process.stderr.write(`merge-baseline: ${kind}: ${err.message}\n`);
|
|
183
|
+
return delegateToGit(basePath, oursPath, theirsPath);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const rowConflicts = merged.conflicts.filter((c) => c.scope === 'row');
|
|
187
|
+
const envelopeConflicts = merged.conflicts.filter(
|
|
188
|
+
(c) => c.scope === 'envelope',
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Write the canonical projection first even when conflicted: the marker
|
|
192
|
+
// rendering operates on exactly the bytes a clean merge would have left,
|
|
193
|
+
// so the merged remainder of a conflicted file is identical to it.
|
|
194
|
+
writeEnvelopeFile(oursPath, merged.envelope);
|
|
195
|
+
|
|
196
|
+
if (merged.conflicts.length === 0) {
|
|
197
|
+
assertEnvelope(merged.envelope);
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const label = mergedPath || oursPath;
|
|
202
|
+
for (const conflict of envelopeConflicts) {
|
|
203
|
+
process.stderr.write(
|
|
204
|
+
`merge-baseline: conflict ${kind} envelope key "${conflict.identity}" in ${label} — ours ${JSON.stringify(conflict.ours)}, theirs ${JSON.stringify(conflict.theirs)}\n`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
for (const conflict of rowConflicts) {
|
|
208
|
+
process.stderr.write(
|
|
209
|
+
`merge-baseline: conflict ${kind} row "${conflict.identity}" in ${label}\n`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (rowConflicts.length > 0) {
|
|
214
|
+
const text = fs.readFileSync(oursPath, 'utf8');
|
|
215
|
+
fs.writeFileSync(oursPath, renderConflictMarkers(text, rowConflicts));
|
|
216
|
+
}
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function main() {
|
|
221
|
+
return runMergeBaseline(process.argv.slice(2));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
runAsCli(import.meta.url, main, {
|
|
225
|
+
source: 'merge-baseline',
|
|
226
|
+
propagateExitCode: true,
|
|
227
|
+
usage: {
|
|
228
|
+
invocation: 'node .agents/scripts/merge-baseline.js %O %A %B %P',
|
|
229
|
+
summary:
|
|
230
|
+
'Git merge driver for baselines/*.json. Merges per-kind envelopes by ROW IDENTITY — disjoint refreshes merge clean, the rollup is recomputed from the merged rows, and generatedAt resolves to the later stamp instead of conflicting. A baselines file that is not a known per-kind envelope is handed back to git merge-file unchanged. Exit 0 clean, 1 conflicted.',
|
|
231
|
+
flags: [
|
|
232
|
+
['%O', 'Merge ancestor (git supplies this).'],
|
|
233
|
+
['%A', 'Our version — the driver writes its result here.'],
|
|
234
|
+
['%B', 'Their version.'],
|
|
235
|
+
['%P', 'Real pathname being merged; used in conflict messages.'],
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
});
|
|
@@ -87,17 +87,67 @@ function matchesAny(haystack, needles) {
|
|
|
87
87
|
return false;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** `gh` renders the HTTP status onto stderr as `HTTP 403: <reason>`. */
|
|
91
|
+
const GH_STDERR_STATUS_RE = /\bHTTP (\d{3})\b/i;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The captured `gh` stderr, or `''` when the error carries none.
|
|
95
|
+
*
|
|
96
|
+
* Its own function so {@link extractErrorFields} keeps the cyclomatic weight it
|
|
97
|
+
* had before stderr became a classification input — the field is read twice
|
|
98
|
+
* there, and inlining the guard twice is what pushed the CRAP ratchet.
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} err
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function stderrText(err) {
|
|
104
|
+
return typeof err?.stderr === 'string' ? err.stderr : '';
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
108
|
+
* Recover the HTTP status from a `gh`-CLI failure's stderr.
|
|
109
|
+
*
|
|
110
|
+
* The `fetch` transport sets `err.status`; the `gh` transport does not — it
|
|
111
|
+
* has only an exit code, and puts the status in the text it printed. Without
|
|
112
|
+
* this, every `gh`-path failure reached the status rules as `undefined` and a
|
|
113
|
+
* 403 or a 429 was indistinguishable from an unclassifiable error (Story
|
|
114
|
+
* #5210).
|
|
115
|
+
*
|
|
116
|
+
* @param {unknown} stderr
|
|
117
|
+
* @returns {number|undefined}
|
|
118
|
+
*/
|
|
119
|
+
function statusFromStderr(stderr) {
|
|
120
|
+
if (typeof stderr !== 'string') return undefined;
|
|
121
|
+
const m = GH_STDERR_STATUS_RE.exec(stderr);
|
|
122
|
+
return m ? Number.parseInt(m[1], 10) : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Extract `{ lower, detail, status, code }` from an error in the shape
|
|
127
|
+
* `gh-exec` throws. Pure — exported style for unit-testability without
|
|
128
|
+
* instantiating the provider. Defensive on shape: errors arrive as `Error`
|
|
129
|
+
* objects, plain `{message,status,code}` bags, or non-Errors stringified into
|
|
130
|
+
* `String(err)`.
|
|
131
|
+
*
|
|
132
|
+
* `lower` is the message alone. `detail` is the message **plus** any captured
|
|
133
|
+
* `stderr`, and is what the keyword rules read: on the `gh` path the message
|
|
134
|
+
* is the classified summary (`gh-exec: gh exited with code 1`) and every
|
|
135
|
+
* actionable word — the status line, `secondary rate limit`, the missing
|
|
136
|
+
* GraphQL field — lives only on stderr. Matching the keyword lists against the
|
|
137
|
+
* message alone is what flattened a retryable 403 to `permanent` and let the
|
|
138
|
+
* Epic rollup treat a rate-limit burst as a settled answer (Story #5210).
|
|
95
139
|
*/
|
|
96
140
|
export function extractErrorFields(err) {
|
|
97
141
|
const message = typeof err.message === 'string' ? err.message : String(err);
|
|
142
|
+
const stderr = stderrText(err);
|
|
143
|
+
const lower = message.toLowerCase();
|
|
98
144
|
return {
|
|
99
|
-
lower
|
|
100
|
-
|
|
145
|
+
lower,
|
|
146
|
+
// Unconditional concatenation: with no stderr this is the message plus a
|
|
147
|
+
// trailing space, which every `includes` rule below reads identically.
|
|
148
|
+
detail: `${lower} ${stderr.toLowerCase()}`,
|
|
149
|
+
status:
|
|
150
|
+
typeof err.status === 'number' ? err.status : statusFromStderr(stderr),
|
|
101
151
|
code: typeof err.code === 'string' ? err.code : undefined,
|
|
102
152
|
};
|
|
103
153
|
}
|
|
@@ -127,17 +177,23 @@ export function classifyGithubError(err) {
|
|
|
127
177
|
// no `.status` / `.code`. Match by `err.name` to avoid a circular import
|
|
128
178
|
// between this module and `lib/gh-exec.js`. Story #2860.
|
|
129
179
|
if (err.name === 'GhExecTimeoutError') return 'transient';
|
|
130
|
-
|
|
131
|
-
|
|
180
|
+
// Every keyword rule below reads `detail` (message + stderr), never `lower`
|
|
181
|
+
// alone: the `gh` transport carries its reason exclusively on stderr, so a
|
|
182
|
+
// message-only match sees nothing but the exit code. Rule ORDER is
|
|
183
|
+
// load-bearing and unchanged — a secondary rate limit is delivered as HTTP
|
|
184
|
+
// 403, so the transient rules must stay ahead of the permission rule or it
|
|
185
|
+
// would bucket as 'permission' and never retry.
|
|
186
|
+
const { detail, status, code } = extractErrorFields(err);
|
|
187
|
+
if (matchesAny(detail, FEATURE_DISABLED_MESSAGES)) return 'feature-disabled';
|
|
132
188
|
if (isTransientStatus(status)) return 'transient';
|
|
133
|
-
if (isTransientByCodeOrMessage(code,
|
|
189
|
+
if (isTransientByCodeOrMessage(code, detail)) return 'transient';
|
|
134
190
|
// Union with the former `transient-retry.js` predicate (Story #4298):
|
|
135
191
|
// retry on network/connectivity blips the status/code checks above miss
|
|
136
192
|
// (e.g. a `dial tcp ... i/o timeout` on `err.stderr` from the gh-CLI path,
|
|
137
193
|
// or `ECONNREFUSED` / `ENETUNREACH`). Checked before the permission rule so
|
|
138
194
|
// a transient network failure never masquerades as a permanent denial.
|
|
139
195
|
if (isTransientNetworkError(err)) return 'transient';
|
|
140
|
-
if (isPermissionSignal(status,
|
|
196
|
+
if (isPermissionSignal(status, detail)) return 'permission';
|
|
141
197
|
return 'permanent';
|
|
142
198
|
}
|
|
143
199
|
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { describeGhFailure } from '../../lib/gh-exec.js';
|
|
26
27
|
import { Logger } from '../../lib/Logger.js';
|
|
27
28
|
import {
|
|
28
29
|
classifyGithubError as defaultClassifyGithubError,
|
|
@@ -104,8 +105,14 @@ export class SubIssueGateway {
|
|
|
104
105
|
);
|
|
105
106
|
return [];
|
|
106
107
|
}
|
|
108
|
+
// `describeGhFailure`, not `err.message`: on the gh transport the
|
|
109
|
+
// message is only the classified summary (`gh exited with code 1`) and
|
|
110
|
+
// the actionable sentence — the HTTP status, the rate-limit notice — is
|
|
111
|
+
// on stderr. Three identical opaque lines are what made the Epic-rollup
|
|
112
|
+
// incident unreadable until the API was queried by hand (Story #5210).
|
|
107
113
|
Logger.error(
|
|
108
|
-
`[GitHubProvider] sub-issues GraphQL failed (parent #${parentId},
|
|
114
|
+
`[GitHubProvider] sub-issues GraphQL failed (parent #${parentId}, ` +
|
|
115
|
+
`category=${category}): ${describeGhFailure(err)}`,
|
|
109
116
|
);
|
|
110
117
|
throw err;
|
|
111
118
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: >-
|
|
3
|
-
The deliver path's one bundled framework read
|
|
4
|
-
|
|
5
|
-
the
|
|
6
|
-
|
|
7
|
-
file instead of re-reading the helper/schema set each session.
|
|
3
|
+
The deliver path's one bundled framework read: dispatch decision, engine
|
|
4
|
+
invariants, the change-set/ceremony incantation, the acceptance-eval gate,
|
|
5
|
+
the credited full-suite run, and the terminal envelope contract — the
|
|
6
|
+
engine reads one file, not the helper/schema set, each session.
|
|
8
7
|
---
|
|
9
8
|
|
|
10
9
|
# Deliver digest (read once per session)
|
|
@@ -23,9 +22,9 @@ Read `stories[].dispatchMode` from the `resolve-stories.js` envelope.
|
|
|
23
22
|
`inline` names one indivisible resource — **the router's own session** — so one
|
|
24
23
|
rule produces it:
|
|
25
24
|
|
|
26
|
-
1. **Run topology.** A run resolving **one** Story is `inline`
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
1. **Run topology.** A run resolving **one** Story is `inline` whatever its
|
|
26
|
+
shape — sub-agent isolation only matters against a *concurrent* sibling
|
|
27
|
+
racing the same checkout, and a one-Story run has none.
|
|
29
28
|
2. **Every other run is `subagent`.** A multi-Story run dispatches every Story
|
|
30
29
|
as a sub-agent however trivial its shape. Shape still sets ceremony; the
|
|
31
30
|
`route::lite` label is a human-visible hint, never the control signal.
|
|
@@ -67,8 +66,8 @@ node --input-type=module -e '
|
|
|
67
66
|
const { level, classes } = deriveChangeLevel({ changedFiles: files });
|
|
68
67
|
// resolveCeremonyForRisk({ derivedLevel, clusterIndex?, freshCriticSampleRate?,
|
|
69
68
|
// ceremonyProfile? }) -> { mode, reason, sampled, profile, verdictOwner }.
|
|
70
|
-
// derivedLevel is that level STRING
|
|
71
|
-
//
|
|
69
|
+
// derivedLevel is that level STRING — the object above matches no tier and
|
|
70
|
+
// routes to the null fail-safe: a fresh critic, silently.
|
|
72
71
|
const ceremony = resolveCeremonyForRisk({ derivedLevel: level, clusterIndex: 0 });
|
|
73
72
|
console.log(JSON.stringify({ files, level, classes, ...ceremony }));
|
|
74
73
|
'
|
|
@@ -96,11 +95,8 @@ every cluster's records into a single `criteria[]` in `acceptance[]` order, one
|
|
|
96
95
|
per acceptance item, and score that once. A gate call per cluster spends a
|
|
97
96
|
round *per cluster* and races the round ledger:
|
|
98
97
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
--story <storyId> --verdict <merged-verdict-path> \
|
|
102
|
-
--expected-criteria <acceptance[] count>
|
|
103
|
-
```
|
|
98
|
+
`node <main-repo>/.agents/scripts/acceptance-eval.js --story <storyId>
|
|
99
|
+
--verdict <merged-verdict-path> --expected-criteria <acceptance[] count>`
|
|
104
100
|
|
|
105
101
|
Pass `--expected-criteria` — **without it the coverage assertion is inert**, so
|
|
106
102
|
an unmerged cluster verdict scores a fraction of the criteria and still reports
|
|
@@ -115,20 +111,27 @@ Per-round mechanics: [`acceptance-self-eval.md`](acceptance-self-eval.md).
|
|
|
115
111
|
**After the self-eval loop's last fix commit, immediately before the push** —
|
|
116
112
|
the credit is keyed on the tree, so any later commit invalidates it. Redraft
|
|
117
113
|
rounds run scoped tests; only this final run needs credit, and a bare
|
|
118
|
-
`npm test` / `pnpm run test` deposits **none**, so close re-runs
|
|
119
|
-
|
|
120
|
-
gate:
|
|
114
|
+
`npm test` / `pnpm run test` deposits **none**, so close re-runs it. Shape it
|
|
115
|
+
by what `close-validation/gates.js` runs:
|
|
121
116
|
|
|
122
117
|
```bash
|
|
123
|
-
# CRAP gate on (default) + a `test:coverage` script — writes
|
|
124
|
-
# close `coverage-capture` gate reads:
|
|
118
|
+
# CRAP gate on (default) + a `test:coverage` script — writes close's stamp:
|
|
125
119
|
node <main-repo>/.agents/scripts/coverage-capture.js --cwd <workCwd>
|
|
126
|
-
# otherwise — the record
|
|
127
|
-
# runner exactly `npm test
|
|
120
|
+
# otherwise — the record close's `test` gate reads; <workCwd> ABSOLUTE,
|
|
121
|
+
# runner exactly `npm test` (both sides hash {cmd, args, cwd}):
|
|
128
122
|
node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
|
|
129
123
|
--scope-id <storyId> --gate test --worktree <workCwd> -- npm test
|
|
130
124
|
```
|
|
131
125
|
|
|
126
|
+
Dispatch it in the **background**: it outruns the host's synchronous Bash
|
|
127
|
+
ceiling, and its completion re-invokes you. Never spawn a task to poll or
|
|
128
|
+
`sleep`-loop against it ([`parallel-tooling.md`](parallel-tooling.md)
|
|
129
|
+
Rule 2).
|
|
130
|
+
|
|
131
|
+
Read the **output**, not the exit code: capture skips — no test run, no
|
|
132
|
+
credit — when nothing changed under the CRAP `targetDirs`, so run the suite
|
|
133
|
+
yourself before handing off.
|
|
134
|
+
|
|
132
135
|
`verify[]` is scoped entries **plus** this one run: an entry that is itself a
|
|
133
136
|
full-suite command is reported credited against the same stamp, never
|
|
134
137
|
respawned.
|
|
@@ -162,7 +165,8 @@ restores live streaming.
|
|
|
162
165
|
|
|
163
166
|
## 7. When to leave this file
|
|
164
167
|
|
|
165
|
-
|
|
166
|
-
- Lease, sweep, worktree
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
Unclear state / a re-run refusal → `deliver-recover.js --story <id>`
|
|
169
|
+
(read-only). Lease, sweep, worktree scope; sequencing, epilogue, checklist
|
|
170
|
+
threading → [`deliver-story-reference.md`](deliver-story-reference.md) and
|
|
171
|
+
[`deliver-reference.md`](deliver-reference.md). CI red after the PR opens →
|
|
172
|
+
[`rules/ci-remediation.md`](../../rules/ci-remediation.md).
|
|
@@ -47,6 +47,23 @@ the full duration and blocks every other parallel opportunity.
|
|
|
47
47
|
- **Don't poll with `sleep`:** `Monitor` returns on each stdout line. Loop
|
|
48
48
|
on `until <condition>; do sleep 2; done` only when no event stream is
|
|
49
49
|
available — never as a substitute for the event channel.
|
|
50
|
+
- **A hand-rolled waiter outlives the agent that spawned it.** Prefer the
|
|
51
|
+
completion notification: it is the signal, and needs no waiter at all. Two
|
|
52
|
+
measured failure shapes, both from one delivery run whose waiters were
|
|
53
|
+
still listed running nearly eight hours after their agent had finished and
|
|
54
|
+
its worktree had been deleted:
|
|
55
|
+
- An `until` guard that inverts to permanently-false the moment the run
|
|
56
|
+
**succeeds** — `until [ -n "$(grep -l 'Test Files' $LOG)" ] && ! grep -q
|
|
57
|
+
'Test Files' $LOG` exits only while the log both has and lacks the same
|
|
58
|
+
marker.
|
|
59
|
+
- `pgrep -f <literal>` matching the waiter's **own** command line, so it
|
|
60
|
+
finds itself and concludes the work is still running. Forever.
|
|
61
|
+
|
|
62
|
+
The tell is a task file of **0 bytes** with no backing process. If you must
|
|
63
|
+
wait, hold the PID and test `kill -0 "$PID"`, or break the self-match with
|
|
64
|
+
a bracketed pattern (`pgrep -f "[m]y-script.js"`) — and always bound the
|
|
65
|
+
loop with a maximum iteration count so a wrong condition ends the wait
|
|
66
|
+
instead of the agent.
|
|
50
67
|
|
|
51
68
|
## Rule 3 — N parallel `Agent` calls in one turn for N independent units
|
|
52
69
|
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,31 @@ All notable changes to this project will be documented in this file.
|
|
|
15
15
|
-->
|
|
16
16
|
<!-- markdownlint-disable-file MD004 MD012 MD037 -->
|
|
17
17
|
|
|
18
|
+
## [2.49.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.48.0...mandrel-v2.49.0) (2026-09-08)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
* give story-worker a reachable long-command dispatch contract so the credited suite run stops growing hand-rolled waiters ([#5219](https://github.com/dsj1984/mandrel/issues/5219)) ([#5220](https://github.com/dsj1984/mandrel/issues/5220)) ([e2bb9eb](https://github.com/dsj1984/mandrel/commit/e2bb9eba8aaaf49ecf0d8069f36fc4438edf5e40))
|
|
24
|
+
* write improved maintainability rows back at land time so upward baseline drift stops accumulating ([#5224](https://github.com/dsj1984/mandrel/issues/5224)) ([#5227](https://github.com/dsj1984/mandrel/issues/5227)) ([95d2e38](https://github.com/dsj1984/mandrel/commit/95d2e38db7fe87c410dbb40d43818e31ee80a497))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
* tell the worker what to do when the credited full-suite command legitimately skips ([#5225](https://github.com/dsj1984/mandrel/issues/5225)) ([#5226](https://github.com/dsj1984/mandrel/issues/5226)) ([330ecaf](https://github.com/dsj1984/mandrel/commit/330ecaf6001755a515c45f69a495310d8b65643a))
|
|
30
|
+
|
|
31
|
+
## [2.48.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.47.0...mandrel-v2.48.0) (2026-09-08)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
* baselines: merge concurrent refreshes by row identity with a git merge driver ([#5215](https://github.com/dsj1984/mandrel/issues/5215)) ([#5216](https://github.com/dsj1984/mandrel/issues/5216)) ([bcc7057](https://github.com/dsj1984/mandrel/commit/bcc7057d8aad647ec4dae0dc859dd35865fe40a5))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
* never close a container Epic on a degraded child read, and stop flattening gh transport failures to permanent ([#5210](https://github.com/dsj1984/mandrel/issues/5210)) ([#5212](https://github.com/dsj1984/mandrel/issues/5212)) ([08520d7](https://github.com/dsj1984/mandrel/commit/08520d7b5ca0962f98042ca4b103cf0c295516cf))
|
|
42
|
+
|
|
18
43
|
## [2.47.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.46.0...mandrel-v2.47.0) (2026-09-07)
|
|
19
44
|
|
|
20
45
|
|
package/lib/cli/registry.js
CHANGED
|
@@ -25,6 +25,11 @@ import fs from 'node:fs';
|
|
|
25
25
|
import { createRequire } from 'node:module';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import {
|
|
29
|
+
BASELINE_MERGE_DRIVER_CONFIG_KEY,
|
|
30
|
+
BASELINE_MERGE_DRIVER_REMEDY,
|
|
31
|
+
declaresBaselineMergeDriver,
|
|
32
|
+
} from '../../.agents/scripts/lib/bootstrap/baseline-merge-driver.js';
|
|
28
33
|
import {
|
|
29
34
|
REQUIRED_NODE_CEILING_MAJOR,
|
|
30
35
|
REQUIRED_NODE_FLOOR,
|
|
@@ -1074,6 +1079,60 @@ function runVersionCurrent({ cachePath, installedVersion, fsImpl = fs } = {}) {
|
|
|
1074
1079
|
// Registry
|
|
1075
1080
|
// ---------------------------------------------------------------------------
|
|
1076
1081
|
|
|
1082
|
+
/**
|
|
1083
|
+
* Is this clone's `baselines/*.json` merge driver actually registered?
|
|
1084
|
+
*
|
|
1085
|
+
* The two halves of that registration live in different places on purpose.
|
|
1086
|
+
* `.gitattributes` is tracked, so the "use the driver" half ships with the
|
|
1087
|
+
* repo; the driver COMMAND is per-clone `git config`, because git will not
|
|
1088
|
+
* execute a command chosen by whoever wrote the repository. A fresh clone
|
|
1089
|
+
* therefore has the first half and not the second — and git reports nothing
|
|
1090
|
+
* at all, it just quietly falls back to text-merging baselines, which is the
|
|
1091
|
+
* behaviour the driver exists to replace.
|
|
1092
|
+
*
|
|
1093
|
+
* Silent degradation is why this is a doctor check rather than a one-time
|
|
1094
|
+
* install step. It is scoped to repos that opted in: when `.gitattributes`
|
|
1095
|
+
* does not declare the driver, the check passes as skipped, so a consumer
|
|
1096
|
+
* who never installed the quality surface is not told to fix something they
|
|
1097
|
+
* did not ask for.
|
|
1098
|
+
*
|
|
1099
|
+
* @param {{cwd?: () => string, fsImpl?: typeof fs, runner?: typeof spawn}} [opts]
|
|
1100
|
+
* @returns {{ ok: boolean, detail: string, remedy?: string }}
|
|
1101
|
+
*/
|
|
1102
|
+
export function runMergeDriver({ cwd, fsImpl = fs, runner = spawn } = {}) {
|
|
1103
|
+
const projectRoot = (cwd ?? (() => process.cwd()))();
|
|
1104
|
+
const attributesPath = path.join(projectRoot, '.gitattributes');
|
|
1105
|
+
|
|
1106
|
+
let attributes = '';
|
|
1107
|
+
try {
|
|
1108
|
+
attributes = fsImpl.readFileSync(attributesPath, 'utf8');
|
|
1109
|
+
} catch {
|
|
1110
|
+
attributes = '';
|
|
1111
|
+
}
|
|
1112
|
+
if (!declaresBaselineMergeDriver(attributes)) {
|
|
1113
|
+
return {
|
|
1114
|
+
ok: true,
|
|
1115
|
+
detail:
|
|
1116
|
+
'skipped — .gitattributes does not route baselines/*.json through the mandrel merge driver',
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const configured = runner('git', [
|
|
1121
|
+
'config',
|
|
1122
|
+
'--get',
|
|
1123
|
+
BASELINE_MERGE_DRIVER_CONFIG_KEY,
|
|
1124
|
+
]);
|
|
1125
|
+
if (configured.status === 0 && configured.stdout.trim() !== '') {
|
|
1126
|
+
return { ok: true, detail: configured.stdout.trim() };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return {
|
|
1130
|
+
ok: false,
|
|
1131
|
+
detail: `${BASELINE_MERGE_DRIVER_CONFIG_KEY} is unset — baselines/*.json will fall back to git's text merge, which conflicts on the generatedAt stamp and can splice rows neither branch scored`,
|
|
1132
|
+
remedy: BASELINE_MERGE_DRIVER_REMEDY,
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1077
1136
|
/**
|
|
1078
1137
|
* Ordered array of doctor checks. Each entry follows the
|
|
1079
1138
|
* `{ name: string, run(opts?): { ok: boolean, detail: string, remedy?: string } }` contract.
|
|
@@ -1123,6 +1182,10 @@ export const registry = [
|
|
|
1123
1182
|
name: 'agents-drift',
|
|
1124
1183
|
run: (opts) => runAgentsDrift(opts),
|
|
1125
1184
|
},
|
|
1185
|
+
{
|
|
1186
|
+
name: 'merge-driver',
|
|
1187
|
+
run: (opts) => runMergeDriver(opts),
|
|
1188
|
+
},
|
|
1126
1189
|
{
|
|
1127
1190
|
name: 'pin-current',
|
|
1128
1191
|
// Fatal, unlike version-current below (Story #4525/#4530): a pin/install
|
package/package.json
CHANGED