mandrel 2.15.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/docs/workflows.md +2 -1
- package/.agents/scripts/deliver-light.js +31 -3
- package/.agents/scripts/lib/close-validation/process.js +61 -15
- package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
- package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
- package/.agents/scripts/lib/orchestration/plan-context.js +190 -10
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +24 -15
- package/.agents/scripts/plan-context.js +4 -1
- package/.agents/workflows/helpers/deliver-light.md +34 -8
- package/.agents/workflows/helpers/plan-reference.md +27 -6
- package/.agents/workflows/plan.md +4 -2
- package/.agents/workflows/prototype.md +104 -0
- package/docs/CHANGELOG.md +17 -0
- package/package.json +1 -1
|
@@ -32,7 +32,7 @@ by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
|
|
|
32
32
|
fails when it drifts from the on-disk workflow set. To change a command’s
|
|
33
33
|
description, edit the workflow file’s front-matter and regenerate.
|
|
34
34
|
|
|
35
|
-
## Commands (
|
|
35
|
+
## Commands (25)
|
|
36
36
|
|
|
37
37
|
| Command | Description |
|
|
38
38
|
| --- | --- |
|
|
@@ -57,6 +57,7 @@ description, edit the workflow file’s front-matter and regenerate.
|
|
|
57
57
|
| `/git-deliver` | Single ad-hoc delivery command for working-tree changes. Detects the git setup and escalates to the right terminal step — commit only, commit + push, or commit + push + open a PR with native auto-merge — picking the default from observable state and letting flags pin any level explicitly. Replaces the retired git-commit-all, git-push, and git-pr-all trio. |
|
|
58
58
|
| `/mandrel-update` | npm-era upgrade wraparound for a Mandrel consumer. Runs `npx mandrel update` (resolve newest published version → install → re-materialize `.agents/` → migrate → doctor → surface changelog) as the single mechanical step, then walks the operator through the judgment wraparound the CLI deliberately leaves unowned: reconcile `.agentrc.json`, install the stabilized quality-gate surface, refresh the harness permission allowlist, reconcile the consumer's `AGENTS.md` / runbooks against the surfaced changelog, and stage + commit the staged lockfile bump. |
|
|
59
59
|
| `/plan` | Unified planning entry point. Interrogate → author → persist. Emits one Story by default; splits into N>1 only under the default-single split policy. |
|
|
60
|
+
| `/prototype` | Operator-invoked UI prototype pass. Discovers the consumer's design-system SSOT first, then — only after the operator confirms — writes exactly one self-contained HTML file under the gitignored workspace-root temp tree, so a layout can be reviewed before its UI acceptance criteria are authored. |
|
|
60
61
|
| `/qa-assist` | Human-led QA assist loop — set up, then ride a rolling multi-observation intake session. The operator reports observations in any order; the agent enriches each (repro + root-cause file:line + coverage verdict for bugs; analysis + options + recommendation for enhancements), asks clarifying questions only when ambiguous, and appends a redacted ledger item — recording, never planning — to a persistent, resumable session under temp/qa/. Only when the operator says they are done does it review the full ledger and hand off to /plan. |
|
|
61
62
|
| `/qa-explore` | Agent-led exploratory-QA loop — the agent Plans a surface with an explicit static-vs-drive method choice, drives it (browser MCP or static), and captures ledger items read-only, then Triages — a bounded per-surface session, HITL-gated at every phase transition, routed through the shared dedup/coverage/classification/missing-test/redaction/session core under temp/qa/ |
|
|
62
63
|
| `/qa-run` | Drive Gherkin scenarios through a real browser as an agent-driven QA sweep |
|
|
@@ -79,18 +79,29 @@ import { createProvider } from './lib/provider-factory.js';
|
|
|
79
79
|
const HELP = `\
|
|
80
80
|
Usage:
|
|
81
81
|
deliver-light.js --prompt <text> [--creates csv] [--refactors csv]
|
|
82
|
-
[--acceptance n] [--
|
|
82
|
+
[--acceptance n] [--kinds csv] [--magnitude m]
|
|
83
|
+
[--uncertainty u] [--route lite|full] [--reason <text>]
|
|
83
84
|
[--amends '#id'] [--yes]
|
|
84
85
|
deliver-light.js --backstop --story <id>
|
|
85
86
|
|
|
86
87
|
The thin /deliver-light entry point: suitability gate → inline receipt Story →
|
|
87
88
|
the same single-story-init.js / single-story-close.js engine /deliver uses.
|
|
88
89
|
|
|
90
|
+
The gate judges EFFORT and RISK, not artifact counts: N instances of one
|
|
91
|
+
mechanical edit is one kind at N sites. It rejects only clearly-epic work; the
|
|
92
|
+
--backstop pass enforces size against the actual diff.
|
|
93
|
+
|
|
89
94
|
Gate options:
|
|
90
95
|
--prompt <text> Operator prompt describing the change. Required for the gate.
|
|
91
96
|
--creates <csv> Predicted NEW file paths (comma-separated).
|
|
92
97
|
--refactors <csv> Predicted edited/existing file paths (comma-separated).
|
|
93
|
-
--acceptance <n> Predicted acceptance-criteria count (default 1).
|
|
98
|
+
--acceptance <n> Predicted acceptance-criteria count (default 1). Not capped.
|
|
99
|
+
--kinds <csv> Distinct KINDS of change (default: one per assumption, so
|
|
100
|
+
N same-shaped edits count once).
|
|
101
|
+
--magnitude <m> Coarse effort bucket: trivial | moderate | substantial
|
|
102
|
+
(default moderate; substantial routes to /plan).
|
|
103
|
+
--uncertainty <u> determined (the request fixes the shape) | needs-design
|
|
104
|
+
(default determined; needs-design routes to /plan).
|
|
94
105
|
--route <r> Ledgered model verdict route: lite | full.
|
|
95
106
|
--reason <text> Recorded reason for a lite verdict (required for lite).
|
|
96
107
|
--amends <#id> Mark this as an amendment of an existing issue.
|
|
@@ -166,17 +177,25 @@ export function synthesizeAcceptance(count) {
|
|
|
166
177
|
* creates?: string[],
|
|
167
178
|
* refactors?: string[],
|
|
168
179
|
* acceptance?: number,
|
|
180
|
+
* kinds?: string[],
|
|
181
|
+
* magnitude?: string,
|
|
182
|
+
* uncertainty?: string,
|
|
169
183
|
* route?: string,
|
|
170
184
|
* reason?: string,
|
|
171
185
|
* yes?: boolean,
|
|
172
186
|
* injectedRules?: object,
|
|
173
|
-
* }} args
|
|
187
|
+
* }} args `kinds` / `magnitude` / `uncertainty` are the declared effort-and-risk
|
|
188
|
+
* axes the gate judges (Story #4764); omitting them declares no signal, not a
|
|
189
|
+
* small one — an unrecognized bucket fails closed.
|
|
174
190
|
* @returns {{ action: string, suitability: object, outcome: object }}
|
|
175
191
|
*/
|
|
176
192
|
export function runLightGate({
|
|
177
193
|
creates = [],
|
|
178
194
|
refactors = [],
|
|
179
195
|
acceptance,
|
|
196
|
+
kinds,
|
|
197
|
+
magnitude,
|
|
198
|
+
uncertainty,
|
|
180
199
|
route,
|
|
181
200
|
reason,
|
|
182
201
|
yes = false,
|
|
@@ -186,6 +205,9 @@ export function runLightGate({
|
|
|
186
205
|
const suitability = deriveLightSuitability({
|
|
187
206
|
predictedChanges,
|
|
188
207
|
predictedAcceptance: synthesizeAcceptance(acceptance),
|
|
208
|
+
predictedKinds: kinds,
|
|
209
|
+
predictedMagnitude: magnitude,
|
|
210
|
+
predictedUncertainty: uncertainty,
|
|
189
211
|
verdict: { route, reason },
|
|
190
212
|
injectedRules,
|
|
191
213
|
});
|
|
@@ -355,6 +377,9 @@ export async function runGateMode(values, deps = {}) {
|
|
|
355
377
|
acceptance: values.acceptance
|
|
356
378
|
? Number.parseInt(String(values.acceptance), 10)
|
|
357
379
|
: 1,
|
|
380
|
+
kinds: parseCsvPaths(values.kinds),
|
|
381
|
+
magnitude: values.magnitude,
|
|
382
|
+
uncertainty: values.uncertainty,
|
|
358
383
|
route: values.route,
|
|
359
384
|
reason: values.reason,
|
|
360
385
|
yes: values.yes === true,
|
|
@@ -417,6 +442,9 @@ async function main() {
|
|
|
417
442
|
creates: { type: 'string' },
|
|
418
443
|
refactors: { type: 'string' },
|
|
419
444
|
acceptance: { type: 'string' },
|
|
445
|
+
kinds: { type: 'string' },
|
|
446
|
+
magnitude: { type: 'string' },
|
|
447
|
+
uncertainty: { type: 'string' },
|
|
420
448
|
route: { type: 'string' },
|
|
421
449
|
reason: { type: 'string' },
|
|
422
450
|
amends: { type: 'string' },
|
|
@@ -11,22 +11,48 @@ import { spawn } from 'node:child_process';
|
|
|
11
11
|
* Pipe a child stream's output line-by-line through `emit`, prepending
|
|
12
12
|
* `prefix` to each line. Tail bytes without a trailing newline flush on
|
|
13
13
|
* `end` so the operator never loses the last line of a gate's output.
|
|
14
|
+
*
|
|
15
|
+
* ## The drain must stay cheap (Story #4766)
|
|
16
|
+
*
|
|
17
|
+
* This handler runs on the reader side of the child's stdout/stderr pipe.
|
|
18
|
+
* Every microsecond spent here is a microsecond the pipe is not being read,
|
|
19
|
+
* and once the OS pipe buffer fills, the child's own write blocks — or, on a
|
|
20
|
+
* non-blocking pipe, fails outright with `EAGAIN`. A gate child is not
|
|
21
|
+
* obliged to survive that: Biome's `biome_console` `.unwrap()`s the error and
|
|
22
|
+
* aborts the process with exit 101, so a green lint verdict presents as a
|
|
23
|
+
* failed close. Two consequences bind everything on this path:
|
|
24
|
+
*
|
|
25
|
+
* 1. Splitting is O(chunk), not O(chunk × lines) — the scan advances a
|
|
26
|
+
* `start` index instead of re-slicing the buffer once per line, so a
|
|
27
|
+
* 64KB chunk carrying 500 lines does not copy 16MB.
|
|
28
|
+
* 2. **`emit` MUST NOT block.** A synchronous per-line file write is
|
|
29
|
+
* exactly the stall this path cannot afford; the close path's capture
|
|
30
|
+
* sink (`single-story-close/gate-log.js`) buffers to an async stream for
|
|
31
|
+
* that reason.
|
|
14
32
|
*/
|
|
15
33
|
function pipePrefixed(stream, prefix, emit) {
|
|
16
34
|
let buf = '';
|
|
17
35
|
stream.setEncoding('utf8');
|
|
18
36
|
stream.on('data', (chunk) => {
|
|
19
37
|
buf += chunk;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
emit(prefix + buf.slice(
|
|
24
|
-
|
|
38
|
+
let start = 0;
|
|
39
|
+
let nl = buf.indexOf('\n', start);
|
|
40
|
+
while (nl !== -1) {
|
|
41
|
+
emit(prefix + buf.slice(start, nl));
|
|
42
|
+
start = nl + 1;
|
|
43
|
+
nl = buf.indexOf('\n', start);
|
|
25
44
|
}
|
|
45
|
+
if (start > 0) buf = buf.slice(start);
|
|
26
46
|
});
|
|
27
47
|
stream.on('end', () => {
|
|
28
|
-
if (buf.length > 0)
|
|
48
|
+
if (buf.length > 0) {
|
|
49
|
+
emit(prefix + buf);
|
|
50
|
+
buf = '';
|
|
51
|
+
}
|
|
29
52
|
});
|
|
53
|
+
// A pipe-level error (EIO on a vanished child) must not become an
|
|
54
|
+
// unhandled 'error' event that takes the whole close down.
|
|
55
|
+
stream.on('error', () => {});
|
|
30
56
|
}
|
|
31
57
|
|
|
32
58
|
/** Wire the AbortSignal so an abort kills the child. Returns the cleanup fn. */
|
|
@@ -68,6 +94,18 @@ export function gateExitCode(code, sig) {
|
|
|
68
94
|
const BIOME_NO_FILES_PROCESSED =
|
|
69
95
|
'No files were processed in the specified paths';
|
|
70
96
|
|
|
97
|
+
/**
|
|
98
|
+
* How many trailing gate lines the "No files were processed" probe retains.
|
|
99
|
+
*
|
|
100
|
+
* The marker only ever appears when biome processed nothing, and in that case
|
|
101
|
+
* its whole output is a handful of lines — so a bounded tail always contains
|
|
102
|
+
* it when it is there at all. Retaining a tail rather than the full transcript
|
|
103
|
+
* keeps the drain path's per-line work O(1) in the volume of gate output
|
|
104
|
+
* (Story #4766): the previous `captured += line` grew a string without limit,
|
|
105
|
+
* on the one gate (biome/format) whose output is the loudest.
|
|
106
|
+
*/
|
|
107
|
+
const MARKER_PROBE_TAIL_LINES = 32;
|
|
108
|
+
|
|
71
109
|
/**
|
|
72
110
|
* Whether biome's combined gate output carries the "No files were processed"
|
|
73
111
|
* marker. Pure function — no I/O. Exported for unit coverage (Story #4292).
|
|
@@ -85,8 +123,11 @@ export function isBiomeNoFilesProcessed(output) {
|
|
|
85
123
|
* Default async gate runner — used by `runCloseValidation` when no `runner`
|
|
86
124
|
* is injected. Spawns the gate via `child_process.spawn`, prefixes every
|
|
87
125
|
* stdout/stderr line with `[gate-name] ` (so concurrent gates don't bleed
|
|
88
|
-
* into each other in the operator's terminal), and resolves only
|
|
89
|
-
* child
|
|
126
|
+
* into each other in the operator's terminal), and resolves only once the
|
|
127
|
+
* child has exited and both stdio pipes are drained.
|
|
128
|
+
*
|
|
129
|
+
* `opts.log` is the drain sink and **must not block** — see `pipePrefixed`
|
|
130
|
+
* above for what a synchronous per-line write costs the child (Story #4766).
|
|
90
131
|
*
|
|
91
132
|
* Honours `opts.signal`: a TERM is delivered to the child the moment the
|
|
92
133
|
* signal fires, so a sibling gate's failure aborts the rest of the wave
|
|
@@ -119,13 +160,14 @@ export function defaultGateRunner(cmd, args, opts = {}) {
|
|
|
119
160
|
const prefix = gateName ? `[${gateName}] ` : '';
|
|
120
161
|
const emit =
|
|
121
162
|
typeof log === 'function' ? log : (m) => process.stdout.write(`${m}\n`);
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
163
|
+
// Retain a bounded tail only when we may need to inspect it for the biome
|
|
164
|
+
// "No files were processed" marker — otherwise the stream is purely piped
|
|
165
|
+
// through to the operator (no retained buffer).
|
|
166
|
+
const recent = [];
|
|
126
167
|
const tap = tolerateNoFilesProcessed
|
|
127
168
|
? (line) => {
|
|
128
|
-
|
|
169
|
+
recent.push(line);
|
|
170
|
+
if (recent.length > MARKER_PROBE_TAIL_LINES) recent.shift();
|
|
129
171
|
emit(line);
|
|
130
172
|
}
|
|
131
173
|
: emit;
|
|
@@ -133,13 +175,17 @@ export function defaultGateRunner(cmd, args, opts = {}) {
|
|
|
133
175
|
pipePrefixed(child.stderr, prefix, tap);
|
|
134
176
|
const detach = attachGateAbortHandler(child, signal);
|
|
135
177
|
return new Promise((resolve) => {
|
|
136
|
-
|
|
178
|
+
// 'close', not 'exit' (Story #4766): 'close' fires only once the child has
|
|
179
|
+
// exited AND both stdio pipes have been fully drained and closed, so no
|
|
180
|
+
// gate ever reports its status while lines are still in flight. Resolving
|
|
181
|
+
// on 'exit' raced the tail of a high-volume gate's output.
|
|
182
|
+
child.on('close', (code, sig) => {
|
|
137
183
|
detach();
|
|
138
184
|
const status = gateExitCode(code, sig);
|
|
139
185
|
if (
|
|
140
186
|
status !== 0 &&
|
|
141
187
|
tolerateNoFilesProcessed &&
|
|
142
|
-
isBiomeNoFilesProcessed(
|
|
188
|
+
isBiomeNoFilesProcessed(recent.join('\n'))
|
|
143
189
|
) {
|
|
144
190
|
emit(
|
|
145
191
|
`${prefix}↳ biome processed zero files (all changed paths are config-ignored); treating as a clean skip`,
|