mandrel 2.16.0 → 2.18.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.
Files changed (69) hide show
  1. package/.agents/docs/agentrc-reference.json +10 -0
  2. package/.agents/docs/configuration.md +9 -0
  3. package/.agents/docs/quality-gates.md +137 -0
  4. package/.agents/schemas/agentrc.schema.json +48 -0
  5. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  6. package/.agents/schemas/baselines/crap.schema.json +4 -0
  7. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  8. package/.agents/scripts/acceptance-eval.js +52 -12
  9. package/.agents/scripts/audit-to-stories.js +92 -25
  10. package/.agents/scripts/boot-sweep.js +67 -8
  11. package/.agents/scripts/check-baseline-drift.js +138 -0
  12. package/.agents/scripts/coverage-capture.js +74 -25
  13. package/.agents/scripts/deliver-recover.js +45 -18
  14. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  15. package/.agents/scripts/generate-lens-checklists.js +81 -30
  16. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  17. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  18. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  19. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  20. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  21. package/.agents/scripts/lib/baselines/reader.js +12 -1
  22. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  23. package/.agents/scripts/lib/baselines/writer.js +10 -0
  24. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  25. package/.agents/scripts/lib/cli-utils.js +48 -13
  26. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  27. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  28. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  29. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  30. package/.agents/scripts/lib/config/quality.js +40 -0
  31. package/.agents/scripts/lib/config/temp-paths.js +27 -0
  32. package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
  33. package/.agents/scripts/lib/coverage-utils.js +92 -9
  34. package/.agents/scripts/lib/crap-engine.js +113 -23
  35. package/.agents/scripts/lib/crap-utils.js +159 -93
  36. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  37. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  38. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  39. package/.agents/scripts/lib/observability/terse-result.js +7 -3
  40. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  41. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  42. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  43. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
  48. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  50. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  51. package/.agents/scripts/lib/single-story-sweep.js +11 -0
  52. package/.agents/scripts/lib/stdio-flush.js +71 -0
  53. package/.agents/scripts/lib/temp-retention.js +559 -0
  54. package/.agents/scripts/lib/transpile.js +133 -6
  55. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  56. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  57. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  58. package/.agents/scripts/nav-registry-diff.js +30 -8
  59. package/.agents/scripts/plan-run-epilogue.js +27 -11
  60. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  61. package/.agents/scripts/single-story-close.js +9 -92
  62. package/.agents/scripts/single-story-init.js +1 -1
  63. package/.agents/scripts/sync-branch-from-base.js +6 -1
  64. package/.agents/scripts/update-crap-baseline.js +13 -0
  65. package/README.md +14 -6
  66. package/docs/CHANGELOG.md +36 -0
  67. package/lib/cli/version-helpers.js +7 -0
  68. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  69. package/package.json +5 -1
@@ -0,0 +1,166 @@
1
+ /**
2
+ * story-deliver-terminal-schema.js — load `story-deliver-terminal.schema.json`
3
+ * and validate envelopes against it.
4
+ *
5
+ * Split out of `story-deliver-terminal.js` so the envelope WRITER holds only
6
+ * the contract's shape and vocabulary, and this module holds the one thing
7
+ * the writer must never depend on at call time: the filesystem.
8
+ *
9
+ * That separation is the fix, not just tidiness. `single-story-close.js`
10
+ * invoked by a *worktree-relative* path runs the Story worktree's own copy of
11
+ * the script and then **reaps that worktree** as one of its phases. The schema
12
+ * used to be read lazily, on the first envelope build — which happens after
13
+ * the reap — so the read hit a path that no longer existed. The throw landed
14
+ * inside the close CLI's error path, so a Story whose PR had merged, whose
15
+ * label was `agent::done`, and whose post-land tail was green exited non-zero
16
+ * emitting NO envelope at all: the delivery engine's documented return
17
+ * contract lost to a success, recoverable only by a second close run from the
18
+ * main checkout.
19
+ *
20
+ * Two guarantees close that, and both live here:
21
+ *
22
+ * 1. The schema is read and parsed ONCE, at module load. The parsed schema
23
+ * outlives the file, so what happens to the directory afterwards is
24
+ * irrelevant. Compilation stays lazy — it needs no filesystem — so an
25
+ * import costs one small read and nothing else.
26
+ * 2. An unreadable schema DEGRADES validation rather than throwing. A
27
+ * schema violation still fails loudly; see {@link validateTerminalEnvelope}.
28
+ */
29
+
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ import Ajv2020 from 'ajv/dist/2020.js';
35
+ import addFormats from 'ajv-formats';
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+
39
+ /**
40
+ * Absolute path to the shipped schema — the SSOT this module reads.
41
+ *
42
+ * Module-private, like every other `SCHEMA_PATH` in the tree
43
+ * (`validation-evidence.js`, `signal-validator.js`): the path is an
44
+ * implementation detail of loading, and callers want the verdict, not the
45
+ * location.
46
+ */
47
+ const SCHEMA_PATH = path.resolve(
48
+ __dirname,
49
+ '..',
50
+ '..',
51
+ '..',
52
+ 'schemas',
53
+ 'story-deliver-terminal.schema.json',
54
+ );
55
+
56
+ /**
57
+ * Read and parse the shipped schema. Never throws: a read failure is recorded
58
+ * on the returned source and degrades validation downstream, because importing
59
+ * this module must never be what breaks a delivery.
60
+ *
61
+ * @returns {{ schema: object|null, error: string|null }}
62
+ */
63
+ function loadSchemaSource() {
64
+ try {
65
+ return {
66
+ schema: JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8')),
67
+ error: null,
68
+ };
69
+ } catch (err) {
70
+ return { schema: null, error: err?.message ?? String(err) };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The schema, read and parsed at module load. Deliberately eager — see the
76
+ * module header for the failure that made it so.
77
+ *
78
+ * @type {{ schema: object|null, error: string|null }}
79
+ */
80
+ const SCHEMA_SOURCE = loadSchemaSource();
81
+
82
+ /**
83
+ * Compiled validators keyed by the source object they came from.
84
+ *
85
+ * A `WeakMap` rather than one module-level slot so an injected `schemaSource`
86
+ * (the test seam) can never poison the validator the production path memoizes.
87
+ *
88
+ * @type {WeakMap<object, Function>}
89
+ */
90
+ const VALIDATORS = new WeakMap();
91
+
92
+ /**
93
+ * Compile (once per source) and return the terminal-envelope validator, or
94
+ * `null` when the source carries no usable schema.
95
+ *
96
+ * @param {{ schema: object|null }} source
97
+ * @returns {Function|null}
98
+ */
99
+ function getValidator(source) {
100
+ if (!source?.schema) return null;
101
+ const cached = VALIDATORS.get(source);
102
+ if (cached) return cached;
103
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
104
+ addFormats(ajv);
105
+ const validate = ajv.compile(source.schema);
106
+ VALIDATORS.set(source, validate);
107
+ return validate;
108
+ }
109
+
110
+ let _unvalidatedWarned = false;
111
+
112
+ /**
113
+ * Announce — once per process — that an envelope is going out unvalidated.
114
+ *
115
+ * Written straight to stderr rather than through `Logger.warn` for the same
116
+ * reason `emitTerminalEnvelope` bypasses `Logger.info`: the envelope itself is
117
+ * unsuppressible, so the notice that one was not checked has to be too. Under
118
+ * `AGENT_LOG_LEVEL=silent` a level-gated warning would vanish and the degrade
119
+ * would be invisible.
120
+ *
121
+ * @param {string|null|undefined} error
122
+ * @returns {void}
123
+ */
124
+ function warnUnvalidated(error) {
125
+ if (_unvalidatedWarned) return;
126
+ _unvalidatedWarned = true;
127
+ process.stderr.write(
128
+ `[story-deliver-terminal] ⚠️ terminal-envelope schema unavailable (${error ?? 'unknown'}) — ` +
129
+ `emitting the envelope UNVALIDATED. The return contract is preserved; its shape is not checked. ` +
130
+ `Expected at: ${SCHEMA_PATH}\n`,
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Validate a candidate envelope against the shipped schema.
136
+ *
137
+ * When the schema is unavailable the result reports `validated: false` and
138
+ * `valid: true` — a **deliberate degrade**, not an oversight. Losing the shape
139
+ * check costs a guard against a malformed envelope; throwing here costs the
140
+ * envelope entirely, and the envelope is the documented return contract of the
141
+ * delivery engine. An unvalidated terminal a caller can act on beats no
142
+ * terminal at all, so the unreadable-schema case degrades and says so on
143
+ * stderr while a schema *violation* still fails loudly at the writer.
144
+ *
145
+ * @param {object} envelope
146
+ * @param {{ schemaSource?: { schema: object|null, error: string|null } }} [opts]
147
+ * `schemaSource` is a test seam — production always uses the eagerly loaded
148
+ * module-level source.
149
+ * @returns {{ valid: boolean, errors: string[], validated: boolean }}
150
+ */
151
+ export function validateTerminalEnvelope(
152
+ envelope,
153
+ { schemaSource = SCHEMA_SOURCE } = {},
154
+ ) {
155
+ const validate = getValidator(schemaSource);
156
+ if (!validate) {
157
+ warnUnvalidated(schemaSource?.error);
158
+ return { valid: true, errors: [], validated: false };
159
+ }
160
+ const valid = validate(envelope);
161
+ if (valid) return { valid: true, errors: [], validated: true };
162
+ const errors = (validate.errors ?? []).map(
163
+ (e) => `${e.instancePath || '/'} ${e.message}`,
164
+ );
165
+ return { valid: false, errors, validated: true };
166
+ }
@@ -28,22 +28,12 @@
28
28
  * pre-#4543 pipeline collapsed by treating budget exhaustion as a block.
29
29
  */
30
30
 
31
- import { readFileSync } from 'node:fs';
32
- import path from 'node:path';
33
- import { fileURLToPath } from 'node:url';
31
+ import { validateTerminalEnvelope } from './story-deliver-terminal-schema.js';
34
32
 
35
- import Ajv2020 from 'ajv/dist/2020.js';
36
- import addFormats from 'ajv-formats';
37
-
38
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
- const SCHEMA_PATH = path.resolve(
40
- __dirname,
41
- '..',
42
- '..',
43
- '..',
44
- 'schemas',
45
- 'story-deliver-terminal.schema.json',
46
- );
33
+ // Re-exported so the schema split stays an implementation detail: every
34
+ // consumer still reaches the validator through the envelope module that owns
35
+ // the contract.
36
+ export { validateTerminalEnvelope };
47
37
 
48
38
  export const TERMINAL_ENVELOPE_KIND = 'story-deliver-terminal';
49
39
 
@@ -152,39 +142,6 @@ export const NEXT_COMMANDS = Object.freeze({
152
142
  escalateToPlan: (prompt) => `/plan "${quoteForPlan(prompt)}"`,
153
143
  });
154
144
 
155
- /** @type {Function|null} */
156
- let _validator = null;
157
-
158
- /**
159
- * Compile (once) and return the terminal-envelope validator.
160
- *
161
- * @returns {Function}
162
- */
163
- function getValidator() {
164
- if (_validator) return _validator;
165
- const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
166
- const ajv = new Ajv2020({ allErrors: true, strict: false });
167
- addFormats(ajv);
168
- _validator = ajv.compile(schema);
169
- return _validator;
170
- }
171
-
172
- /**
173
- * Validate a candidate envelope against the shipped schema.
174
- *
175
- * @param {object} envelope
176
- * @returns {{ valid: boolean, errors: string[] }}
177
- */
178
- export function validateTerminalEnvelope(envelope) {
179
- const validate = getValidator();
180
- const valid = validate(envelope);
181
- if (valid) return { valid: true, errors: [] };
182
- const errors = (validate.errors ?? []).map(
183
- (e) => `${e.instancePath || '/'} ${e.message}`,
184
- );
185
- return { valid: false, errors };
186
- }
187
-
188
145
  /**
189
146
  * Drop `undefined`-valued keys so the schema's `additionalProperties: false`
190
147
  * and its nullable unions both stay satisfiable from one optional-argument
@@ -207,9 +164,14 @@ function compact(obj) {
207
164
  * Throws a `TypeError` naming the schema violations when the assembled
208
165
  * object does not validate. That is deliberate: the whole point of the
209
166
  * envelope is that a caller can trust its status without re-probing
210
- * GitHub, so emitting an unvalidated one would reintroduce the ambiguity
167
+ * GitHub, so emitting a *malformed* one would reintroduce the ambiguity
211
168
  * this replaces.
212
169
  *
170
+ * A schema that cannot be READ is the opposite case and does not throw —
171
+ * see {@link validateTerminalEnvelope}. "This envelope is wrong" is worth
172
+ * failing on; "I could not check this envelope" is not worth destroying
173
+ * the return contract over.
174
+ *
213
175
  * @param {object} args
214
176
  * @param {number|null} args.storyId `null` only for an `escalated` terminal,
215
177
  * which by construction never authored a Story.
@@ -227,6 +189,9 @@ function compact(obj) {
227
189
  * @param {number} args.elapsedSeconds
228
190
  * @param {object|null} [args.waitBudget]
229
191
  * @param {string} [args.timestamp]
192
+ * @param {{ schema: object|null, error: string|null }} [args.schemaSource]
193
+ * Test seam; never passed in production. Not part of the envelope — the
194
+ * envelope is assembled from named fields only.
230
195
  * @returns {object} The validated envelope.
231
196
  */
232
197
  export function buildTerminalEnvelope({
@@ -245,6 +210,7 @@ export function buildTerminalEnvelope({
245
210
  elapsedSeconds = 0,
246
211
  waitBudget,
247
212
  timestamp = new Date().toISOString(),
213
+ schemaSource,
248
214
  }) {
249
215
  const envelope = compact({
250
216
  kind: TERMINAL_ENVELOPE_KIND,
@@ -268,7 +234,12 @@ export function buildTerminalEnvelope({
268
234
  timestamp,
269
235
  });
270
236
 
271
- const { valid, errors } = validateTerminalEnvelope(envelope);
237
+ // Passing `{ schemaSource }` unconditionally is safe: an `undefined`
238
+ // property value is exactly what triggers the destructuring default on the
239
+ // other side, so production still gets the eagerly-loaded module source.
240
+ const { valid, errors } = validateTerminalEnvelope(envelope, {
241
+ schemaSource,
242
+ });
272
243
  if (!valid) {
273
244
  throw new TypeError(
274
245
  `buildTerminalEnvelope: assembled envelope violates story-deliver-terminal.schema.json:\n` +
@@ -449,15 +449,29 @@ function computeMissingBddScaffoldFindings(stories, reach, severity) {
449
449
  * Tasks satisfy (a) or (b). When ≥2 such Stories sit in the same wave (no
450
450
  * transitive `depends_on` between them), emit a single finding keyed by the
451
451
  * registry path.
452
+ *
453
+ * Reached from the module's `_internal` named export. The pass is pure — it
454
+ * performs no filesystem I/O and spawns no process — so its optional final
455
+ * `deps` parameter seams the three collaborating predicates rather than a
456
+ * built-in; each entry defaults to the real implementation
457
+ * (`.agents/rules/test-seams.md` rules 1-3: the defaults live on the function,
458
+ * never on a module-level mutable variable).
459
+ *
460
+ * @param {object} input
461
+ * @param {{
462
+ * isRegistryPathImpl?: typeof isRegistryPath,
463
+ * inSameWaveImpl?: typeof inSameWave,
464
+ * registryRegistryImpl?: typeof registryRegistry,
465
+ * }} [deps]
452
466
  */
453
- function computeRegistryFindings({
454
- stories,
455
- reach,
456
- patterns,
457
- producers,
458
- assumptionEntries,
459
- severity,
460
- }) {
467
+ function computeRegistryFindings(
468
+ { stories, reach, patterns, producers, assumptionEntries, severity },
469
+ {
470
+ isRegistryPathImpl = isRegistryPath,
471
+ inSameWaveImpl = inSameWave,
472
+ registryRegistryImpl = registryRegistry,
473
+ } = {},
474
+ ) {
461
475
  const findings = [];
462
476
  // Build the matching registry path set from producer & creator paths.
463
477
  const registryHits = new Map(); // registryPath -> Map<storySlug, producers[]>
@@ -474,7 +488,7 @@ function computeRegistryFindings({
474
488
  // (a) direct registry edits — object-form `{ path, assumption }` entries
475
489
  // from `indexAssumptionEntries` (and the producer index built from them).
476
490
  for (const [path, entries] of producers.entries()) {
477
- if (!isRegistryPath(path, patterns)) continue;
491
+ if (!isRegistryPathImpl(path, patterns)) continue;
478
492
  for (const e of entries) {
479
493
  bump(path, {
480
494
  storySlug: e.storySlug,
@@ -485,7 +499,7 @@ function computeRegistryFindings({
485
499
  }
486
500
  }
487
501
  for (const e of assumptionEntries) {
488
- if (!isRegistryPath(e.path, patterns)) continue;
502
+ if (!isRegistryPathImpl(e.path, patterns)) continue;
489
503
  bump(e.path, {
490
504
  storySlug: e.storySlug,
491
505
  taskSlug: e.taskSlug,
@@ -510,7 +524,7 @@ function computeRegistryFindings({
510
524
  continue;
511
525
  const childParent = parentDirOf(change.path);
512
526
  if (!childParent) continue;
513
- for (const reg of registryRegistry(
527
+ for (const reg of registryRegistryImpl(
514
528
  producers,
515
529
  assumptionEntries,
516
530
  patterns,
@@ -532,7 +546,7 @@ function computeRegistryFindings({
532
546
  const cluster = new Set();
533
547
  for (let i = 0; i < stories.length; i += 1) {
534
548
  for (let j = i + 1; j < stories.length; j += 1) {
535
- if (inSameWave(reach, stories[i], stories[j])) {
549
+ if (inSameWaveImpl(reach, stories[i], stories[j])) {
536
550
  cluster.add(stories[i]);
537
551
  cluster.add(stories[j]);
538
552
  }
@@ -90,6 +90,7 @@ const STORY_BRANCH_INCLUDE = 'story-*';
90
90
  * candidates: number,
91
91
  * localDeleted: number,
92
92
  * remoteDeleted: number,
93
+ * reaped: string[],
93
94
  * protected: Array<{ branch: string, reason: string, worktreePath?: string|null }>,
94
95
  * contentMerged: Array<{ branch: string, worktreePath: string|null }>,
95
96
  * failures: Array<{ branch: string|null, scope: string, stderr?: string }>,
@@ -147,6 +148,7 @@ export async function sweepMergedBranches({
147
148
  candidates: 0,
148
149
  localDeleted: 0,
149
150
  remoteDeleted: 0,
151
+ reaped: [],
150
152
  protected: [],
151
153
  contentMerged: [],
152
154
  failures: [],
@@ -296,6 +298,7 @@ async function runSweepUnderLock({
296
298
  candidates: 0,
297
299
  localDeleted: 0,
298
300
  remoteDeleted: 0,
301
+ reaped: [],
299
302
  protected: [],
300
303
  contentMerged,
301
304
  failures: [],
@@ -320,6 +323,7 @@ async function runSweepUnderLock({
320
323
  candidates: reapCandidates.length,
321
324
  localDeleted: 0,
322
325
  remoteDeleted: 0,
326
+ reaped: [],
323
327
  protected: protectedList,
324
328
  contentMerged,
325
329
  failures: [],
@@ -365,6 +369,7 @@ function executeReap({
365
369
  candidates: candidateCount,
366
370
  localDeleted: 0,
367
371
  remoteDeleted: 0,
372
+ reaped: [],
368
373
  protected: protectedList,
369
374
  contentMerged,
370
375
  failures: [{ branch: null, scope: 'execute', stderr: msg }],
@@ -398,6 +403,11 @@ function executeReap({
398
403
  candidates: candidateCount,
399
404
  localDeleted,
400
405
  remoteDeleted,
406
+ // Story #4794 — the branch names, not just the count. Each one is a merge
407
+ // this sweep CONFIRMED (merged PR + matching headRefOid), which is exactly
408
+ // the evidence the temp-retention catch-up needs to purge that Story's
409
+ // spent artifacts. Previously these existed only inside a log string.
410
+ reaped: reapable.map((c) => c.branch),
401
411
  protected: protectedList,
402
412
  contentMerged,
403
413
  failures: result.failures,
@@ -503,6 +513,7 @@ function zeroResult({ error }) {
503
513
  candidates: 0,
504
514
  localDeleted: 0,
505
515
  remoteDeleted: 0,
516
+ reaped: [],
506
517
  protected: [],
507
518
  contentMerged: [],
508
519
  failures: [],
@@ -0,0 +1,71 @@
1
+ // .agents/scripts/lib/stdio-flush.js
2
+ /**
3
+ * Stdio drain helper (Story #4783).
4
+ *
5
+ * `process.stdout` / `process.stderr` are only synchronous when they point at
6
+ * a TTY or a regular file. On a **pipe** — every `cmd | consumer`, every
7
+ * `child_process` capture, every `$(...)` substitution — Node writes
8
+ * asynchronously once the 64 KiB kernel pipe buffer fills: `write()` returns
9
+ * `false` and the remaining bytes sit in the stream's internal queue until the
10
+ * reader drains it.
11
+ *
12
+ * `process.exit()` does not wait for that queue. Anything still buffered when
13
+ * the process terminates is discarded, so a CLI that emits more than a pipe
14
+ * buffer's worth of output and then exits eagerly silently truncates — the
15
+ * exit code still reads green, and the consumer parses a half-written
16
+ * envelope. `runAsCli` is the shared boundary where that used to happen for
17
+ * every `.agents/scripts` entry point.
18
+ *
19
+ * The fix is to stop exiting eagerly (set `process.exitCode` and let the loop
20
+ * drain naturally). This helper is the belt-and-braces half: an explicit await
21
+ * on the queued bytes, so the flush is complete before the CLI's last frame
22
+ * unwinds even when something further out terminates the process.
23
+ *
24
+ * @module stdio-flush
25
+ */
26
+
27
+ /**
28
+ * Await the point at which one writable stream's queued bytes have been handed
29
+ * to the OS. Resolves immediately when the stream has nothing queued, is not
30
+ * writable, or is not a stream at all — a flush must never be the reason a
31
+ * process hangs.
32
+ *
33
+ * Both settle paths are covered: the zero-length `write()` callback (which
34
+ * fires after every previously queued chunk, since writes are ordered) and the
35
+ * `'drain'` event (which fires when a backed-up stream empties). Whichever
36
+ * lands first resolves; the other is detached.
37
+ *
38
+ * @param {NodeJS.WritableStream & { writableLength?: number, writableEnded?: boolean, destroyed?: boolean }} [stream]
39
+ * @returns {Promise<void>}
40
+ */
41
+ function drainStream(stream) {
42
+ if (!stream || typeof stream.write !== 'function') return Promise.resolve();
43
+ if (stream.destroyed || stream.writableEnded) return Promise.resolve();
44
+ if ((stream.writableLength ?? 0) === 0) return Promise.resolve();
45
+
46
+ return new Promise((resolve) => {
47
+ let settled = false;
48
+ const done = () => {
49
+ if (settled) return;
50
+ settled = true;
51
+ stream.removeListener?.('drain', done);
52
+ resolve();
53
+ };
54
+ stream.once?.('drain', done);
55
+ // The write callback fires once this (empty) chunk — and therefore every
56
+ // chunk queued ahead of it — has been flushed to the underlying handle.
57
+ stream.write('', done);
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Await the drain of the process's stdio streams. Never rejects: a stream that
63
+ * errored, closed, or was never writable resolves as already-flushed.
64
+ *
65
+ * @param {Array<NodeJS.WritableStream|undefined>} [streams] Defaults to
66
+ * `[process.stdout, process.stderr]`.
67
+ * @returns {Promise<void>}
68
+ */
69
+ export async function flushStdio(streams = [process.stdout, process.stderr]) {
70
+ await Promise.all(streams.map((stream) => drainStream(stream)));
71
+ }