dorfl 0.5.1 → 0.7.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.
@@ -36,6 +36,47 @@ import {git, run, runAsync, type RunResult} from './git.js';
36
36
  import {realSleep, type Sleep} from './retry-backoff.js';
37
37
  import {workBranchRef} from './slug-namespace.js';
38
38
  import {isAncestor} from './gc.js';
39
+ import {
40
+ detectColocatedSidecars,
41
+ formatSidecarGuardReason,
42
+ } from './sidecar-guard.js';
43
+
44
+ /**
45
+ * Build the human-facing CONTEXT tail appended to an `acceptance gate failed`
46
+ * reason — the load-bearing fix for the opaque `acceptance gate failed (exit N)`
47
+ * bounce message (a maintainer could not tell WHICH command failed or WHY without
48
+ * re-running the whole gate). Given the failed command + its output tail (both
49
+ * from {@link RunVerifyResult}), it produces a compact, deterministic block:
50
+ *
51
+ * - names the FAILED command (`the failing step was: \`<cmd>\``), so a
52
+ * multi-command gate (`build && test && format:check`) points at the culprit;
53
+ * - quotes the last lines of that command's output (the ACTUAL error, e.g.
54
+ * "no changesets were found"), so the answer surface carries real signal;
55
+ * - when neither is known (an older result / a prepare failure) it returns the
56
+ * empty string — the caller's base reason is unchanged (graceful degrade).
57
+ *
58
+ * PURE + seam-free (string in, string out) so it is unit-testable and reused by
59
+ * every gate-failure site (front gate, rebased-tip fresh gate, committed
60
+ * recovery). The output is bounded by {@link RunVerifyResult.outputTail}'s own
61
+ * cap, so it can never bloat the surfaced sidecar.
62
+ */
63
+ export function formatGateFailureContext(params: {
64
+ failedCommand?: string;
65
+ outputTail?: string;
66
+ }): string {
67
+ const {failedCommand, outputTail} = params;
68
+ const parts: string[] = [];
69
+ if (failedCommand !== undefined && failedCommand.trim() !== '') {
70
+ parts.push(`the failing step was: \`${failedCommand.trim()}\``);
71
+ }
72
+ if (outputTail !== undefined && outputTail.trim() !== '') {
73
+ parts.push(`its last output was:\n\n${outputTail.trimEnd()}`);
74
+ }
75
+ if (parts.length === 0) {
76
+ return '';
77
+ }
78
+ return ` — ${parts.join('; ')}`;
79
+ }
39
80
 
40
81
  /**
41
82
  * **The shared gate→integrate BACK-HALF** of the per-item pipeline, extracted out
@@ -92,6 +133,7 @@ export type IntegrationCoreOutcome =
92
133
  | 'review-blocked' // Gate 2 (PR/code review) returned `block` (or exhausted rounds)
93
134
  | 'review-unparseable' // Gate 2 ran but its verdict JSON could not be parsed (malformed output) — work-preserving route, transient-infra cause (NOT a reviewer block)
94
135
  | 'rebase-conflict' // rebase onto arbiter/main conflicted (aborted; human resolves)
136
+ | 'sidecar-violation' // a co-located <slug>/ sidecar sits beside a flowing task/spec item (WORK-CONTRACT rule 8) — HARD BLOCK before the durable move
95
137
  | 'invariant-violation' // one-slug-one-folder would break (slug in two folders on the arbiter)
96
138
  | 'already-integrated'; // committed-recovery: the kept tip is already on <arbiter>/main (clean no-op)
97
139
 
@@ -854,7 +896,15 @@ export async function performIntegration(
854
896
  // reason on the lock entry, no `in-progress/ → needs-attention/` folder
855
897
  // move) plus saving the agent's uncommitted work as a wip commit. No
856
898
  // partial state.
857
- const reason = `acceptance gate failed (exit ${gate.exitCode})`;
899
+ // Enrich the surfaced reason with WHICH gate command failed + the tail of
900
+ // its output (the actual error), so the needs-attention question is
901
+ // actionable instead of a bare exit code.
902
+ const reason =
903
+ `acceptance gate failed (exit ${gate.exitCode})` +
904
+ formatGateFailureContext({
905
+ failedCommand: gate.failedCommand,
906
+ outputTail: gate.outputTail,
907
+ });
858
908
  const routed = await ledgerWrite.applyNeedsAttentionTransition({
859
909
  cwd,
860
910
  slug,
@@ -933,6 +983,61 @@ export async function performIntegration(
933
983
  });
934
984
  }
935
985
 
986
+ // 1c. CO-LOCATED SIDECAR GUARD (WORK-CONTRACT.md rule 8). A `<slug>/` asset
987
+ // sidecar co-located with a FLOWING task/spec item strands on the
988
+ // `ready → done` / `ready → tasked` `git mv` (the `<slug>.md` moves, the
989
+ // `<slug>/` folder is left behind — one item split across two status folders,
990
+ // the SAME one-slug-one-folder invariant `ledger-lint`/the integration core
991
+ // enforce). So this is a HARD BLOCK at LAND, BEFORE the durable move: a
992
+ // detected sidecar routes the item to needs-attention via the SAME
993
+ // `applyNeedsAttentionTransition` seam the red gate / review block use, with
994
+ // an ACTIONABLE relocate-to-`docs/spikes/<slug>/` reason. A `notes/*` sidecar
995
+ // is NOT scanned (notes do not flow), and the `work/questions/*`
996
+ // status-mechanism file is not an item sidecar — so neither false-positives.
997
+ // Runs for BOTH the build and the tasking (`lifecycle`) transitions: either
998
+ // way a flowing item is about to be `git mv`'d and would strand a sidecar.
999
+ // ROUTING: the BUILD path routes here (the task lock the `applyNeedsAttention
1000
+ // Transition` seam keys on — `task:<slug>` — is the one `claim` holds); the
1001
+ // TASKING (`lifecycle`) path does its OWN spec-lock routing in `tasking.ts`
1002
+ // from `reviewBlockReason` (exactly as the review-blocked tasking path does),
1003
+ // so here it only RETURNS the reason without touching a `task:` lock that a
1004
+ // tasking run never held.
1005
+ const colocatedSidecars = detectColocatedSidecars(cwd, slug);
1006
+ if (colocatedSidecars.length > 0) {
1007
+ const reason = formatSidecarGuardReason(colocatedSidecars);
1008
+ if (lifecycle) {
1009
+ return {
1010
+ outcome: 'sidecar-violation',
1011
+ routedToNeedsAttention: false,
1012
+ branch,
1013
+ reason:
1014
+ `Co-located sidecar detected for '${slug}'; not completing the ` +
1015
+ `transition. ${reason}`,
1016
+ reviewBlockReason: reason,
1017
+ };
1018
+ }
1019
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
1020
+ cwd,
1021
+ slug,
1022
+ reason,
1023
+ arbiter: input.surfaceArbiter,
1024
+ env,
1025
+ note,
1026
+ });
1027
+ return {
1028
+ outcome: 'sidecar-violation',
1029
+ routedToNeedsAttention: routed.moved,
1030
+ branch,
1031
+ reason: routed.moved
1032
+ ? `Co-located task/spec sidecar detected for '${slug}'; marked it stuck ` +
1033
+ `on its per-item lock (a flowing item's <slug>/ sidecar strands on ` +
1034
+ `the durable move). ${reason}`
1035
+ : `Co-located task/spec sidecar detected for '${slug}'; not completing ` +
1036
+ `it. ${reason}`,
1037
+ reviewBlockReason: reason,
1038
+ };
1039
+ }
1040
+
936
1041
  // Read the title now, BEFORE the move, for the default commit summary AND the
937
1042
  // synthesised propose-mode PR TITLE (the source file is about to be git-mv'd
938
1043
  // away). The PR title is a SINGLE, capped line built runner-side from the
@@ -1314,10 +1419,21 @@ export async function performIntegration(
1314
1419
  gated.kind === 'prepare'
1315
1420
  ? `Env-prep (prepare) failed (exit ${gated.exitCode})`
1316
1421
  : `Acceptance gate failed (exit ${gated.exitCode})`;
1422
+ // The gate-failure CONTEXT (which command failed + its output tail) is
1423
+ // meaningful only for a `verify` failure; a prepare failure names its
1424
+ // single command already. Enrich the surfaced reason so the question is
1425
+ // actionable rather than an opaque exit code.
1426
+ const context =
1427
+ gated.kind === 'prepare'
1428
+ ? ''
1429
+ : formatGateFailureContext({
1430
+ failedCommand: gated.failedCommand,
1431
+ outputTail: gated.outputTail,
1432
+ });
1317
1433
  const reason =
1318
1434
  gated.kind === 'prepare'
1319
1435
  ? `prepare (env-prep) failed (exit ${gated.exitCode}) on the rebased tip`
1320
- : `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip`;
1436
+ : `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip${context}`;
1321
1437
  const routed = await ledgerWrite.applyNeedsAttentionTransition({
1322
1438
  cwd,
1323
1439
  slug,
@@ -1898,10 +2014,17 @@ async function recoverAlreadyCommitted(params: {
1898
2014
  gated.kind === 'prepare'
1899
2015
  ? `Env-prep (prepare) failed (exit ${gated.exitCode})`
1900
2016
  : `Acceptance gate failed (exit ${gated.exitCode})`;
2017
+ const context =
2018
+ gated.kind === 'prepare'
2019
+ ? ''
2020
+ : formatGateFailureContext({
2021
+ failedCommand: gated.failedCommand,
2022
+ outputTail: gated.outputTail,
2023
+ });
1901
2024
  const reason =
1902
2025
  gated.kind === 'prepare'
1903
2026
  ? `prepare (env-prep) failed (exit ${gated.exitCode}) on the rebased tip`
1904
- : `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip`;
2027
+ : `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip${context}`;
1905
2028
  const routed = await ledgerWrite.applyNeedsAttentionTransition({
1906
2029
  cwd,
1907
2030
  slug,
@@ -3055,6 +3178,19 @@ interface FreshGateResult {
3055
3178
  kind?: 'prepare' | 'verify';
3056
3179
  /** The non-zero exit code of the failing step (when `!passed`). */
3057
3180
  exitCode?: number;
3181
+ /**
3182
+ * The EXACT gate command that failed (`verify` kind only) — threaded from
3183
+ * {@link RunVerifyResult.failedCommand} so the surfaced reason names WHICH step
3184
+ * of a multi-command gate failed. Absent for a `prepare` failure (the prepare
3185
+ * command is single + already named).
3186
+ */
3187
+ failedCommand?: string;
3188
+ /**
3189
+ * The TAIL of the failed gate command's output (`verify` kind only) — threaded
3190
+ * from {@link RunVerifyResult.outputTail} so the surfaced question carries the
3191
+ * actual error text.
3192
+ */
3193
+ outputTail?: string;
3058
3194
  /**
3059
3195
  * The Gate-2 REVIEW outcome, present ONLY when a review gate was supplied to the
3060
3196
  * fresh gate AND `verify` passed (so the review ran AFTER it on the rebased tip).
@@ -3144,7 +3280,13 @@ async function runFreshWorktreeGate(params: {
3144
3280
  env,
3145
3281
  });
3146
3282
  if (!gate.passed) {
3147
- return {passed: false, kind: 'verify', exitCode: gate.exitCode};
3283
+ return {
3284
+ passed: false,
3285
+ kind: 'verify',
3286
+ exitCode: gate.exitCode,
3287
+ failedCommand: gate.failedCommand,
3288
+ outputTail: gate.outputTail,
3289
+ };
3148
3290
  }
3149
3291
  // GATE-2 REVIEW on the rebased tip, AFTER the green verify (verify-then-review
3150
3292
  // on the SAME merged tree). Runs while the worktree is still live (the review
package/src/run.ts CHANGED
@@ -1059,8 +1059,14 @@ async function runOneItem(
1059
1059
  core.outcome === 'prepare-failed' ||
1060
1060
  core.outcome === 'review-blocked' ||
1061
1061
  core.outcome === 'rebase-conflict' ||
1062
+ core.outcome === 'sidecar-violation' ||
1062
1063
  core.outcome === 'invariant-violation'
1063
1064
  ) {
1065
+ // `sidecar-violation`: a co-located `<slug>/` sidecar sits beside a FLOWING
1066
+ // task/spec item (WORK-CONTRACT rule 8) — a HARD BLOCK before the durable
1067
+ // move (it would strand on `ready → done`). The core routed it to
1068
+ // needs-attention with an actionable relocate-to-`docs/spikes/<slug>/`
1069
+ // reason; the least-supervised caller MUST NOT fall through to success.
1064
1070
  // `prepare-failed`: the env-prep (install) step was red, so the env could
1065
1071
  // not be made ready and `verify` was NOT run — distinct from a `tests-failed`
1066
1072
  // red gate. Route it to needs-attention like the others (a human fixes the
@@ -0,0 +1,142 @@
1
+ import {existsSync, statSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {
4
+ workFolderName,
5
+ workFolderPath,
6
+ type WorkFolderKey,
7
+ } from './work-layout.js';
8
+
9
+ /**
10
+ * The **co-located task/spec sidecar GUARD** (WORK-CONTRACT.md rule 8, the
11
+ * `notes/*`-only scoping).
12
+ *
13
+ * WHAT IT ENFORCES. A `<slug>/` asset sidecar folder co-located with a work item
14
+ * is ALLOWED for `notes/*` ONLY (`ideas`/`observations`/`findings` — they do NOT
15
+ * flow; a note leaves by deletion, so its sidecar never moves). It is FORBIDDEN
16
+ * for a `tasks/*` or `specs/*` item, because those regimes FLOW through status
17
+ * folders (`tasks/ready → tasks/done`, `specs/ready → specs/tasked`, …): a
18
+ * co-located sidecar shares the item's lifecycle and must be `git mv`'d in
19
+ * lockstep on every transition, and in practice gets STRANDED — the `<slug>.md`
20
+ * moves to the new status folder while the `<slug>/` sidecar is left behind in
21
+ * the old one, splitting ONE item across TWO status folders (a
22
+ * one-slug-one-folder violation, the SAME invariant `ledger-lint.ts` reads and
23
+ * the integration core enforces). A task's/spec's durable companion artifacts
24
+ * belong in the STABLE, non-flowing `docs/spikes/<slug>/` home (referenced by
25
+ * path from the `<slug>.md`), NOT a co-located sidecar.
26
+ *
27
+ * WHERE IT RUNS. This is the DETECTOR half; the integration core (`integration-core.ts`)
28
+ * wires it as a HARD BLOCK at LAND, BEFORE the durable `git mv` — a detected
29
+ * sidecar routes the item to needs-attention with {@link formatSidecarGuardReason},
30
+ * consistent with the status=folder / one-item-one-location contract the stranding
31
+ * violates. Fix = `git mv` the sidecar contents to `docs/spikes/<slug>/` + a
32
+ * reference edit in the `<slug>.md`.
33
+ *
34
+ * NO FALSE POSITIVES on: (a) the `work/questions/<type>-<slug>.md` needs-attention
35
+ * file — it is a tooling-owned STATUS-MECHANISM file, NOT scanned here (only
36
+ * `tasks/*` + `specs/*` FLOWING folders are); (b) a legitimate `notes/*` sidecar —
37
+ * the note buckets are deliberately EXCLUDED from the scan set; (c) a
38
+ * `docs/spikes/<slug>/` outside `work/` — this only ever looks INSIDE the FLOWING
39
+ * `work/` status folders.
40
+ */
41
+
42
+ /**
43
+ * The FLOWING status folders a `tasks/*` / `specs/*` item moves through — the ONLY
44
+ * folders scanned for an illegal co-located sidecar. Deliberately EXCLUDES the
45
+ * `notes/*` capture buckets (`ideas`/`observations`/`findings`, which legitimately
46
+ * MAY carry a sidecar) and the top-level `questions`/`protocol` surfaces (neither
47
+ * holds a flowing work item). A sidecar under any of THESE is a rule-8 violation
48
+ * because the item it sits beside will be `git mv`'d to another status folder and
49
+ * strand it.
50
+ */
51
+ export const SIDECAR_GUARD_FLOWING_FOLDERS = [
52
+ 'tasks-backlog',
53
+ 'tasks-ready',
54
+ 'done',
55
+ 'cancelled',
56
+ 'specs-proposed',
57
+ 'specs-ready',
58
+ 'specs-tasked',
59
+ 'specs-dropped',
60
+ ] as const satisfies readonly WorkFolderKey[];
61
+
62
+ /** One illegal co-located `<slug>/` sidecar found beside a flowing task/spec item. */
63
+ export interface ColocatedSidecar {
64
+ /** The flowing status folder the sidecar was found in. */
65
+ folder: WorkFolderKey;
66
+ /** The slug of the offending `<slug>/` sidecar directory. */
67
+ slug: string;
68
+ /** The repo-relative path of the sidecar directory (`work/<folder>/<slug>/`). */
69
+ dirRel: string;
70
+ }
71
+
72
+ /** Does `<dir>/<name>` exist AND is it a directory? (a sidecar is a folder). */
73
+ function isDir(dir: string, name: string): boolean {
74
+ try {
75
+ return statSync(join(dir, name)).isDirectory();
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Detect a co-located `<slug>/` asset sidecar directory sitting beside the
83
+ * `<slug>.md` of a FLOWING task/spec item, in the given `cwd`'s working tree.
84
+ * Scans ONLY {@link SIDECAR_GUARD_FLOWING_FOLDERS} for a `<slug>/` DIRECTORY whose
85
+ * sibling `<slug>.md` FILE is present (both must be there: a lone `<slug>/`
86
+ * directory with no item file is not this item's sidecar). Returns every offender
87
+ * found (typically at most one, for the item being landed), or `[]` when clean.
88
+ *
89
+ * PURE-ish: reads the filesystem, no writes, no throws. The integration core
90
+ * passes the specific `slug` being landed so the block is scoped to THAT item.
91
+ */
92
+ export function detectColocatedSidecars(
93
+ cwd: string,
94
+ slug: string,
95
+ ): ColocatedSidecar[] {
96
+ const found: ColocatedSidecar[] = [];
97
+ for (const folder of SIDECAR_GUARD_FLOWING_FOLDERS) {
98
+ const dir = workFolderPath(cwd, folder);
99
+ // The item file `<slug>.md` AND the sidecar dir `<slug>/` must BOTH be
100
+ // present for this to be the item's stranded-able sidecar. A stray `<slug>/`
101
+ // with no `<slug>.md` in the same folder is not an item's sidecar.
102
+ if (!existsSync(join(dir, `${slug}.md`))) {
103
+ continue;
104
+ }
105
+ if (isDir(dir, slug)) {
106
+ found.push({
107
+ folder,
108
+ slug,
109
+ dirRel: `work/${workFolderName(folder)}/${slug}/`,
110
+ });
111
+ }
112
+ }
113
+ return found;
114
+ }
115
+
116
+ /**
117
+ * Format the ACTIONABLE needs-attention reason for a detected co-located sidecar
118
+ * (the message the LAND-time hard block surfaces VERBATIM). Names the offending
119
+ * path, the correct destination, and the exact fix — a `git mv` to
120
+ * `docs/spikes/<slug>/` plus a reference edit.
121
+ */
122
+ export function formatSidecarGuardReason(
123
+ sidecars: readonly ColocatedSidecar[],
124
+ ): string {
125
+ if (sidecars.length === 0) {
126
+ return '';
127
+ }
128
+ const lines = [
129
+ 'task/spec artifacts belong in docs/spikes/<slug>/, not a co-located ' +
130
+ 'work/tasks|specs/<slug>/ sidecar; only notes/* may carry a sidecar ' +
131
+ '(WORK-CONTRACT rule 8) — relocate + reference by path:',
132
+ ];
133
+ for (const s of sidecars) {
134
+ lines.push(
135
+ ` - ${s.dirRel}: git mv its contents to docs/spikes/${s.slug}/ ` +
136
+ `(a STABLE, non-flowing home), then reference them by that path from ` +
137
+ `work/${workFolderName(s.folder)}/${s.slug}.md (a flowing item's ` +
138
+ `co-located sidecar strands on the ready→done move).`,
139
+ );
140
+ }
141
+ return lines.join('\n');
142
+ }
package/src/tasking.ts CHANGED
@@ -758,6 +758,39 @@ export async function performTask(
758
758
  `marked the per-item lock stuck (needs attention; no tasks landed).`,
759
759
  };
760
760
  }
761
+ if (core.outcome === 'sidecar-violation') {
762
+ // A co-located `<slug>/` asset sidecar sits beside the FLOWING spec item
763
+ // (WORK-CONTRACT.md rule 8): the tasking transition would `git mv`
764
+ // `specs/ready → specs/tasked` and STRAND the sidecar. The core HARD-BLOCKED
765
+ // before the stage/integrate; route the held spec to needs-attention through
766
+ // the SAME `spec:<slug>` lock-release seam the block path uses (no tasks land),
767
+ // carrying the actionable relocate-to-`docs/spikes/<slug>/` reason.
768
+ const reason =
769
+ `The spec '${slug}' carries a co-located asset sidecar: ` +
770
+ `${core.reviewBlockReason ?? core.reason ?? ''}`;
771
+ const routed = await lock.release({
772
+ slug,
773
+ cwd,
774
+ arbiter,
775
+ lockedBlob,
776
+ routeToNeedsAttention: {reason},
777
+ env,
778
+ note,
779
+ });
780
+ if (routed.outcome !== 'released') {
781
+ return releaseFailureToResult(routed, slug);
782
+ }
783
+ note(reason);
784
+ return {
785
+ exitCode: 1,
786
+ outcome: 'needs-attention',
787
+ slug,
788
+ message:
789
+ `The spec '${slug}' carries a co-located asset sidecar (WORK-CONTRACT ` +
790
+ `rule 8); marked the per-item lock stuck (needs attention; no tasks ` +
791
+ `landed; relocate it to docs/spikes/${slug}/ and reference by path).`,
792
+ };
793
+ }
761
794
  if (core.outcome === 'review-unparseable') {
762
795
  // The task-set acceptance gate RAN but its verdict was UNPARSEABLE (malformed
763
796
  // JSON). Route the held spec to needs-attention through the SAME lock-release
package/src/verify.ts CHANGED
@@ -99,6 +99,22 @@ export interface RunVerifyResult {
99
99
  commands: string[];
100
100
  /** Whether the gate passed (exitCode === 0). */
101
101
  passed: boolean;
102
+ /**
103
+ * The EXACT command that failed (the first non-zero exit — `&&`-short-circuit
104
+ * semantics), verbatim from the resolved gate list. Present ONLY on a failing
105
+ * result with a configured gate. This is the load-bearing context a bare `exit
106
+ * N` throws away: in a multi-command gate (`build && test && format:check`) it
107
+ * tells the human WHICH step failed without re-running the whole gate.
108
+ */
109
+ failedCommand?: string;
110
+ /**
111
+ * The TAIL of the failed command's combined stdout+stderr (last
112
+ * {@link VERIFY_OUTPUT_TAIL_LINES} non-empty lines), so the surfaced
113
+ * needs-attention question carries the ACTUAL error text (e.g. "no changesets
114
+ * were found") rather than an opaque exit code. Bounded so a noisy gate cannot
115
+ * bloat the sidecar. Present ONLY on a failing result with a configured gate.
116
+ */
117
+ outputTail?: string;
102
118
  /**
103
119
  * True iff the gate could not run because NO `verify` is declared (unset /
104
120
  * empty / all-blank). A distinct, always-failing outcome (`passed: false`)
@@ -108,6 +124,15 @@ export interface RunVerifyResult {
108
124
  notConfigured?: boolean;
109
125
  }
110
126
 
127
+ /**
128
+ * How many trailing non-empty output lines of the FAILED gate command are kept
129
+ * in {@link RunVerifyResult.outputTail}. Small enough to keep the surfaced
130
+ * question readable, large enough to carry the actual error (most tool errors
131
+ * are 1–3 lines). The tail is captured per-command and reset on each command so
132
+ * only the failing command's output is retained.
133
+ */
134
+ export const VERIFY_OUTPUT_TAIL_LINES = 20;
135
+
111
136
  /**
112
137
  * Run the resolved gate command(s) in `cwd`, streaming output, and resolve with
113
138
  * the gate's status: exit 0 iff every command passed. Commands run in sequence;
@@ -142,17 +167,62 @@ export async function runVerify(
142
167
  options.onStderr ?? ((chunk: string) => process.stderr.write(chunk));
143
168
 
144
169
  for (const command of commands) {
170
+ // Capture a bounded ring of this command's combined output so a FAILURE can
171
+ // carry the actual error text (not just an exit code). Reset per command so
172
+ // only the failing command's tail is retained. The captured chunks still
173
+ // stream through the sinks unchanged (the console/log is unaffected).
174
+ const tail: string[] = [];
175
+ const capture = (chunk: string) => {
176
+ for (const line of chunk.split('\n')) {
177
+ tail.push(line);
178
+ }
179
+ // Keep a little slack over the reported budget; trimmed to the exact budget
180
+ // (non-empty lines only) when a failure surfaces.
181
+ const maxRing = VERIFY_OUTPUT_TAIL_LINES * 4;
182
+ if (tail.length > maxRing) {
183
+ tail.splice(0, tail.length - maxRing);
184
+ }
185
+ };
145
186
  const exitCode = await runOne(command, options.cwd, options.env, {
146
- onStdout,
147
- onStderr,
187
+ onStdout: (chunk) => {
188
+ capture(chunk);
189
+ onStdout(chunk);
190
+ },
191
+ onStderr: (chunk) => {
192
+ capture(chunk);
193
+ onStderr(chunk);
194
+ },
148
195
  });
149
196
  if (exitCode !== 0) {
150
- return {exitCode, commands, passed: false};
197
+ return {
198
+ exitCode,
199
+ commands,
200
+ passed: false,
201
+ failedCommand: command,
202
+ outputTail: lastNonEmptyLines(tail, VERIFY_OUTPUT_TAIL_LINES),
203
+ };
151
204
  }
152
205
  }
153
206
  return {exitCode: 0, commands, passed: true};
154
207
  }
155
208
 
209
+ /**
210
+ * Join the last `n` NON-EMPTY lines of a captured output ring into a single
211
+ * string (newline-separated), preserving order. Blank lines are dropped so the
212
+ * tail is dense signal (tool errors, not the trailing whitespace many gates
213
+ * emit). Returns `undefined` when nothing was captured, so callers can omit the
214
+ * context cleanly rather than surfacing an empty block.
215
+ */
216
+ function lastNonEmptyLines(lines: string[], n: number): string | undefined {
217
+ const dense = lines
218
+ .map((line) => line.trimEnd())
219
+ .filter((line) => line !== '');
220
+ if (dense.length === 0) {
221
+ return undefined;
222
+ }
223
+ return dense.slice(-n).join('\n');
224
+ }
225
+
156
226
  /** Spawn one command via `bash -c`, streaming its output, resolving its code. */
157
227
  function runOne(
158
228
  command: string,