mandrel 2.11.0 → 2.13.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.
@@ -21,6 +21,7 @@ import { readFile } from 'node:fs/promises';
21
21
  import { getLimits } from '../config-resolver.js';
22
22
  import { findSimilarOpenStories } from '../duplicate-search.js';
23
23
  import { Logger } from '../Logger.js';
24
+ import { parse as parseStoryBody } from '../story-body/story-body.js';
24
25
  import {
25
26
  renderAcceptanceSpecSystemPrompt,
26
27
  renderTechSpecSystemPrompt,
@@ -353,6 +354,111 @@ function resolveRiskHeuristics(config = {}) {
353
354
  return [];
354
355
  }
355
356
 
357
+ /**
358
+ * 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
360
+ * handshake). Framework constants, not operator knobs — mirroring the
361
+ * conservative intent of `complexity-gate.js`'s `STORY_SHAPE_CEILINGS`
362
+ * (small, mostly-additive, non-sensitive) but read against the *seed-time*
363
+ * signals rather than an authored Story shape.
364
+ *
365
+ * The suggestion is **advisory only and never an automatic reroute**: it
366
+ * 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.
370
+ *
371
+ * - `maxArtifacts` — enumerated seed items (one artifact each).
372
+ * - `maxRiskHeuristicHits` — any risk-heuristic hit disqualifies: risk
373
+ * is exactly what a light path should not carry.
374
+ * - `maxSensitivePathClasses`— any sensitive-path class disqualifies, the
375
+ * same taxonomy close applies to a landed diff.
376
+ */
377
+ const DELIVER_LIGHT_SUGGESTION_CEILINGS = Object.freeze({
378
+ maxArtifacts: 2,
379
+ maxRiskHeuristicHits: 0,
380
+ maxSensitivePathClasses: 0,
381
+ });
382
+
383
+ /**
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.
387
+ *
388
+ * `automatic: false` is part of the contract — the suggestion is surfaced for
389
+ * the operator, never a silent reroute of a non-interactive run.
390
+ *
391
+ * @param {object|null|undefined} complexitySignals
392
+ * @returns {{
393
+ * suggested: boolean,
394
+ * automatic: false,
395
+ * advisory: true,
396
+ * ceilings: typeof DELIVER_LIGHT_SUGGESTION_CEILINGS,
397
+ * reasons: string[],
398
+ * }}
399
+ */
400
+ export function buildDeliverLightSuggestion(complexitySignals) {
401
+ const ceilings = DELIVER_LIGHT_SUGGESTION_CEILINGS;
402
+ const advisory = /** @type {const} */ (true);
403
+ const automatic = /** @type {const} */ (false);
404
+ const s = complexitySignals ?? {};
405
+ const artifactCount = Number.isInteger(s.artifactCount)
406
+ ? s.artifactCount
407
+ : Number.POSITIVE_INFINITY;
408
+ const riskHits = Array.isArray(s.riskHeuristicHits)
409
+ ? s.riskHeuristicHits.length
410
+ : Number.POSITIVE_INFINITY;
411
+ const sensitive = Array.isArray(s.sensitivePathClasses)
412
+ ? s.sensitivePathClasses.length
413
+ : Number.POSITIVE_INFINITY;
414
+
415
+ const reasons = [];
416
+ if (artifactCount > ceilings.maxArtifacts) {
417
+ reasons.push(
418
+ `seed enumerates ${artifactCount} artifacts (> ${ceilings.maxArtifacts})`,
419
+ );
420
+ }
421
+ if (riskHits > ceilings.maxRiskHeuristicHits) {
422
+ reasons.push(`seed hits ${riskHits} risk-heuristic phrase(s)`);
423
+ }
424
+ if (sensitive > ceilings.maxSensitivePathClasses) {
425
+ reasons.push(
426
+ `predicted footprint touches ${sensitive} sensitive-path class(es)`,
427
+ );
428
+ }
429
+
430
+ const suggested = reasons.length === 0;
431
+ return {
432
+ suggested,
433
+ automatic,
434
+ advisory,
435
+ ceilings,
436
+ reasons: suggested
437
+ ? [
438
+ `seed fits the /deliver-light ceilings (≤${ceilings.maxArtifacts} artifacts, ` +
439
+ 'no risk-heuristic hits, no sensitive-path classes) — the operator ' +
440
+ 'may prefer /deliver-light for this scope',
441
+ ]
442
+ : reasons,
443
+ };
444
+ }
445
+
446
+ /**
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.
451
+ *
452
+ * @param {object} complexitySignals
453
+ * @returns {object} the same signals plus `deliverLightSuggestion`.
454
+ */
455
+ function withDeliverLightSuggestion(complexitySignals) {
456
+ return {
457
+ ...complexitySignals,
458
+ deliverLightSuggestion: buildDeliverLightSuggestion(complexitySignals),
459
+ };
460
+ }
461
+
356
462
  /**
357
463
  * Count top-level enumerated items (`- `, `* `, `1. `) under the first
358
464
  * scope-shaped `## ` heading (Scope / MVP Scope / Proposed Scope / Work
@@ -588,13 +694,17 @@ async function buildSeedFileModeEnvelope({
588
694
  seed: { path: seedFilePath ?? null, content },
589
695
  // Advisory complexity signals only (Story #4722): no route, no routing
590
696
  // authority. The planner authors the trivial-vs-standard verdict; persist
591
- // validates a lite claim against the authored Story's shape.
592
- complexitySignals: buildComplexitySignals({
593
- seedText: content,
594
- config,
595
- riskHeuristics: heuristics,
596
- cwd,
597
- }),
697
+ // validates a lite claim against the authored Story's shape. The nested
698
+ // `deliverLightSuggestion` is the advisory plan-side routing handshake
699
+ // (Story #4741 AC-6) — never an automatic reroute.
700
+ complexitySignals: withDeliverLightSuggestion(
701
+ buildComplexitySignals({
702
+ seedText: content,
703
+ config,
704
+ riskHeuristics: heuristics,
705
+ cwd,
706
+ }),
707
+ ),
598
708
  duplicates,
599
709
  docsContext,
600
710
  codebaseSnapshot: authoring.codebaseSnapshot,
@@ -748,12 +858,14 @@ async function buildTicketsModeEnvelope({
748
858
  mode: 'tickets',
749
859
  sourceTickets,
750
860
  seed: { text: seed, path: null },
751
- complexitySignals: buildComplexitySignals({
752
- seedText: seed,
753
- config,
754
- riskHeuristics: heuristics,
755
- cwd,
756
- }),
861
+ complexitySignals: withDeliverLightSuggestion(
862
+ buildComplexitySignals({
863
+ seedText: seed,
864
+ config,
865
+ riskHeuristics: heuristics,
866
+ cwd,
867
+ }),
868
+ ),
757
869
  duplicates,
758
870
  docsContext,
759
871
  codebaseSnapshot: authoring.codebaseSnapshot,
@@ -779,6 +891,122 @@ async function buildTicketsModeEnvelope({
779
891
  };
780
892
  }
781
893
 
894
+ /**
895
+ * Parse a prior Story body into its acceptance criteria and delivered file
896
+ * map. Total: an unparseable body degrades to empty lists (a delta envelope
897
+ * grounded on whatever survived), never a throw.
898
+ *
899
+ * @param {string} priorBody
900
+ * @returns {{ priorAcceptance: string[], deliveredFiles: string[] }}
901
+ */
902
+ function extractPriorArtifacts(priorBody) {
903
+ let parsed;
904
+ try {
905
+ parsed = parseStoryBody(priorBody).body;
906
+ } catch {
907
+ return { priorAcceptance: [], deliveredFiles: [] };
908
+ }
909
+ const priorAcceptance = Array.isArray(parsed.acceptance)
910
+ ? parsed.acceptance.filter((a) => typeof a === 'string' && a.length > 0)
911
+ : [];
912
+ const deliveredFiles = Array.isArray(parsed.changes)
913
+ ? parsed.changes
914
+ .map((c) => (c && typeof c === 'object' ? c.path : c))
915
+ .filter((p) => typeof p === 'string' && p.length > 0)
916
+ : [];
917
+ return { priorAcceptance, deliveredFiles };
918
+ }
919
+
920
+ /**
921
+ * Build the amendment (delta) envelope — `plan-context --amends #<id>`
922
+ * (Story #4741 AC-4, R3-A). The heavy-amendment counterpart to routing a
923
+ * light amendment through `/deliver-light`: instead of re-interrogating the
924
+ * repo from scratch (`buildAuthoringContext`'s codebase snapshot and the BDD /
925
+ * memory / feedback probes), the envelope composes a DELTA from what already
926
+ * exists — the prior Story's body, its acceptance criteria (the real
927
+ * contract), and its delivered file map — so a follow-up change plans from the
928
+ * shape already shipped.
929
+ *
930
+ * The semantic steps that reach the ticket are preserved: the open-Story
931
+ * duplicate search (excluding the amended Story itself), the risk heuristics,
932
+ * and the authoring system prompts all still ride the envelope. What is
933
+ * dropped is only the from-scratch repo interrogation the prior artifacts
934
+ * already stand in for — that is the round-trip diet, not an amputation.
935
+ *
936
+ * @param {{
937
+ * amendsId: number,
938
+ * provider: object,
939
+ * config: object,
940
+ * settings: object,
941
+ * cwd?: string,
942
+ * }} args
943
+ * @returns {Promise<object>}
944
+ */
945
+ async function buildAmendmentModeEnvelope({
946
+ amendsId,
947
+ provider,
948
+ config,
949
+ settings: _settings,
950
+ cwd,
951
+ }) {
952
+ if (!provider || typeof provider.getTicket !== 'function') {
953
+ throw new Error(
954
+ '[plan-context] --amends requires provider.getTicket() to load the prior Story.',
955
+ );
956
+ }
957
+ const prior = await provider.getTicket(amendsId);
958
+ if (!prior) {
959
+ throw new Error(
960
+ `[plan-context] --amends #${amendsId}: prior Story not found — nothing to amend.`,
961
+ );
962
+ }
963
+ const priorBody = typeof prior.body === 'string' ? prior.body : '';
964
+ const { priorAcceptance, deliveredFiles } = extractPriorArtifacts(priorBody);
965
+
966
+ const heuristics = resolveRiskHeuristics(config);
967
+ const limits = getLimits(config);
968
+ const duplicates = await searchStoryDuplicates({
969
+ seed: priorBody,
970
+ provider,
971
+ config,
972
+ excludeIds: [amendsId],
973
+ });
974
+
975
+ return {
976
+ mode: 'amends',
977
+ amends: {
978
+ id: Number(prior.id ?? prior.number ?? amendsId),
979
+ title: prior.title ?? '',
980
+ priorBody,
981
+ priorAcceptance,
982
+ deliveredFiles,
983
+ },
984
+ // The prior body is the seed the delta is authored against.
985
+ seed: { text: priorBody, path: null },
986
+ complexitySignals: withDeliverLightSuggestion(
987
+ buildComplexitySignals({
988
+ seedText: priorBody,
989
+ config,
990
+ riskHeuristics: heuristics,
991
+ cwd,
992
+ }),
993
+ ),
994
+ duplicates,
995
+ // No plan temp dir and no from-scratch repo interrogation — the prior
996
+ // artifacts are the grounding, so there is no docs digest to anchor.
997
+ docsContext: null,
998
+ ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
999
+ maxTickets: limits.maxTickets,
1000
+ riskHeuristics: heuristics,
1001
+ systemPrompts: buildSystemPrompts({
1002
+ heuristics,
1003
+ maxTickets: limits.maxTickets,
1004
+ }),
1005
+ planState: null,
1006
+ planProfile: 'story-amendment',
1007
+ };
1008
+ }
1009
+
782
1010
  /**
783
1011
  * Build the single planner-context envelope.
784
1012
  *
@@ -787,11 +1015,12 @@ async function buildTicketsModeEnvelope({
787
1015
  * bound it (see {@link assertPlanContextWithinCeiling}).
788
1016
  *
789
1017
  * @param {{
790
- * mode: 'seed-file'|'seed'|'tickets',
1018
+ * mode: 'seed-file'|'seed'|'tickets'|'amends',
791
1019
  * seedFilePath?: string,
792
1020
  * seedFileContent?: string,
793
1021
  * seedText?: string,
794
1022
  * ticketIds?: number[],
1023
+ * amendsId?: number,
795
1024
  * provider: object,
796
1025
  * config: object,
797
1026
  * settings: object,
@@ -805,6 +1034,7 @@ export async function buildPlanContext({
805
1034
  seedFileContent,
806
1035
  seedText,
807
1036
  ticketIds,
1037
+ amendsId,
808
1038
  provider,
809
1039
  config = {},
810
1040
  settings = {},
@@ -817,6 +1047,7 @@ export async function buildPlanContext({
817
1047
  seedFileContent,
818
1048
  seedText,
819
1049
  ticketIds,
1050
+ amendsId,
820
1051
  provider,
821
1052
  config,
822
1053
  settings,
@@ -835,11 +1066,21 @@ async function buildPlanContextEnvelope({
835
1066
  seedFileContent,
836
1067
  seedText,
837
1068
  ticketIds,
1069
+ amendsId,
838
1070
  provider,
839
1071
  config,
840
1072
  settings,
841
1073
  cwd,
842
1074
  }) {
1075
+ if (mode === 'amends') {
1076
+ return buildAmendmentModeEnvelope({
1077
+ amendsId,
1078
+ provider,
1079
+ config,
1080
+ settings,
1081
+ cwd,
1082
+ });
1083
+ }
843
1084
  if (mode === 'seed-file') {
844
1085
  if (!seedFilePath && typeof seedFileContent !== 'string') {
845
1086
  throw new Error(
@@ -875,6 +1116,6 @@ async function buildPlanContextEnvelope({
875
1116
  });
876
1117
  }
877
1118
  throw new Error(
878
- `[plan-context] unknown mode "${mode}" — expected "seed", "seed-file", or "tickets".`,
1119
+ `[plan-context] unknown mode "${mode}" — expected "seed", "seed-file", "tickets", or "amends".`,
879
1120
  );
880
1121
  }
@@ -301,6 +301,7 @@ async function renderRunScopedPlanMetricsLine({
301
301
  * stories: ReturnType<typeof assemblePlanStories>['stories'],
302
302
  * routeDowngradeReason?: string|null,
303
303
  * config?: object,
304
+ * injectedRules?: object,
304
305
  * }} args
305
306
  * @returns {{
306
307
  * route: 'lite'|'full',
@@ -313,6 +314,7 @@ function resolveEffectiveRoute({
313
314
  stories,
314
315
  routeDowngradeReason = null,
315
316
  config = {},
317
+ injectedRules,
316
318
  }) {
317
319
  const verdict = resolvePlannerRouteVerdict({ reason: routeDowngradeReason });
318
320
  if (verdict.route !== 'lite') return null;
@@ -339,6 +341,7 @@ function resolveEffectiveRoute({
339
341
  const derived = deriveStoryShape({
340
342
  changes: story.bodyObject?.changes,
341
343
  acceptance: story.acceptance,
344
+ injectedRules,
342
345
  });
343
346
  return {
344
347
  slug: story.slug,
@@ -455,6 +458,7 @@ export async function reapStalePlanDirs({
455
458
  * sourceTicketOrigin?: 'flag'|'envelope'|'none',
456
459
  * closeSuperseded?: boolean,
457
460
  * routeDowngradeReason?: string|null,
461
+ * injectedRules?: object,
458
462
  * },
459
463
  * }} input
460
464
  */
@@ -483,6 +487,7 @@ export async function runPlanPersist({
483
487
  sourceTicketOrigin = 'none',
484
488
  closeSuperseded = true,
485
489
  routeDowngradeReason = null,
490
+ injectedRules = undefined,
486
491
  } = opts;
487
492
 
488
493
  // Boundary for the plan-metrics summary below: everything this invocation
@@ -566,6 +571,7 @@ export async function runPlanPersist({
566
571
  stories,
567
572
  routeDowngradeReason,
568
573
  config,
574
+ injectedRules,
569
575
  });
570
576
  const isLiteRoute = route?.route === 'lite';
571
577
  if (isLiteRoute) {
@@ -300,6 +300,9 @@ export async function readNativeBlockedBy({
300
300
  * @param {Map<number, number[]>} nativeEdges
301
301
  * @param {number[]} foreignDone Ids outside the set already satisfied.
302
302
  * @param {(msg: string) => void} [warn]
303
+ * @param {object} [injectedRules] Test seam forwarded to the shape
304
+ * derivation — skips the `audit-rules.json` disk read. Production callers
305
+ * omit it (the real manifest, memoized per process, is the default).
303
306
  * @returns {{ kind: string, stories: object[], dag: object[], done: number[] }}
304
307
  */
305
308
  export function buildStoriesEnvelope({
@@ -308,6 +311,7 @@ export function buildStoriesEnvelope({
308
311
  foreignDone = [],
309
312
  warn,
310
313
  config,
314
+ injectedRules,
311
315
  }) {
312
316
  const sorted = [...stories].sort((a, b) => a.id - b.id);
313
317
  const inSetDone = sorted.filter(isSatisfiedBlocker).map((s) => s.id);
@@ -321,13 +325,26 @@ export function buildStoriesEnvelope({
321
325
  // `route::lite` label is a human-visible hint only, never the control
322
326
  // signal: a lost label cannot misroute delivery. Model-side fan-out
323
327
  // only; close gates are untouched.
328
+ //
329
+ // `storyCount` (Story #4736) carries the run's topology into that same
330
+ // decision: a run resolving exactly ONE Story is inline whatever its
331
+ // shape, because the isolation a sub-agent buys only matters against a
332
+ // concurrently-dispatched sibling. It is the resolved set size — not the
333
+ // undelivered remainder — so the mode a caller reads for a given `--ids`
334
+ // list never changes as siblings land mid-run.
324
335
  stories: sorted.map(({ id, title, body, url, labels, state }) => ({
325
336
  id,
326
337
  title,
327
338
  url,
328
339
  labels,
329
340
  state,
330
- dispatchMode: resolveStoryDispatchMode({ body, labels, config }).mode,
341
+ dispatchMode: resolveStoryDispatchMode({
342
+ body,
343
+ labels,
344
+ config,
345
+ storyCount: sorted.length,
346
+ injectedRules,
347
+ }).mode,
331
348
  })),
332
349
  dag: storiesToDag(sorted, nativeEdges, warn),
333
350
  done: [...new Set([...inSetDone, ...foreignDone])].sort((a, b) => a - b),
@@ -0,0 +1,186 @@
1
+ /**
2
+ * single-story-close/gate-log.js — bounded gate output for the close path
3
+ * (Story #4736).
4
+ *
5
+ * ## Why
6
+ *
7
+ * `runCloseValidation` streams every child gate's stdout/stderr line through
8
+ * an injected `log`, and the close phase used to hand it `Logger.info` — whose
9
+ * default sink is `console.log`. A single successful close therefore wrote the
10
+ * whole of `npm test`, the linter, and the baseline checks onto the invoking
11
+ * agent's stdout: ~50KB, over the host's inline tool-result ceiling. The caller
12
+ * got a truncated preview, had to open the persisted file anyway, and re-ran
13
+ * close for a clean envelope — burning the run's most expensive stretch to
14
+ * re-derive output it already had.
15
+ *
16
+ * Story #4708 set the contract this restores compliance with (see
17
+ * `rules/orchestration-error-handling.md` § Output Contract): compact digest
18
+ * plus an on-disk artifact path, ≤ ~2KB on the **default success path**.
19
+ *
20
+ * ## The shape
21
+ *
22
+ * A sink captures every gate line to a log under the gitignored temp tree and
23
+ * emits nothing inline. What happens next depends on the outcome, because the
24
+ * two outcomes want opposite things:
25
+ *
26
+ * - **success** — the caller wants the verdict, not the evidence.
27
+ * {@link GateLogSink#digest} is one line: the pass count and the log path.
28
+ * - **failure** — the evidence IS the point, and making the caller open a
29
+ * file to see why a gate went red just moves the cost.
30
+ * {@link GateLogSink#replay} puts the captured tail back inline.
31
+ *
32
+ * `AGENT_LOG_LEVEL=verbose` opts back into live inline streaming (the
33
+ * "existing log-level control"): the capture still happens, so the artifact is
34
+ * written either way.
35
+ *
36
+ * The sink never throws. A log directory that cannot be written degrades to
37
+ * inline streaming — losing the size bound is strictly better than losing the
38
+ * gate output that says why a close failed.
39
+ */
40
+
41
+ import nodeFs from 'node:fs';
42
+ import path from 'node:path';
43
+
44
+ import { Logger, resolveLevel } from '../../Logger.js';
45
+
46
+ /**
47
+ * How many trailing captured lines {@link GateLogSink#replay} puts back
48
+ * inline. A failed gate's actionable evidence — the assertion, the stack, the
49
+ * summary counts — sits at the end of its output; the head is startup noise.
50
+ * The full text is always in the artifact regardless.
51
+ */
52
+ export const REPLAY_TAIL_LINES = 200;
53
+
54
+ /** Basename of the per-Story gate log inside the temp directory. */
55
+ function logNameFor(storyId) {
56
+ return `close-gates-${storyId ?? 'unknown'}.log`;
57
+ }
58
+
59
+ /**
60
+ * A capturing sink for close-validation gate output.
61
+ *
62
+ * Not exported as a constructor — {@link createGateLogSink} owns the
63
+ * degradation decision, so every instance in the wild has already resolved
64
+ * whether it has a writable artifact.
65
+ */
66
+ class GateLogSink {
67
+ /**
68
+ * @param {{ logPath: string|null, streamInline: boolean, write: (line: string) => void, emit: (line: string) => void }} args
69
+ */
70
+ constructor({ logPath, streamInline, write, emit }) {
71
+ /** Absolute path of the artifact, or `null` when capture is unavailable. */
72
+ this.logPath = logPath;
73
+ /** Whether lines are ALSO echoed inline as they arrive. */
74
+ this.streamInline = streamInline;
75
+ /** Number of lines captured so far. */
76
+ this.lineCount = 0;
77
+ this._write = write;
78
+ this._emit = emit;
79
+ this._tail = [];
80
+ }
81
+
82
+ /**
83
+ * The `log` callable handed to `runCloseValidation` / `buildDefaultGates`.
84
+ * Bound, because it is passed by reference into the gate machinery.
85
+ *
86
+ * @type {(message: string) => void}
87
+ */
88
+ get log() {
89
+ return (message) => {
90
+ const line = String(message ?? '');
91
+ this.lineCount += 1;
92
+ this._tail.push(line);
93
+ if (this._tail.length > REPLAY_TAIL_LINES) this._tail.shift();
94
+ this._write(line);
95
+ if (this.streamInline) this._emit(line);
96
+ };
97
+ }
98
+
99
+ /**
100
+ * The success-path digest: one line, no gate output. Names the artifact so
101
+ * the caller can open it on demand rather than carrying it all session.
102
+ *
103
+ * @returns {string}
104
+ */
105
+ digest() {
106
+ const where = this.logPath
107
+ ? `full gate output → ${this.logPath}`
108
+ : 'full gate output was streamed inline (no artifact could be written)';
109
+ return `${this.lineCount} line(s) of gate output captured; ${where}`;
110
+ }
111
+
112
+ /**
113
+ * Put the captured tail back inline — the failure path, where the evidence
114
+ * is what the caller came for. A no-op when the lines were already streamed
115
+ * inline (verbose, or degraded capture), so nothing is ever printed twice.
116
+ *
117
+ * @returns {number} Lines replayed.
118
+ */
119
+ replay() {
120
+ if (this.streamInline || this._tail.length === 0) return 0;
121
+ const dropped = this.lineCount - this._tail.length;
122
+ if (dropped > 0) {
123
+ this._emit(
124
+ `[close-validation] … ${dropped} earlier line(s) omitted; full output → ${this.logPath}`,
125
+ );
126
+ }
127
+ for (const line of this._tail) this._emit(line);
128
+ return this._tail.length;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Build the gate-output sink for one close run.
134
+ *
135
+ * @param {{
136
+ * storyId: number|null,
137
+ * cwd?: string,
138
+ * logDir?: string,
139
+ * fs?: typeof nodeFs,
140
+ * logger?: { info: (m: string) => void },
141
+ * level?: string,
142
+ * }} [args] `logDir` defaults to `<cwd>/temp/orchestration`; `level` defaults
143
+ * to the live Logger level so `AGENT_LOG_LEVEL=verbose` restores streaming.
144
+ * @returns {GateLogSink}
145
+ */
146
+ export function createGateLogSink({
147
+ storyId = null,
148
+ cwd = process.cwd(),
149
+ logDir,
150
+ fs = nodeFs,
151
+ logger = Logger,
152
+ level,
153
+ } = {}) {
154
+ const emit = (line) => logger.info?.(line);
155
+ const verbose = (level ?? resolveLevel()) === 'verbose';
156
+ const dir = logDir ?? path.join(cwd, 'temp', 'orchestration');
157
+
158
+ let handle = null;
159
+ let logPath = null;
160
+ try {
161
+ fs.mkdirSync(dir, { recursive: true });
162
+ logPath = path.join(dir, logNameFor(storyId));
163
+ // Truncate: each close run owns its artifact outright, so a re-run never
164
+ // hands the reader a file interleaving two runs' gates.
165
+ handle = fs.openSync(logPath, 'w');
166
+ } catch {
167
+ // No artifact — fall back to inline streaming rather than dropping the
168
+ // gate output on the floor.
169
+ return new GateLogSink({
170
+ logPath: null,
171
+ streamInline: true,
172
+ write: () => {},
173
+ emit,
174
+ });
175
+ }
176
+
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 });
186
+ }
@@ -24,6 +24,15 @@
24
24
  * the same, with `baseBranch` as the diff anchor and the Story worktree as
25
25
  * the commit target.
26
26
  *
27
+ * Bounded gate output (Story #4736). Every gate line goes to the run's
28
+ * `gate-log.js` sink — an artifact under the gitignored temp tree — instead
29
+ * of straight to the agent's stdout, where a passing `npm test` alone once
30
+ * pushed a successful close past the host's inline tool-result ceiling. A
31
+ * clean run reports one digest line naming the artifact; a failing gate
32
+ * replays its captured tail inline, because that is exactly when the caller
33
+ * needs the evidence in front of them. `AGENT_LOG_LEVEL=verbose` restores
34
+ * live streaming.
35
+ *
27
36
  * `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
28
37
  * are accepted as injected dependencies so the parent CLI's cache-busted
29
38
  * bindings win in tests that mock the upstream module URLs.
@@ -33,6 +42,7 @@ import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-va
33
42
  import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
34
43
  import { Logger } from '../../../Logger.js';
35
44
  import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
45
+ import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
36
46
 
37
47
  /**
38
48
  * Run the close-validation gate chain. Throws on first gate failure.
@@ -60,6 +70,7 @@ import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../s
60
70
  * runCloseValidation?: typeof defaultRunCloseValidation,
61
71
  * buildDefaultGates?: typeof defaultBuildDefaultGates,
62
72
  * runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
73
+ * createGateLogSink?: typeof defaultCreateGateLogSink,
63
74
  * }} args
64
75
  */
65
76
  export async function runCloseValidationPhase({
@@ -73,6 +84,7 @@ export async function runCloseValidationPhase({
73
84
  runCloseValidation = defaultRunCloseValidation,
74
85
  buildDefaultGates = defaultBuildDefaultGates,
75
86
  runScopedFormatAutofix = defaultRunScopedFormatAutofix,
87
+ createGateLogSink = defaultCreateGateLogSink,
76
88
  }) {
77
89
  // Story #4250 — format-autofix self-heal before the check-only gates.
78
90
  // Mirrors the Epic path (story-close/phases/gates.js): the formatter is
@@ -122,6 +134,9 @@ export async function runCloseValidationPhase({
122
134
  'VALIDATE',
123
135
  `Running close-validation gates against baseline ${baseBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
124
136
  );
137
+ // Story #4736 — one sink for both `log` seams (gate construction and gate
138
+ // execution), so nothing in the chain can route around the artifact.
139
+ const gateLog = createGateLogSink({ storyId, cwd });
125
140
  const validation = await runCloseValidation({
126
141
  cwd,
127
142
  worktreePath,
@@ -129,9 +144,9 @@ export async function runCloseValidationPhase({
129
144
  config,
130
145
  baseBranch,
131
146
  cwd: worktreePath || cwd,
132
- log: (m) => Logger.info(m),
147
+ log: gateLog.log,
133
148
  }),
134
- log: (m) => Logger.info(m),
149
+ log: gateLog.log,
135
150
  storyId,
136
151
  // Story #4250 — standalone storyId-anchored evidence keyspace. No
137
152
  // epicId; the standalone flag routes the cache to
@@ -141,10 +156,13 @@ export async function runCloseValidationPhase({
141
156
  if (!validation.ok) {
142
157
  const [first] = validation.failed;
143
158
  const { gate, status, cwd: gateCwd } = first;
159
+ // The evidence is the point on this path: replay the captured tail inline
160
+ // rather than making the caller open a file to learn why close stopped.
161
+ gateLog.replay();
144
162
  throw new Error(
145
163
  `[single-story-close] Gate failed: ${gate.name} (exit ${status})${gateCwd ? ` in ${gateCwd}` : ''}.` +
146
164
  (gate.hint ? ` ${gate.hint}` : ''),
147
165
  );
148
166
  }
149
- progress('VALIDATE', '✅ All gates passed.');
167
+ progress('VALIDATE', `✅ All gates passed. ${gateLog.digest()}`);
150
168
  }