mandrel 2.14.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.
Files changed (75) hide show
  1. package/.agents/audit-checklists/navigability.md +1 -1
  2. package/.agents/docs/workflows.md +4 -4
  3. package/.agents/scripts/acceptance-eval.js +18 -1
  4. package/.agents/scripts/agents-bootstrap-github.js +22 -1
  5. package/.agents/scripts/apply-quality-bootstrap.js +6 -0
  6. package/.agents/scripts/audit-labels-bootstrap.js +15 -1
  7. package/.agents/scripts/audit-to-stories.js +26 -1
  8. package/.agents/scripts/boot-sweep.js +4 -1
  9. package/.agents/scripts/bootstrap.js +1 -0
  10. package/.agents/scripts/check-arch-cycles.js +20 -0
  11. package/.agents/scripts/check-baselines.js +8 -2
  12. package/.agents/scripts/check-context-budget.js +40 -5
  13. package/.agents/scripts/check-dead-exports.js +21 -0
  14. package/.agents/scripts/check-doc-links.js +12 -1
  15. package/.agents/scripts/check-lifecycle-doc-drift.js +9 -0
  16. package/.agents/scripts/check-workflow-citations.js +332 -0
  17. package/.agents/scripts/deliver-light.js +32 -3
  18. package/.agents/scripts/deliver-recover.js +4 -1
  19. package/.agents/scripts/diagnose-friction.js +17 -1
  20. package/.agents/scripts/diagnose.js +20 -14
  21. package/.agents/scripts/drain-pending-cleanup.js +20 -1
  22. package/.agents/scripts/evidence-gate.js +20 -1
  23. package/.agents/scripts/generate-config-docs.js +14 -1
  24. package/.agents/scripts/generate-lifecycle-docs.js +14 -1
  25. package/.agents/scripts/generate-workflows-doc.js +14 -1
  26. package/.agents/scripts/git-cleanup.js +32 -1
  27. package/.agents/scripts/lib/cli-usage.js +174 -0
  28. package/.agents/scripts/lib/cli-utils.js +12 -0
  29. package/.agents/scripts/lib/close-validation/process.js +61 -15
  30. package/.agents/scripts/lib/doc-tiers.js +53 -10
  31. package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
  32. package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
  33. package/.agents/scripts/lib/orchestration/plan-context.js +205 -20
  34. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
  35. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +24 -15
  36. package/.agents/scripts/lib/workflow-closure.js +431 -0
  37. package/.agents/scripts/mandrel-update-preflight.js +9 -0
  38. package/.agents/scripts/nav-registry-diff.js +13 -0
  39. package/.agents/scripts/plan-context.js +21 -2
  40. package/.agents/scripts/plan-critics.js +10 -0
  41. package/.agents/scripts/plan-persist.js +33 -1
  42. package/.agents/scripts/plan-run-epilogue.js +12 -1
  43. package/.agents/scripts/quality-preview.js +17 -1
  44. package/.agents/scripts/resolve-doc-tiers.js +13 -0
  45. package/.agents/scripts/resolve-stories.js +1 -0
  46. package/.agents/scripts/resync-status-column.js +4 -1
  47. package/.agents/scripts/signals-view.js +11 -0
  48. package/.agents/scripts/single-story-close.js +24 -0
  49. package/.agents/scripts/single-story-confirm-merge.js +16 -0
  50. package/.agents/scripts/single-story-init.js +21 -1
  51. package/.agents/scripts/stories-wave-tick.js +1 -0
  52. package/.agents/scripts/sync-agentrc.js +16 -4
  53. package/.agents/scripts/update-ticket-state.js +23 -2
  54. package/.agents/workflows/audit-navigability.md +2 -2
  55. package/.agents/workflows/audit-to-stories.md +1 -1
  56. package/.agents/workflows/deliver.md +80 -81
  57. package/.agents/workflows/git-cleanup.md +9 -14
  58. package/.agents/workflows/helpers/acceptance-self-eval.md +14 -13
  59. package/.agents/workflows/helpers/audit-lens-core.md +2 -2
  60. package/.agents/workflows/helpers/code-review.md +11 -11
  61. package/.agents/workflows/helpers/deliver-digest.md +7 -8
  62. package/.agents/workflows/helpers/deliver-light.md +222 -0
  63. package/.agents/workflows/helpers/deliver-reference.md +46 -14
  64. package/.agents/workflows/helpers/deliver-story-reference.md +55 -63
  65. package/.agents/workflows/helpers/deliver-story.md +22 -22
  66. package/.agents/workflows/helpers/mandrel-sync-config.md +2 -2
  67. package/.agents/workflows/helpers/parallel-tooling.md +1 -2
  68. package/.agents/workflows/helpers/plan-reference.md +96 -17
  69. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -4
  70. package/.agents/workflows/mandrel-update.md +6 -6
  71. package/.agents/workflows/plan.md +90 -85
  72. package/.agents/workflows/prototype.md +104 -0
  73. package/docs/CHANGELOG.md +32 -0
  74. package/package.json +2 -1
  75. package/.agents/workflows/deliver-light.md +0 -148
@@ -18,6 +18,11 @@
18
18
  */
19
19
 
20
20
  import { readFile } from 'node:fs/promises';
21
+ import { readAuditRulesSync } from '../audit-suite/audit-rules-reader.js';
22
+ import {
23
+ hasWebSurface,
24
+ matchesAnyFilePattern,
25
+ } from '../audit-suite/selector.js';
21
26
  import { getLimits } from '../config-resolver.js';
22
27
  import { findSimilarOpenStories } from '../duplicate-search.js';
23
28
  import { Logger } from '../Logger.js';
@@ -356,7 +361,7 @@ function resolveRiskHeuristics(config = {}) {
356
361
 
357
362
  /**
358
363
  * Ceilings a seed's advisory complexity signals must fit for the plan
359
- * workflow to **suggest** `/deliver-light` at Gate #1 (Story #4741 R3 plan-side
364
+ * workflow to **suggest** the light path at Gate #1 (Story #4741 R3 plan-side
360
365
  * handshake). Framework constants, not operator knobs — mirroring the
361
366
  * conservative intent of `complexity-gate.js`'s `STORY_SHAPE_CEILINGS`
362
367
  * (small, mostly-additive, non-sensitive) but read against the *seed-time*
@@ -364,9 +369,14 @@ function resolveRiskHeuristics(config = {}) {
364
369
  *
365
370
  * The suggestion is **advisory only and never an automatic reroute**: it
366
371
  * surfaces at Gate #1 for the operator to decide, and under `--yes` it is
367
- * recorded on the envelope while planning proceeds unchanged. `/deliver-light`
368
- * is a sibling Story; these ceilings define the plan side of the routing
369
- * handshake independently of it.
372
+ * recorded on the envelope while planning proceeds unchanged.
373
+ *
374
+ * These ceilings are deliberately NOT the ones the light path itself applies
375
+ * (Story #4760). A confirmed suggestion routes into
376
+ * `workflows/helpers/deliver-light.md`, whose gate re-judges the *predicted
377
+ * shape* against `STORY_SHAPE_CEILINGS`. Two checks at two different stages:
378
+ * this one screens a seed, that one decides. Collapsing them would make a
379
+ * confirm a bypass.
370
380
  *
371
381
  * - `maxArtifacts` — enumerated seed items (one artifact each).
372
382
  * - `maxRiskHeuristicHits` — any risk-heuristic hit disqualifies: risk
@@ -381,9 +391,9 @@ const DELIVER_LIGHT_SUGGESTION_CEILINGS = Object.freeze({
381
391
  });
382
392
 
383
393
  /**
384
- * Derive the advisory `/deliver-light` suggestion from a seed's complexity
385
- * signals (Story #4741 AC-6). Pure and total: a malformed / missing signal
386
- * bag fails conservative (not suggested), never throws.
394
+ * Derive the advisory light-path suggestion from a seed's complexity signals
395
+ * (Story #4741 AC-6). Pure and total: a malformed / missing signal bag fails
396
+ * conservative (not suggested), never throws.
387
397
  *
388
398
  * `automatic: false` is part of the contract — the suggestion is surfaced for
389
399
  * the operator, never a silent reroute of a non-interactive run.
@@ -435,27 +445,198 @@ export function buildDeliverLightSuggestion(complexitySignals) {
435
445
  ceilings,
436
446
  reasons: suggested
437
447
  ? [
438
- `seed fits the /deliver-light ceilings (≤${ceilings.maxArtifacts} artifacts, ` +
448
+ `seed fits the light-path ceilings (≤${ceilings.maxArtifacts} artifacts, ` +
439
449
  'no risk-heuristic hits, no sensitive-path classes) — the operator ' +
440
- 'may prefer /deliver-light for this scope',
450
+ 'may prefer /deliver for this scope',
441
451
  ]
442
452
  : reasons,
443
453
  };
444
454
  }
445
455
 
446
456
  /**
447
- * Attach the advisory `/deliver-light` suggestion to a complexity-signals bag
448
- * as a **nested** field (Story #4741). Nesting — rather than a new top-level
449
- * envelope key — keeps every existing per-mode envelope key set byte-stable
450
- * (AC-5): the suggestion is derived from the signals it rides on.
457
+ * The `audit-rules.json` lens `target` value marking a lens applicable only to
458
+ * a project with a rendered frontend.
459
+ */
460
+ const WEB_LENS_TARGET = 'web';
461
+
462
+ /**
463
+ * How many matched UI paths the `uiSurface` signal carries. The signal rides the
464
+ * `--out` stdout digest, which has a ~2KB contract, and a seed can predict up to
465
+ * `MAX_PREDICTED_PATHS` paths — enumerating all of them would let one UI-heavy
466
+ * seed blow that budget. The full count travels beside the sample as
467
+ * `matchedPathCount`, so nothing is silently lost.
468
+ */
469
+ const UI_MATCHED_PATH_SAMPLE = 5;
470
+
471
+ /**
472
+ * Union of the `triggers.filePatterns` globs every `target: "web"` lens
473
+ * registers in `audit-rules.json` — the framework's shipped declaration of
474
+ * "this path is part of a rendered UI surface". Read from the manifest rather
475
+ * than re-listed here: a second copy of the glob set would be a second thing to
476
+ * keep in sync, and the manifest is already the place an operator extends it.
477
+ *
478
+ * @param {{ audits?: Record<string, object> }} rules
479
+ * @returns {string[]} Deduplicated globs, in manifest order.
480
+ */
481
+ function resolveWebFilePatterns(rules) {
482
+ const patterns = new Set();
483
+ for (const entry of Object.values(rules?.audits ?? {})) {
484
+ if (entry?.target !== WEB_LENS_TARGET) continue;
485
+ for (const glob of entry?.triggers?.filePatterns ?? []) {
486
+ if (typeof glob === 'string' && glob !== '') patterns.add(glob);
487
+ }
488
+ }
489
+ return [...patterns];
490
+ }
491
+
492
+ /**
493
+ * Which predicted paths sit on a UI surface, per the web lens globs.
494
+ *
495
+ * An unreadable manifest is **indeterminate**, not "no match": the signal fails
496
+ * OPEN in the same direction {@link hasWebSurface} does, because a spurious
497
+ * mention of an operator-invoked command costs nothing while a missed one costs
498
+ * the whole point of the offer.
499
+ *
500
+ * @param {string[]} predictedPaths
501
+ * @returns {{ matchedPaths: string[], indeterminate: boolean }}
502
+ */
503
+ function resolveWebFootprintMatch(predictedPaths) {
504
+ let patterns;
505
+ try {
506
+ patterns = resolveWebFilePatterns(readAuditRulesSync());
507
+ } catch {
508
+ return { matchedPaths: [], indeterminate: true };
509
+ }
510
+ return {
511
+ matchedPaths: predictedPaths.filter((p) =>
512
+ matchesAnyFilePattern(patterns, [p]),
513
+ ),
514
+ indeterminate: false,
515
+ };
516
+ }
517
+
518
+ /**
519
+ * The one sentence a `uiSurface` signal carries — why the offer fires, or why it
520
+ * does not. Kept in one place so the fired and unfired shapes stay one object.
521
+ *
522
+ * @param {{
523
+ * detected: boolean,
524
+ * webSurface: boolean,
525
+ * indeterminate: boolean,
526
+ * sample: string[],
527
+ * count: number,
528
+ * }} facts
529
+ * @returns {string}
530
+ */
531
+ function uiSurfaceReason({
532
+ detected,
533
+ webSurface,
534
+ indeterminate,
535
+ sample,
536
+ count,
537
+ }) {
538
+ if (!detected) {
539
+ return webSurface
540
+ ? 'no predicted path matches a web lens filePattern — nothing to prototype'
541
+ : 'project has no rendered web surface — nothing to prototype';
542
+ }
543
+ if (indeterminate) {
544
+ return 'web-capable project and the UI-path manifest could not be read — offering /prototype rather than dropping the option';
545
+ }
546
+ const elided = count - sample.length;
547
+ const shown =
548
+ elided > 0 ? `${sample.join(', ')}, +${elided} more` : sample.join(', ');
549
+ return `web-capable project and the predicted footprint touches ${count} UI path(s) (${shown}) — the operator may want /prototype before UI acceptance criteria are authored`;
550
+ }
551
+
552
+ /**
553
+ * Derive the advisory **UI-surface** signal from a seed's predicted footprint.
554
+ *
555
+ * Two observables, both already shipped, ANDed together:
556
+ *
557
+ * 1. the project is web-capable at all (`hasWebSurface` — the same
558
+ * applicability predicate the `target: "web"` audit lenses gate on), and
559
+ * 2. at least one predicted path matches a web lens `filePattern`.
560
+ *
561
+ * No new detection surface and no new `.agentrc.json` key: both halves are
562
+ * derived from the consumer's own checkout, so a frontend-less project — this
563
+ * repository included — resolves falsey and the offer never fires.
564
+ *
565
+ * The signal carries **no routing authority** (`automatic: false`): `/plan`
566
+ * may say that a plan touches UI and that `/prototype` exists, and must never
567
+ * invoke it. Pure over its inputs and total — a malformed signal bag or an
568
+ * unreadable manifest degrades, never throws.
569
+ *
570
+ * @param {{
571
+ * complexitySignals?: object|null,
572
+ * config?: object,
573
+ * cwd?: string,
574
+ * }} [args]
575
+ * @returns {{
576
+ * detected: boolean,
577
+ * automatic: false,
578
+ * advisory: true,
579
+ * webSurface: boolean,
580
+ * matchedPaths: string[],
581
+ * matchedPathCount: number,
582
+ * reasons: string[],
583
+ * }} `matchedPaths` is a bounded sample
584
+ * ({@link UI_MATCHED_PATH_SAMPLE}); `matchedPathCount` is the full total.
585
+ */
586
+ function buildUiSurfaceSignal({ complexitySignals, config, cwd } = {}) {
587
+ const predictedPaths = Array.isArray(complexitySignals?.predictedPaths)
588
+ ? complexitySignals.predictedPaths.filter((p) => typeof p === 'string')
589
+ : [];
590
+ const projectRoot =
591
+ typeof cwd === 'string' && cwd !== '' ? cwd : process.cwd();
592
+
593
+ let webSurface;
594
+ try {
595
+ webSurface = hasWebSurface({ config, projectRoot });
596
+ } catch {
597
+ webSurface = true; // indeterminate ⇒ fail open
598
+ }
599
+
600
+ const { matchedPaths, indeterminate } =
601
+ resolveWebFootprintMatch(predictedPaths);
602
+ const detected = webSurface && (indeterminate || matchedPaths.length > 0);
603
+ const sample = matchedPaths.slice(0, UI_MATCHED_PATH_SAMPLE);
604
+
605
+ return {
606
+ detected,
607
+ automatic: /** @type {const} */ (false),
608
+ advisory: /** @type {const} */ (true),
609
+ webSurface,
610
+ matchedPaths: sample,
611
+ matchedPathCount: matchedPaths.length,
612
+ reasons: [
613
+ uiSurfaceReason({
614
+ detected,
615
+ webSurface,
616
+ indeterminate,
617
+ sample,
618
+ count: matchedPaths.length,
619
+ }),
620
+ ],
621
+ };
622
+ }
623
+
624
+ /**
625
+ * Attach the advisory routing/offer signals to a complexity-signals bag as
626
+ * **nested** fields (Story #4741). Nesting — rather than new top-level envelope
627
+ * keys — keeps every existing per-mode envelope key set byte-stable: both are
628
+ * derived from the signals they ride on.
451
629
  *
452
630
  * @param {object} complexitySignals
453
- * @returns {object} the same signals plus `deliverLightSuggestion`.
631
+ * @param {{ config?: object, cwd?: string }} [context]
632
+ * @returns {object} the same signals plus `deliverLightSuggestion` and
633
+ * `uiSurface`.
454
634
  */
455
- function withDeliverLightSuggestion(complexitySignals) {
635
+ function withAdvisorySignals(complexitySignals, { config, cwd } = {}) {
456
636
  return {
457
637
  ...complexitySignals,
458
638
  deliverLightSuggestion: buildDeliverLightSuggestion(complexitySignals),
639
+ uiSurface: buildUiSurfaceSignal({ complexitySignals, config, cwd }),
459
640
  };
460
641
  }
461
642
 
@@ -696,14 +877,16 @@ async function buildSeedFileModeEnvelope({
696
877
  // authority. The planner authors the trivial-vs-standard verdict; persist
697
878
  // validates a lite claim against the authored Story's shape. The nested
698
879
  // `deliverLightSuggestion` is the advisory plan-side routing handshake
699
- // (Story #4741 AC-6) never an automatic reroute.
700
- complexitySignals: withDeliverLightSuggestion(
880
+ // (Story #4741 AC-6) and `uiSurface` the advisory /prototype offer —
881
+ // neither is ever an automatic reroute.
882
+ complexitySignals: withAdvisorySignals(
701
883
  buildComplexitySignals({
702
884
  seedText: content,
703
885
  config,
704
886
  riskHeuristics: heuristics,
705
887
  cwd,
706
888
  }),
889
+ { config, cwd },
707
890
  ),
708
891
  duplicates,
709
892
  docsContext,
@@ -858,13 +1041,14 @@ async function buildTicketsModeEnvelope({
858
1041
  mode: 'tickets',
859
1042
  sourceTickets,
860
1043
  seed: { text: seed, path: null },
861
- complexitySignals: withDeliverLightSuggestion(
1044
+ complexitySignals: withAdvisorySignals(
862
1045
  buildComplexitySignals({
863
1046
  seedText: seed,
864
1047
  config,
865
1048
  riskHeuristics: heuristics,
866
1049
  cwd,
867
1050
  }),
1051
+ { config, cwd },
868
1052
  ),
869
1053
  duplicates,
870
1054
  docsContext,
@@ -920,7 +1104,7 @@ function extractPriorArtifacts(priorBody) {
920
1104
  /**
921
1105
  * Build the amendment (delta) envelope — `plan-context --amends #<id>`
922
1106
  * (Story #4741 AC-4, R3-A). The heavy-amendment counterpart to routing a
923
- * light amendment through `/deliver-light`: instead of re-interrogating the
1107
+ * light amendment through the light path: instead of re-interrogating the
924
1108
  * repo from scratch (`buildAuthoringContext`'s codebase snapshot and the BDD /
925
1109
  * memory / feedback probes), the envelope composes a DELTA from what already
926
1110
  * exists — the prior Story's body, its acceptance criteria (the real
@@ -983,13 +1167,14 @@ async function buildAmendmentModeEnvelope({
983
1167
  },
984
1168
  // The prior body is the seed the delta is authored against.
985
1169
  seed: { text: priorBody, path: null },
986
- complexitySignals: withDeliverLightSuggestion(
1170
+ complexitySignals: withAdvisorySignals(
987
1171
  buildComplexitySignals({
988
1172
  seedText: priorBody,
989
1173
  config,
990
1174
  riskHeuristics: heuristics,
991
1175
  cwd,
992
1176
  }),
1177
+ { config, cwd },
993
1178
  ),
994
1179
  duplicates,
995
1180
  // No plan temp dir and no from-scratch repo interrogation — the prior
@@ -36,6 +36,24 @@
36
36
  * The sink never throws. A log directory that cannot be written degrades to
37
37
  * inline streaming — losing the size bound is strictly better than losing the
38
38
  * gate output that says why a close failed.
39
+ *
40
+ * ## Why the artifact is written asynchronously (Story #4766)
41
+ *
42
+ * This sink is the `log` callable that `close-validation/process.js` invokes
43
+ * from inside the gate child's stdout/stderr `'data'` handler — once per line.
44
+ * The first cut wrote each line with `fs.writeSync`, which blocks the event
45
+ * loop while the child keeps writing: the OS pipe buffer fills, the child's
46
+ * write fails with `EAGAIN`, and a child that does not tolerate that dies. On
47
+ * a clean `main` `biome ci .` already emits ~625 lines, and it aborts with
48
+ * exit 101 (a `biome_console` panic, not a lint violation) when it happens —
49
+ * so the first gate of any close could die on plumbing while its verdict was
50
+ * green.
51
+ *
52
+ * So the write path buffers into an async stream instead: per-line work is
53
+ * O(1) and never touches a syscall on the drain path. The cost is that the
54
+ * artifact is not on disk the instant a line is logged, which is why the sink
55
+ * exposes {@link GateLogSink#flush} — callers await it before reading,
56
+ * replaying, or naming the artifact as final.
39
57
  */
40
58
 
41
59
  import nodeFs from 'node:fs';
@@ -65,9 +83,9 @@ function logNameFor(storyId) {
65
83
  */
66
84
  class GateLogSink {
67
85
  /**
68
- * @param {{ logPath: string|null, streamInline: boolean, write: (line: string) => void, emit: (line: string) => void }} args
86
+ * @param {{ logPath: string|null, streamInline: boolean, write: (line: string) => void, flush?: () => Promise<void>, emit: (line: string) => void }} args
69
87
  */
70
- constructor({ logPath, streamInline, write, emit }) {
88
+ constructor({ logPath, streamInline, write, flush, emit }) {
71
89
  /** Absolute path of the artifact, or `null` when capture is unavailable. */
72
90
  this.logPath = logPath;
73
91
  /** Whether lines are ALSO echoed inline as they arrive. */
@@ -75,6 +93,7 @@ class GateLogSink {
75
93
  /** Number of lines captured so far. */
76
94
  this.lineCount = 0;
77
95
  this._write = write;
96
+ this._flush = flush ?? (() => Promise.resolve());
78
97
  this._emit = emit;
79
98
  this._tail = [];
80
99
  }
@@ -96,6 +115,19 @@ class GateLogSink {
96
115
  };
97
116
  }
98
117
 
118
+ /**
119
+ * Settle the artifact: wait for every buffered line to reach disk and close
120
+ * the file. Idempotent, never throws, and a no-op on the degraded (no
121
+ * artifact) path. Await it before reading {@link GateLogSink#logPath} or
122
+ * handing the path to anyone — the write path is async precisely so it never
123
+ * stalls a gate child's pipe.
124
+ *
125
+ * @returns {Promise<void>}
126
+ */
127
+ flush() {
128
+ return this._flush();
129
+ }
130
+
99
131
  /**
100
132
  * The success-path digest: one line, no gate output. Names the artifact so
101
133
  * the caller can open it on demand rather than carrying it all session.
@@ -129,6 +161,50 @@ class GateLogSink {
129
161
  }
130
162
  }
131
163
 
164
+ /**
165
+ * Wrap an already-open artifact fd in a non-blocking line writer.
166
+ *
167
+ * `write` hands the line to a `fs.WriteStream` — O(1), no syscall on the
168
+ * caller's stack — and `flush` ends the stream, resolving once every buffered
169
+ * line has reached disk (or the stream has errored; a half-written artifact is
170
+ * still better than a dead close). Both are best-effort by construction: the
171
+ * stream's `'error'` is absorbed, so nothing here can abort a close.
172
+ *
173
+ * @param {typeof nodeFs} fs
174
+ * @param {string} logPath
175
+ * @param {number} handle
176
+ * @returns {{ write: (line: string) => void, flush: () => Promise<void> }}
177
+ */
178
+ function createArtifactWriter(fs, logPath, handle) {
179
+ const stream = fs.createWriteStream(logPath, { fd: handle, autoClose: true });
180
+ stream.on('error', () => {
181
+ /* best-effort: a mid-run write failure must not abort the close */
182
+ });
183
+ let ending = null;
184
+ return {
185
+ write: (line) => {
186
+ if (ending) return;
187
+ try {
188
+ stream.write(`${line}\n`);
189
+ } catch {
190
+ /* best-effort: see above */
191
+ }
192
+ },
193
+ flush: () => {
194
+ ending ??= new Promise((resolve) => {
195
+ const settle = () => resolve();
196
+ stream.once('error', settle);
197
+ try {
198
+ stream.end(settle);
199
+ } catch {
200
+ settle();
201
+ }
202
+ });
203
+ return ending;
204
+ },
205
+ };
206
+ }
207
+
132
208
  /**
133
209
  * Build the gate-output sink for one close run.
134
210
  *
@@ -155,14 +231,14 @@ export function createGateLogSink({
155
231
  const verbose = (level ?? resolveLevel()) === 'verbose';
156
232
  const dir = logDir ?? path.join(cwd, 'temp', 'orchestration');
157
233
 
158
- let handle = null;
234
+ let writer = null;
159
235
  let logPath = null;
160
236
  try {
161
237
  fs.mkdirSync(dir, { recursive: true });
162
238
  logPath = path.join(dir, logNameFor(storyId));
163
239
  // Truncate: each close run owns its artifact outright, so a re-run never
164
240
  // hands the reader a file interleaving two runs' gates.
165
- handle = fs.openSync(logPath, 'w');
241
+ writer = createArtifactWriter(fs, logPath, fs.openSync(logPath, 'w'));
166
242
  } catch {
167
243
  // No artifact — fall back to inline streaming rather than dropping the
168
244
  // gate output on the floor.
@@ -174,13 +250,11 @@ export function createGateLogSink({
174
250
  });
175
251
  }
176
252
 
177
- const write = (line) => {
178
- try {
179
- fs.writeSync(handle, `${line}\n`);
180
- } catch {
181
- /* best-effort: a mid-run write failure must not abort the close */
182
- }
183
- };
184
-
185
- return new GateLogSink({ logPath, streamInline: verbose, write, emit });
253
+ return new GateLogSink({
254
+ logPath,
255
+ streamInline: verbose,
256
+ write: writer.write,
257
+ flush: writer.flush,
258
+ emit,
259
+ });
186
260
  }
@@ -137,22 +137,31 @@ export async function runCloseValidationPhase({
137
137
  // Story #4736 — one sink for both `log` seams (gate construction and gate
138
138
  // execution), so nothing in the chain can route around the artifact.
139
139
  const gateLog = createGateLogSink({ storyId, cwd });
140
- const validation = await runCloseValidation({
141
- cwd,
142
- worktreePath,
143
- gates: buildDefaultGates({
144
- config,
145
- baseBranch,
146
- cwd: worktreePath || cwd,
140
+ let validation;
141
+ try {
142
+ validation = await runCloseValidation({
143
+ cwd,
144
+ worktreePath,
145
+ gates: buildDefaultGates({
146
+ config,
147
+ baseBranch,
148
+ cwd: worktreePath || cwd,
149
+ log: gateLog.log,
150
+ }),
147
151
  log: gateLog.log,
148
- }),
149
- log: gateLog.log,
150
- storyId,
151
- // Story #4250 — standalone storyId-anchored evidence keyspace. No
152
- // epicId; the standalone flag routes the cache to
153
- // temp/standalone/stories/story-<id>/validation-evidence.json.
154
- standalone: true,
155
- });
152
+ storyId,
153
+ // Story #4250 — standalone storyId-anchored evidence keyspace. No
154
+ // epicId; the standalone flag routes the cache to
155
+ // temp/standalone/stories/story-<id>/validation-evidence.json.
156
+ standalone: true,
157
+ });
158
+ } finally {
159
+ // Story #4766 — gate lines are buffered to an async stream so the drain
160
+ // never blocks a gate child's pipe. Settle the artifact before anything
161
+ // reads it, replays from it, or reports its path — including on the throw
162
+ // path, where the artifact is the only surviving record.
163
+ await gateLog.flush();
164
+ }
156
165
  if (!validation.ok) {
157
166
  const [first] = validation.failed;
158
167
  const { gate, status, cwd: gateCwd } = first;