dorfl 0.11.2 → 0.11.3

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.
package/src/item-lock.ts CHANGED
@@ -298,10 +298,14 @@ export async function acquireItemLock(
298
298
  const entry = lockEntryFor(opts.item);
299
299
  const ref = itemLockRef(entry);
300
300
  try {
301
- // Fetch the current lock refs so the lease sees the real state.
301
+ // Fetch the current lock refs (PRUNED) so the lease sees the real arbiter
302
+ // state — `--prune` keeps the local `refs/dorfl/lock/*` namespace from
303
+ // accumulating refs the arbiter has deleted (observation
304
+ // `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`).
302
305
  await gitHard(
303
306
  [
304
307
  'fetch',
308
+ '--prune',
305
309
  '--quiet',
306
310
  arbiter,
307
311
  `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
@@ -393,9 +397,13 @@ async function releaseLockEntry(
393
397
  ): Promise<ReleaseResult> {
394
398
  const ref = itemLockRef(entry);
395
399
  try {
400
+ // `--prune` so a lock already released on the arbiter reads as not-held
401
+ // (its stale local ref is pruned) rather than as a phantom hold
402
+ // (observation `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`).
396
403
  await gitHard(
397
404
  [
398
405
  'fetch',
406
+ '--prune',
399
407
  '--quiet',
400
408
  arbiter,
401
409
  `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
@@ -558,8 +566,18 @@ async function fetchHeldEntry(
558
566
  arbiter: string,
559
567
  env: NodeJS.ProcessEnv | undefined,
560
568
  ): Promise<{lock: LockEntry; sha: string} | undefined> {
569
+ // `--prune` so a lock RELEASED on the arbiter (its ref deleted there) is PRUNED
570
+ // locally — otherwise the stale local ref survives and `rev-parse`/`show` below
571
+ // read it as still held (observation
572
+ // `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`).
561
573
  await gitHard(
562
- ['fetch', '--quiet', arbiter, `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`],
574
+ [
575
+ 'fetch',
576
+ '--prune',
577
+ '--quiet',
578
+ arbiter,
579
+ `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
580
+ ],
563
581
  cwd,
564
582
  env,
565
583
  );
@@ -903,8 +921,19 @@ export async function readItemLock(
903
921
  const env = opts.env;
904
922
  const cwd = opts.cwd;
905
923
  const ref = itemLockRef(lockEntryFor(opts.item));
924
+ // `--prune` so a lock RELEASED on the arbiter (its ref deleted there) is PRUNED
925
+ // locally — otherwise the stale local `refs/dorfl/lock/<entry>` would survive
926
+ // and `git show <ref>:lock.md` below would read it as still held (observation
927
+ // `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`). After the prune,
928
+ // a released lock's ref is gone locally ⇒ `show` fails ⇒ `undefined` (not locked).
906
929
  await gitHard(
907
- ['fetch', '--quiet', arbiter, `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`],
930
+ [
931
+ 'fetch',
932
+ '--prune',
933
+ '--quiet',
934
+ arbiter,
935
+ `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
936
+ ],
908
937
  cwd,
909
938
  env,
910
939
  );
@@ -1052,10 +1081,13 @@ export async function reconcileItemLockAgainstMain(
1052
1081
  const ref = itemLockRef(entry);
1053
1082
  try {
1054
1083
  // One fetch refreshes BOTH the lock refs and `<arbiter>/main` so the lock and
1055
- // the durable record are read from the SAME live arbiter snapshot.
1084
+ // the durable record are read from the SAME live arbiter snapshot. `--prune`
1085
+ // keeps the local lock namespace from accumulating refs the arbiter deleted
1086
+ // (observation `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`).
1056
1087
  await gitHard(
1057
1088
  [
1058
1089
  'fetch',
1090
+ '--prune',
1059
1091
  '--quiet',
1060
1092
  arbiter,
1061
1093
  `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
@@ -1235,9 +1267,12 @@ export async function classifyItemLockAgainstMain(
1235
1267
  const entry = lockEntryFor(opts.item);
1236
1268
  const ref = itemLockRef(entry);
1237
1269
  try {
1270
+ // `--prune` keeps the local lock namespace from accumulating refs the
1271
+ // arbiter deleted (observation `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`).
1238
1272
  await gitHard(
1239
1273
  [
1240
1274
  'fetch',
1275
+ '--prune',
1241
1276
  '--quiet',
1242
1277
  arbiter,
1243
1278
  `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
@@ -1799,8 +1834,25 @@ export async function listItemLocks(
1799
1834
  arbiter = 'origin',
1800
1835
  env?: NodeJS.ProcessEnv,
1801
1836
  ): Promise<string[]> {
1837
+ // `--prune` so a lock RELEASED on the arbiter (its ref deleted there) is PRUNED
1838
+ // locally — a bare `git fetch +refs/dorfl/lock/*:refs/dorfl/lock/*` (force, NO
1839
+ // `--prune`) leaves local refs the arbiter has since DELETED, so `for-each-ref`
1840
+ // would list locks that no longer exist (observation
1841
+ // `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`). After the pruned
1842
+ // fetch the local `refs/dorfl/lock/*` namespace EXACTLY matches the arbiter's, so
1843
+ // `for-each-ref` reads the arbiter's actual lock set. The refs are materialized
1844
+ // LOCALLY (not just `ls-remote`) because callers like `migrateStuckLocks` read a
1845
+ // lock's body via `git show <ref>:lock.md` after this. A fault THROWS (fail-closed
1846
+ // for the SELECTION path via {@link heldTaskSlugsStrict}; the graceful
1847
+ // {@link heldTaskSlugs}/{@link heldSpecSlugs} twins catch it).
1802
1848
  await gitHard(
1803
- ['fetch', '--quiet', arbiter, `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`],
1849
+ [
1850
+ 'fetch',
1851
+ '--prune',
1852
+ '--quiet',
1853
+ arbiter,
1854
+ `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
1855
+ ],
1804
1856
  cwd,
1805
1857
  env,
1806
1858
  );
@@ -1837,9 +1889,44 @@ export async function listItemLockEntries(
1837
1889
  env?: NodeJS.ProcessEnv,
1838
1890
  ): Promise<LockEntry[]> {
1839
1891
  try {
1840
- await gitHard(
1892
+ // Read the arbiter DIRECTLY (`git ls-remote`) for the AUTHORITATIVE lock ref
1893
+ // set — NOT the local `refs/dorfl/lock/*` refs (observation
1894
+ // `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`): a bare
1895
+ // `git fetch +refs/dorfl/lock/*:refs/dorfl/lock/*` (force, NO `--prune`)
1896
+ // leaves local refs the arbiter has since DELETED, so `for-each-ref` would
1897
+ // report locks that no longer exist — and `gc --ledger`'s entire purpose is
1898
+ // to name locks a human should delete, so it MUST NOT name locks that do not
1899
+ // exist. `ls-remote` lists ONLY the refs that ACTUALLY exist on the arbiter
1900
+ // right now; the report is bounded by THAT set, never by accumulated local
1901
+ // state. A fault degrades to an EMPTY report (US #12 — recoverable), exactly
1902
+ // as an absent lock-ref namespace reads; an arbiter with no locks returns
1903
+ // exit 0 + empty output ⇒ `[]`.
1904
+ const ls = await gitSoft(
1905
+ ['ls-remote', arbiter, `${LOCK_REF_PREFIX}/*`],
1906
+ cwd,
1907
+ env,
1908
+ );
1909
+ if (ls.status !== 0) {
1910
+ return [];
1911
+ }
1912
+ const refs = ls.stdout
1913
+ .split('\n')
1914
+ .map((l) => l.trim())
1915
+ .filter((l) => l !== '')
1916
+ .map((l) => l.split(/\s+/)[1])
1917
+ .filter((ref) => ref.startsWith(`${LOCK_REF_PREFIX}/`))
1918
+ .sort();
1919
+ if (refs.length === 0) {
1920
+ return [];
1921
+ }
1922
+ // Materialize the objects AND keep the local `refs/dorfl/lock/*` namespace
1923
+ // PRUNED to match the arbiter (best-effort). The list above is already
1924
+ // bounded by `ls-remote`, so a fetch fault here can only UNDER-report (skip
1925
+ // content), never name a non-existent ref — the safe direction.
1926
+ await gitSoft(
1841
1927
  [
1842
1928
  'fetch',
1929
+ '--prune',
1843
1930
  '--quiet',
1844
1931
  arbiter,
1845
1932
  `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
@@ -1847,19 +1934,6 @@ export async function listItemLockEntries(
1847
1934
  cwd,
1848
1935
  env,
1849
1936
  );
1850
- const out = await gitSoft(
1851
- ['for-each-ref', '--format=%(refname)', `${LOCK_REF_PREFIX}/*`],
1852
- cwd,
1853
- env,
1854
- );
1855
- if (out.status !== 0) {
1856
- return [];
1857
- }
1858
- const refs = out.stdout
1859
- .split('\n')
1860
- .map((l) => l.trim())
1861
- .filter((l) => l.startsWith(`${LOCK_REF_PREFIX}/`))
1862
- .sort();
1863
1937
  const entries: LockEntry[] = [];
1864
1938
  for (const ref of refs) {
1865
1939
  const show = await gitSoft(['show', `${ref}:lock.md`], cwd, env);
package/src/pi-harness.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  type LaunchResult,
13
13
  } from './harness.js';
14
14
  import {generateSessionPath} from './session-path.js';
15
- import {lastAssistantText} from './watch-session.js';
15
+ import {lastAssistantTurn, isOutputCappedTurn} from './watch-session.js';
16
16
  import {reapProcessGroup} from './reap-agent-tree.js';
17
17
  import type {HarnessAdapter} from './config.js';
18
18
 
@@ -188,8 +188,10 @@ export class PiHarness implements Harness {
188
188
  detail: status === 0 ? undefined : (result.stderr ?? '').trim(),
189
189
  // The agent's ANSWER (task `harness-agent-output`): the LAST assistant
190
190
  // turn's text read from the session `.jsonl` pi just wrote — NOT piped
191
- // stdout (which is drained). Shares `watch-session.ts`'s reader.
192
- output: readLastAssistantText(sessionFile),
191
+ // stdout (which is drained). Shares `watch-session.ts`'s reader. The
192
+ // same turn's stop_reason/usage feed the outputCapped cap-truncation
193
+ // signal (observation `tasker-review-edits-payload-caps-the-verdict-response`).
194
+ ...readAssistantOutput(sessionFile),
193
195
  };
194
196
  }
195
197
 
@@ -384,9 +386,10 @@ export class PiHarness implements Harness {
384
386
  timedOut: timedOut ? true : undefined,
385
387
  ...(reap ? {reap} : {}),
386
388
  // Read the agent's ANSWER from the `.jsonl` at `exit` — the same
387
- // last-assistant-text read `launch` does at return (task
389
+ // last-assistant-turn read `launch` does at return (task
388
390
  // `harness-agent-output`); the process has exited so the log is final.
389
- output: readLastAssistantText(sessionFile),
391
+ // The same turn's stop_reason/usage feed the outputCapped signal.
392
+ ...readAssistantOutput(sessionFile),
390
393
  });
391
394
  };
392
395
  if (!timedOut || pgid === undefined) {
@@ -523,14 +526,18 @@ export function piSessionExists(record: HarnessRecord): boolean {
523
526
  }
524
527
 
525
528
  /**
526
- * Read the LAST assistant message's text from the pi session `.jsonl` at
527
- * `sessionFile` — the agent's final ANSWER, surfaced as `LaunchResult.output`
528
- * (task `harness-agent-output`). Called by BOTH `launch` (at return) and
529
- * `launchAsync` (at `close`), AFTER pi has exited so the log is complete.
529
+ * Read the LAST assistant turn's output from the pi session `.jsonl` at
530
+ * `sessionFile` — the agent's final ANSWER (`output`) PLUS the output-cap
531
+ * signal (`outputCapped`, when the turn was truncated at the model's output-token
532
+ * cap before it finished). Surfaced through the harness seam as
533
+ * `LaunchResult.output` / `LaunchResult.outputCapped` (task `harness-agent-output`;
534
+ * observation `tasker-review-edits-payload-caps-the-verdict-response`). Called by
535
+ * BOTH `launch` (at return) and `launchAsync` (at `exit`), AFTER pi has exited so
536
+ * the log is complete.
530
537
  *
531
- * It REUSES `watch-session.ts`'s {@link lastAssistantText} (one `.jsonl` parser,
532
- * not two). An absent file (pi never wrote it) yields `undefined`, as does a log
533
- * with no assistant text — a read error is never thrown back into the launch.
538
+ * It REUSES `watch-session.ts`'s {@link lastAssistantTurn} (one `.jsonl` parser,
539
+ * not two). An absent file (pi never wrote it) yields `{}`, as does a log with no
540
+ * assistant text — a read error is never thrown back into the launch.
534
541
  *
535
542
  * Studied (task `pi-harness-polish`, finding
536
543
  * `work/notes/findings/pi-harness-channels.md`, pinned against pi 0.73.1 +
@@ -543,14 +550,21 @@ export function piSessionExists(record: HarnessRecord): boolean {
543
550
  * so a future stream/HTTP-shaped harness (opencode-style) still fits: the file
544
551
  * shape lives BEHIND this reader and is not observable through the seam.
545
552
  */
546
- function readLastAssistantText(sessionFile: string): string | undefined {
553
+ function readAssistantOutput(sessionFile: string): {
554
+ output?: string;
555
+ outputCapped?: number;
556
+ } {
547
557
  let jsonl: string;
548
558
  try {
549
559
  jsonl = readFileSync(sessionFile, 'utf8');
550
560
  } catch {
551
- return undefined; // no session log on disk — no answer to surface.
561
+ return {}; // no session log on disk — no answer to surface.
552
562
  }
553
- return lastAssistantText(jsonl);
563
+ const turn = lastAssistantTurn(jsonl);
564
+ return {
565
+ output: turn.text,
566
+ outputCapped: isOutputCappedTurn(turn) ? turn.outputTokens : undefined,
567
+ };
554
568
  }
555
569
 
556
570
  // Register the pi adapter so `status`/`do`/`gc` resolve liveness for `pi` jobs
@@ -11,6 +11,7 @@ import {
11
11
  type LoadedRepoConfig,
12
12
  } from './repo-config.js';
13
13
  import {encodeRepoKey} from './repo-key.js';
14
+ import {LOCK_REF_PREFIX} from './item-lock.js';
14
15
 
15
16
  export {encodeRepoKey} from './repo-key.js';
16
17
 
@@ -132,6 +133,27 @@ export function ensureMirror(options: EnsureMirrorOptions): EnsureMirrorResult {
132
133
  // materialisation has its base; the per-branch onboard reads still fail loudly
133
134
  // downstream if a needed ref is genuinely absent.
134
135
  run('git', ['fetch', 'origin', '+refs/heads/*:refs/heads/*'], path, {env});
136
+ // PRUNE the per-item LOCK namespace against the arbiter (observation
137
+ // `gc-ledger-reports-mirror-stale-lock-refs-as-arbiter-state`): the all-heads
138
+ // fetch above covers only `refs/heads/*`, so `refs/dorfl/lock/*` was never
139
+ // pruned here and released locks accumulated on the mirror indefinitely — a
140
+ // `gc --ledger` (or any `status`/`scan` read) that reads the mirror then
141
+ // reported locks that no longer exist on the arbiter. Best-effort (soft):
142
+ // an unreachable arbiter leaves the existing lock refs in place (the
143
+ // read-side `listItemLocks`/`listItemLockEntries` read the arbiter DIRECTLY
144
+ // via `ls-remote`, so this is the proactive keep-the-mirror-clean pass, not
145
+ // the load-bearing one).
146
+ run(
147
+ 'git',
148
+ [
149
+ 'fetch',
150
+ '--prune',
151
+ 'origin',
152
+ `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
153
+ ],
154
+ path,
155
+ {env},
156
+ );
135
157
  fetched = true;
136
158
  } else {
137
159
  // First time: a bare mirror clone (shared object store, cheap).
@@ -40,8 +40,27 @@ export interface ReviewFinding {
40
40
  export interface TaskEdit {
41
41
  /** Repo-relative path of the candidate task file to write. */
42
42
  path: string;
43
- /** The full replacement content for that file. */
44
- content: string;
43
+ /**
44
+ * The full replacement content for that file, emitted INLINE. LEGACY / small-edit
45
+ * form. UNBOUNDED: a large decomposition's worth of full-file bodies in ONE JSON
46
+ * object shares the model's capped response with the verdict, so a rich spec can
47
+ * cap-truncate the response before the verdict closes (observation
48
+ * `tasker-review-edits-payload-caps-the-verdict-response`). The tasker improver loop
49
+ * now asks the agent to WRITE each edited body to a scratch file and reference it by
50
+ * `src` instead (see {@link src}) so the verdict is obtainable independent of edit
51
+ * size. Kept (optional) so a small inline edit + the existing tests still work.
52
+ */
53
+ content?: string;
54
+ /**
55
+ * Repo-relative path to a SCRATCH file the agent WROTE the full replacement body
56
+ * to (the runner reads it, applies it through the SAME scope fence, then deletes
57
+ * it). The DECOUPLED form: the verdict JSON carries only PATHS, so its size is
58
+ * bounded by the NUMBER of edits, not the total body size — a large decomposition
59
+ * can no longer cap-truncate the verdict. The agent writes the scratch file itself
60
+ * (it runs in the job worktree and has the `write` tool); the runner applies /
61
+ * commits. Exactly one of {@link content} / {@link src} carries the body.
62
+ */
63
+ src?: string;
45
64
  }
46
65
 
47
66
  /**
@@ -91,6 +110,39 @@ export interface ReviewVerdict {
91
110
  /** Raised when the review agent ran but produced no parseable verdict. */
92
111
  export class ReviewParseError extends Error {}
93
112
 
113
+ /**
114
+ * Raised when the review agent's output was TRUNCATED at the model's output cap
115
+ * before it could emit a complete verdict — a DISTINCT, named failure class so it is
116
+ * never mis-reported as a generic "produced no parseable result" (which reads as a
117
+ * flake and invites a blind retry, burning a second full tasking run).
118
+ *
119
+ * It is a SUBCLASS of {@link ReviewParseError} so every existing
120
+ * `catch (ReviewParseError)` site (the tasker-review loop's bounce routing) still
121
+ * catches it UNIFORMLY and routes to needs-attention — NEVER a silent approve. The
122
+ * only thing that changes is the MESSAGE: it names the cap and the token count so
123
+ * an operator knows the structural cause (the edits payload is unbounded and
124
+ * shared the capped response with the verdict) rather than chasing a model flake.
125
+ *
126
+ * Detected at the harness seam: the adapter surfaces the last assistant turn's
127
+ * `stop_reason` (null / `None` / `max_tokens` — the turn did not end naturally) and
128
+ * its `usage.output` token count; the gate throws this when a parse fails AND that
129
+ * cap signal is present. When the adapter CANNOT see the signal (the null/shell
130
+ * adapter, or a future adapter without usage telemetry), the parse still fails as a
131
+ * generic {@link ReviewParseError} — still needs-attention, never a silent approve.
132
+ */
133
+ export class ReviewOutputCappedError extends ReviewParseError {
134
+ /** The output token count the agent reached when the cap hit (the observed `usage.output`). */
135
+ readonly outputTokens: number;
136
+ constructor(outputTokens: number) {
137
+ super(
138
+ `review agent output hit the model output cap (${outputTokens} tokens) ` +
139
+ 'and was truncated before emitting its verdict',
140
+ );
141
+ this.name = 'ReviewOutputCappedError';
142
+ this.outputTokens = outputTokens;
143
+ }
144
+ }
145
+
94
146
  /**
95
147
  * Parse the unified review verdict out of the review agent's textual output.
96
148
  * The agent may wrap the JSON object in prose / a fenced block, so the first
@@ -246,9 +298,22 @@ function parseEdits(raw: unknown): TaskEdit[] {
246
298
  continue;
247
299
  }
248
300
  const item = e as Record<string, unknown>;
249
- if (typeof item.path === 'string' && typeof item.content === 'string') {
250
- out.push({path: item.path, content: item.content});
301
+ if (typeof item.path !== 'string') {
302
+ continue;
303
+ }
304
+ // The body is either inline `content` (legacy / small edits) OR a `src` scratch
305
+ // path the agent wrote (the decoupled form — see TaskEdit). Carry whichever
306
+ // is present; the runner resolves it. An edit with NEITHER is dropped (no body
307
+ // to apply) — but a path-only edit is also tolerated as a no-op marker so a
308
+ // verdict that names a path it did not actually rewrite does not crash the loop.
309
+ const edit: TaskEdit = {path: item.path};
310
+ if (typeof item.content === 'string') {
311
+ edit.content = item.content;
312
+ }
313
+ if (typeof item.src === 'string') {
314
+ edit.src = item.src;
251
315
  }
316
+ out.push(edit);
252
317
  }
253
318
  return out;
254
319
  }
@@ -314,7 +379,10 @@ export function verdictContractPrompt(): string {
314
379
  ' {"severity": "blocking" | "non-blocking", "question": "\u2026", "context": "\u2026"}',
315
380
  ' ],',
316
381
  ' "review": "<the human-readable PR-comment prose, when the caller asks for it>",',
317
- ' "edits": [ {"path": "work/tasks/backlog/<slug>.md", "content": "<full replacement>"} ],',
382
+ ' "edits": [ {"path": "work/tasks/backlog/<slug>.md", "src": "<scratch path you wrote the full body to>"} ],',
383
+ ' // (inline "content": "<full replacement>" is the legacy small-edit form —',
384
+ ' // the tasker loop writes the body to a scratch file + references it by',
385
+ ' // "src" so the edits payload cannot cap-truncate this verdict)',
318
386
  ' "edit": "<single in-memory replacement body, for the lone-task review>",',
319
387
  ' "questions": ["<open question for the human>"],',
320
388
  ' "uncertainTasks": [ {"path": "work/tasks/backlog/<slug>.md", "questions": ["\u2026"]} ],',
@@ -1,4 +1,4 @@
1
- import {readFileSync, readdirSync, writeFileSync} from 'node:fs';
1
+ import {readFileSync, readdirSync, writeFileSync, rmSync} from 'node:fs';
2
2
  import {join} from 'node:path';
3
3
  import {
4
4
  workFolderPrefix,
@@ -12,6 +12,7 @@ import {launchWithOptionalWatch} from './agent-launch.js';
12
12
  import {
13
13
  parseReviewVerdict,
14
14
  ReviewParseError,
15
+ ReviewOutputCappedError,
15
16
  reviewDisciplinePrompt,
16
17
  verdictContractPrompt,
17
18
  type ReviewFinding,
@@ -26,6 +27,20 @@ import {
26
27
  // `review-protocol-doc-and-shared-machinery`).
27
28
  export type {TaskEdit, UncertainTask} from './review-verdict.js';
28
29
  export {parseReviewVerdict as parseTaskReviewVerdict} from './review-verdict.js';
30
+ export {ReviewOutputCappedError as TaskReviewOutputCappedError} from './review-verdict.js';
31
+
32
+ /**
33
+ * The SCRATCH directory the review agent writes full-replacement task bodies to
34
+ * (the DECOUPLED edits channel — see {@link TaskEdit.src}). Sibling of `backlog/`
35
+ * under `work/tasks/`; NOT a {@link WorkFolderKey}, so it is never scanned by
36
+ * {@link newOrChangedBacklog} (which reads `work/tasks/backlog/` only) and never
37
+ * appears as a candidate task. The runner reads each `src` file, applies it to the
38
+ * target `work/tasks/backlog/<name>.md` through the SAME scope fence, then DELETES
39
+ * it — so the integrate's `git add -A` never sweeps the scratch into the commit. A
40
+ * start-of-loop clean (see {@link cleanReviewEditsScratch}) reaps any stragglers a
41
+ * crashed prior run left behind.
42
+ */
43
+ export const REVIEW_EDITS_SCRATCH_DIR = 'work/tasks/.review-edits';
29
44
 
30
45
  /**
31
46
  * **The tasker review→edit→re-review→converge LOOP** (`slicer-review-edit-loop`,
@@ -247,6 +262,10 @@ export async function runTaskReviewLoop(
247
262
  // tasks, never pre-existing landed ones (the requeue fix). No snapshot ⇒ an
248
263
  // empty one (the legacy whole-directory behaviour for the empty-backlog case).
249
264
  const before = options.before ?? new Map<string, string>();
265
+ // Reap any review-edits SCRATCH a crashed prior run left behind, so the
266
+ // integrate's `git add -A` never sweeps stragglers into this run's commit. The
267
+ // loop deletes each `src` it applies too; this is the crash-recovery backstop.
268
+ cleanReviewEditsScratch(options.cwd);
250
269
 
251
270
  let totalPasses = 0;
252
271
  let last: SingleExecutionResult | undefined;
@@ -456,6 +475,15 @@ function applyEdits(
456
475
  );
457
476
  continue;
458
477
  }
478
+ // Resolve the edit BODY: inline `content` (legacy / small edits) OR a `src`
479
+ // scratch file the agent wrote (the decoupled form — see TaskEdit.src). The
480
+ // `src` form keeps the verdict JSON bounded by the NUMBER of edits, not the
481
+ // total body size, so a large decomposition can no longer cap-truncate the
482
+ // verdict. A path-only edit with no resolvable body is a no-op marker.
483
+ const body = resolveEditBody(cwd, edit, note);
484
+ if (body === undefined) {
485
+ continue;
486
+ }
459
487
  // A pre-existing task this run did NOT touch must not be overwritten: it is
460
488
  // in `before` and the current on-disk content still equals the snapshot.
461
489
  if (before.has(filename)) {
@@ -475,7 +503,70 @@ function applyEdits(
475
503
  }
476
504
  }
477
505
  const abs = join(cwd, normalized);
478
- writeFileSync(abs, edit.content);
506
+ writeFileSync(abs, body);
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Resolve an edit's full-replacement BODY: inline `content` (legacy) takes
512
+ * precedence, else read the `src` scratch file the agent wrote. Returns
513
+ * `undefined` when there is no body to apply (neither channel present, or `src`
514
+ * missing / outside the scratch fence / unreadable). The `src` is fenced to
515
+ * {@link REVIEW_EDITS_SCRATCH_DIR} (no `..`) so the agent cannot pull an arbitrary
516
+ * repo file in as the edit body. A read `src` is DELETED after reading so the
517
+ * integrate's `git add -A` never sweeps the scratch into the commit.
518
+ */
519
+ function resolveEditBody(
520
+ cwd: string,
521
+ edit: TaskEdit,
522
+ note: (message: string) => void,
523
+ ): string | undefined {
524
+ if (edit.content !== undefined) {
525
+ return edit.content;
526
+ }
527
+ if (edit.src === undefined) {
528
+ return undefined;
529
+ }
530
+ const src = edit.src.replace(/\\/g, '/');
531
+ if (src.includes('..') || !src.startsWith(REVIEW_EDITS_SCRATCH_DIR + '/')) {
532
+ note(
533
+ `Skipped a review edit whose src is outside ${REVIEW_EDITS_SCRATCH_DIR}/ (${edit.src}) — ` +
534
+ 'the scratch body must live under the review-edits scratch dir.',
535
+ );
536
+ return undefined;
537
+ }
538
+ const srcAbs = join(cwd, src);
539
+ let body: string | undefined;
540
+ try {
541
+ body = readFileSync(srcAbs, 'utf8');
542
+ } catch {
543
+ note(
544
+ `Skipped a review edit whose src scratch file is missing/unreadable (${edit.src}) — ` +
545
+ 'the agent did not write it before emitting the verdict.',
546
+ );
547
+ return undefined;
548
+ }
549
+ // Reap the scratch file now the body is in hand — never let it survive to the
550
+ // integrate's `git add -A` (best-effort; a missing file is already gone).
551
+ try {
552
+ rmSync(srcAbs, {force: true});
553
+ } catch {
554
+ // Best-effort: a failure to delete does not block the apply.
555
+ }
556
+ return body;
557
+ }
558
+
559
+ /**
560
+ * Best-effort reap of the {@link REVIEW_EDITS_SCRATCH_DIR} (a crashed prior run may
561
+ * have left scratch bodies behind; the integrate's `git add -A` would otherwise
562
+ * sweep them into the next tasking commit). Called at the START of a loop run.
563
+ * Never throws — a missing/unreadable dir is the normal steady state.
564
+ */
565
+ function cleanReviewEditsScratch(cwd: string): void {
566
+ try {
567
+ rmSync(join(cwd, REVIEW_EDITS_SCRATCH_DIR), {recursive: true, force: true});
568
+ } catch {
569
+ // Best-effort.
479
570
  }
480
571
  }
481
572
 
@@ -600,7 +691,28 @@ export function harnessTaskReviewGate(
600
691
  }`,
601
692
  );
602
693
  }
603
- return parseReviewVerdict(readOutput(launched.output));
694
+ // CAP-TRUNCATION NAMING (observation
695
+ // `tasker-review-edits-payload-caps-the-verdict-response`): attempt the parse
696
+ // FIRST so a verdict that DID complete (even on a capped turn — e.g. the cap
697
+ // hit trailing prose after the JSON closed) is HONORED, not discarded. Only
698
+ // when the parse FAILS AND the adapter surfaced an output-cap signal do we
699
+ // re-throw the NAMED {@link ReviewOutputCappedError} (a subclass of
700
+ // ReviewParseError, so the bounce routing still catches it — NEVER a silent
701
+ // approve). A parse failure with no cap signal stays the generic
702
+ // ReviewParseError (the adapter could not see the signal — still
703
+ // needs-attention, still never a silent approve).
704
+ try {
705
+ return parseReviewVerdict(readOutput(launched.output));
706
+ } catch (err) {
707
+ if (
708
+ launched.outputCapped !== undefined &&
709
+ (err instanceof ReviewParseError ||
710
+ err instanceof ReviewOutputCappedError)
711
+ ) {
712
+ throw new ReviewOutputCappedError(launched.outputCapped);
713
+ }
714
+ throw err;
715
+ }
604
716
  };
605
717
  }
606
718
 
@@ -634,16 +746,26 @@ export function buildTaskReviewPrompt(input: TaskReviewGateInput): string {
634
746
  `loop, not a one-shot gate.`,
635
747
  ``,
636
748
  `This is review pass ${input.pass} (fresh context ${input.execution}). You`,
637
- `do NOT edit files or run git yourself you EMIT the edits to apply as FULL`,
638
- `replacement content and the runner applies them, then re-reviews. Tasks`,
749
+ `do NOT run git. For the EDITS, WRITE each full-replacement task body to a`,
750
+ `SCRATCH file under ${REVIEW_EDITS_SCRATCH_DIR}/ and reference it by path in the`,
751
+ `"edits" channel — do NOT inline the body in the JSON, and do NOT edit the`,
752
+ `candidate task files directly (the runner applies your scratch body to the`,
753
+ `candidate task through its scope fence + deletes the scratch; on a review`,
754
+ `failure the tasker's original candidates stay pristine for recovery). This`,
755
+ `keeps the verdict JSON bounded by the NUMBER of edits, not the total body size,`,
756
+ `so a large decomposition cannot cap-truncate the verdict. Tasks`,
639
757
  `measurably keep improving when reviewed, so propose edits that fix the`,
640
758
  `findings; converge when a pass finds NO NEW blocking issue.`,
641
759
  ``,
642
760
  verdictContractPrompt(),
643
761
  ``,
644
762
  `Fill the channels appropriate to THIS caller (the tasker improver loop):`,
645
- ` - "edits" — full-content replacements for candidate task files when you`,
646
- ` can FIX a finding by editing (the natural improver step).`,
763
+ ` - "edits" — for each candidate task file you want to rewrite, WRITE the full`,
764
+ ` replacement body to ${REVIEW_EDITS_SCRATCH_DIR}/<name>.md (the runner reads`,
765
+ ` it, applies it to work/tasks/backlog/<name>.md, then deletes the scratch),`,
766
+ ` and emit {"path": "work/tasks/backlog/<name>.md", "src": "${REVIEW_EDITS_SCRATCH_DIR}/<name>.md"}.`,
767
+ ` Do NOT put the body in "content" — that shares the capped response with`,
768
+ ` the verdict and cap-truncates it on a large set.`,
647
769
  ` - "uncertainTasks" — specific tasks you cannot make buildable (each gets`,
648
770
  ` \`needsAnswers: true\` with the questions in its body).`,
649
771
  ` - "decompositionUnclear" — the WHOLE decomposition is unsound (the spec is`,