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.
@@ -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 (24)
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] [--route lite|full] [--reason <text>]
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
- while (true) {
21
- const nl = buf.indexOf('\n');
22
- if (nl === -1) break;
23
- emit(prefix + buf.slice(0, nl));
24
- buf = buf.slice(nl + 1);
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) emit(prefix + buf);
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 when the
89
- * child exits.
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
- // Capture the combined output only when we may need to inspect it for the
123
- // biome "No files were processed" marker — otherwise the stream is purely
124
- // piped through to the operator (no retained buffer).
125
- let captured = '';
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
- captured += `${line}\n`;
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
- child.on('exit', (code, sig) => {
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(captured)
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`,