dorfl 0.11.2 → 0.12.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 (54) hide show
  1. package/dist/harness.d.ts +15 -0
  2. package/dist/harness.d.ts.map +1 -1
  3. package/dist/harness.js.map +1 -1
  4. package/dist/integration-core.d.ts.map +1 -1
  5. package/dist/integration-core.js +69 -0
  6. package/dist/integration-core.js.map +1 -1
  7. package/dist/item-lock.d.ts.map +1 -1
  8. package/dist/item-lock.js +89 -15
  9. package/dist/item-lock.js.map +1 -1
  10. package/dist/pi-harness.d.ts.map +1 -1
  11. package/dist/pi-harness.js +26 -15
  12. package/dist/pi-harness.js.map +1 -1
  13. package/dist/protocol/CLAIM-PROTOCOL.md +22 -13
  14. package/dist/protocol/REVIEW-PROTOCOL.md +1 -1
  15. package/dist/protocol/TASKING-PROTOCOL.md +1 -0
  16. package/dist/protocol/WORK-CONTRACT.md +6 -0
  17. package/dist/protocol/task-template.md +1 -1
  18. package/dist/repo-mirror.d.ts.map +1 -1
  19. package/dist/repo-mirror.js +17 -0
  20. package/dist/repo-mirror.js.map +1 -1
  21. package/dist/review-gate.d.ts.map +1 -1
  22. package/dist/review-gate.js +9 -3
  23. package/dist/review-gate.js.map +1 -1
  24. package/dist/review-verdict.d.ts +46 -2
  25. package/dist/review-verdict.d.ts.map +1 -1
  26. package/dist/review-verdict.js +49 -3
  27. package/dist/review-verdict.js.map +1 -1
  28. package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +22 -13
  29. package/dist/skills/setup/protocol/REVIEW-PROTOCOL.md +1 -1
  30. package/dist/skills/setup/protocol/TASKING-PROTOCOL.md +1 -0
  31. package/dist/skills/setup/protocol/WORK-CONTRACT.md +6 -0
  32. package/dist/skills/setup/protocol/task-template.md +1 -1
  33. package/dist/tasker-review-loop.d.ts +13 -0
  34. package/dist/tasker-review-loop.d.ts.map +1 -1
  35. package/dist/tasker-review-loop.js +120 -8
  36. package/dist/tasker-review-loop.js.map +1 -1
  37. package/dist/tasking.d.ts.map +1 -1
  38. package/dist/tasking.js +161 -17
  39. package/dist/tasking.js.map +1 -1
  40. package/dist/watch-session.d.ts +35 -4
  41. package/dist/watch-session.d.ts.map +1 -1
  42. package/dist/watch-session.js +54 -7
  43. package/dist/watch-session.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/harness.ts +15 -0
  46. package/src/integration-core.ts +77 -0
  47. package/src/item-lock.ts +93 -19
  48. package/src/pi-harness.ts +29 -15
  49. package/src/repo-mirror.ts +22 -0
  50. package/src/review-gate.ts +9 -3
  51. package/src/review-verdict.ts +73 -5
  52. package/src/tasker-review-loop.ts +129 -7
  53. package/src/tasking.ts +198 -16
  54. package/src/watch-session.ts +83 -7
@@ -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`,
package/src/tasking.ts CHANGED
@@ -44,7 +44,9 @@ import {
44
44
  runTaskReviewLoop,
45
45
  type TaskReviewGate,
46
46
  type RunTaskReviewLoopResult,
47
+ REVIEW_EDITS_SCRATCH_DIR,
47
48
  } from './tasker-review-loop.js';
49
+ import {ReviewParseError, ReviewOutputCappedError} from './review-verdict.js';
48
50
  import type {ReviewGate} from './review-gate.js';
49
51
 
50
52
  /**
@@ -551,22 +553,78 @@ export async function performTask(
551
553
  // AGENT path runs the loop — the human tasking path is unaffected.
552
554
  let loopDisposition: RunTaskReviewLoopResult | undefined;
553
555
  if (options.reviewLoop && doer === 'agent') {
554
- loopDisposition = await runTaskReviewLoop({
555
- slug,
556
- cwd,
557
- gate: options.reviewLoop,
558
- // SCOPING FENCE (the requeue fix): the loop reviews/edits/flags ONLY the
559
- // tasks THIS run produced (new-or-changed vs `before`), never the
560
- // pre-existing staged tasks that share `work/tasks/backlog/`.
561
- before,
562
- taskerLoopMax: options.taskerLoopMax ?? 3,
563
- executions: options.reviewExecutions,
564
- taskerLoopModel: options.taskerLoopModel,
565
- sessionsDir: options.sessionsDir,
566
- // The improver loop's review AGENT launches AMBIENT, never the identity.
567
- env: agentEnv,
568
- note,
569
- });
556
+ // REVIEW-LEG FAILURE handling (observation
557
+ // `tasker-review-edits-payload-caps-the-verdict-response`): the tasker produced
558
+ // a good candidate set but the review/improver leg can fail to return a
559
+ // parseable verdict (a generic parse failure, or the NAMED cap-truncation
560
+ // class). The pre-fix behaviour THREW here, crashing the run, LEAVING THE
561
+ // LOCK HELD, writing NO question sidecar, and DISCARDING the tasker's
562
+ // candidate tasks. Now: catch it, PERSIST the candidate tasks, and BOUNCE
563
+ // through the SAME surface the decomposition-unclear path uses (release the
564
+ // lock + a question sidecar). NEVER a silent approve — a parse failure is
565
+ // always a needs-attention route. A non-review throw (genuine wiring error)
566
+ // is re-thrown.
567
+ try {
568
+ loopDisposition = await runTaskReviewLoop({
569
+ slug,
570
+ cwd,
571
+ gate: options.reviewLoop,
572
+ // SCOPING FENCE (the requeue fix): the loop reviews/edits/flags ONLY the
573
+ // tasks THIS run produced (new-or-changed vs `before`), never the
574
+ // pre-existing staged tasks that share `work/tasks/backlog/`.
575
+ before,
576
+ taskerLoopMax: options.taskerLoopMax ?? 3,
577
+ executions: options.reviewExecutions,
578
+ taskerLoopModel: options.taskerLoopModel,
579
+ sessionsDir: options.sessionsDir,
580
+ // The improver loop's review AGENT launches AMBIENT, never the identity.
581
+ env: agentEnv,
582
+ note,
583
+ });
584
+ } catch (err) {
585
+ if (!(err instanceof ReviewParseError)) {
586
+ throw err;
587
+ }
588
+ const reviewErr = err as ReviewParseError;
589
+ // Reap any review-edits SCRATCH the failed pass left on disk so it is never
590
+ // swept into a later run's integrate commit.
591
+ try {
592
+ rmSync(join(cwd, REVIEW_EDITS_SCRATCH_DIR), {
593
+ recursive: true,
594
+ force: true,
595
+ });
596
+ } catch {
597
+ // Best-effort.
598
+ }
599
+ const candidatePaths = await persistTaskingCandidates(
600
+ cwd,
601
+ slug,
602
+ before,
603
+ arbiter,
604
+ env,
605
+ note,
606
+ );
607
+ const reason = reviewFailureReason(slug, reviewErr, candidatePaths);
608
+ const message = reviewFailureMessage(slug, reviewErr);
609
+ if (useLock) {
610
+ return await surfaceTaskingBlock({
611
+ slug,
612
+ cwd,
613
+ arbiter,
614
+ reason,
615
+ message,
616
+ lockedBlob,
617
+ release: lock.release,
618
+ mode: resolvedMode,
619
+ provider: options.providerInstance,
620
+ env,
621
+ note,
622
+ });
623
+ }
624
+ note(reason);
625
+ // Human, no-lock path: a clean park-for-human is a success terminal (exit 0).
626
+ return {exitCode: 0, outcome: 'needs-attention', slug, message};
627
+ }
570
628
  // DECOMPOSITION UNCLEAR: emit NO guessed tasks — route the held spec to
571
629
  // needs-attention with the questions as the reason. The lock release amends the
572
630
  // `spec:<slug>` unified lock `active → stuck` (the tasking needs-attention surface
@@ -1332,6 +1390,130 @@ function decompositionUnclearReason(slug: string, questions: string[]): string {
1332
1390
  return `${head}\n${body}`;
1333
1391
  }
1334
1392
 
1393
+ /**
1394
+ * The needs-attention REASON for a REVIEW-LEG FAILURE (a parse failure, or the
1395
+ * NAMED cap-truncation class — observation
1396
+ * `tasker-review-edits-payload-caps-the-verdict-response`). The tasker produced a
1397
+ * good candidate set but the review/improver leg could not return a parseable
1398
+ * verdict, so the run is parked (the lock RELEASED, a question sidecar written)
1399
+ * rather than the whole tasking discarded. Names the failure precisely so an
1400
+ * operator does not mis-read a cap-truncation as a model flake and retry blindly.
1401
+ * The candidate tasks the tasker produced are PERSISTED on the work branch (see
1402
+ * {@link persistTaskingCandidates}) so a human can recover them.
1403
+ */
1404
+ function reviewFailureReason(
1405
+ slug: string,
1406
+ err: ReviewParseError,
1407
+ candidatePaths: string[],
1408
+ ): string {
1409
+ const named =
1410
+ err instanceof ReviewOutputCappedError
1411
+ ? `The tasker review leg failed on '${slug}': ${err.message}. This is a ` +
1412
+ "STRUCTURAL cap-truncation (the review edits payload shared the model's " +
1413
+ 'capped output response with the verdict), NOT a model flake — do not ' +
1414
+ 'blindly retry; the run is parked for you to recover the candidate tasks ' +
1415
+ 'and re-task.'
1416
+ : `The tasker review leg failed on '${slug}': the review agent produced no ` +
1417
+ `parseable verdict (${err.message}). The run is parked for you to recover ` +
1418
+ 'the candidate tasks and re-task.';
1419
+ const tasks =
1420
+ candidatePaths.length > 0
1421
+ ? `\n\nThe tasker produced ${candidatePaths.length} candidate task(s) before the ` +
1422
+ 'review leg failed; they are saved on the work branch ' +
1423
+ `'${workBranchRef('spec', slug)}' (commit "chore(tasking): save candidate ` +
1424
+ `tasks") for recovery:\n${candidatePaths.map((p) => `- ${p}`).join('\n')}`
1425
+ : '\n\n(No candidate tasks were on disk when the review leg failed.)';
1426
+ return `${named}${tasks}`;
1427
+ }
1428
+
1429
+ /** The human-readable terminal MESSAGE for a review-leg failure (the result's `message`). */
1430
+ function reviewFailureMessage(slug: string, err: ReviewParseError): string {
1431
+ if (err instanceof ReviewOutputCappedError) {
1432
+ return (
1433
+ `Tasking '${slug}' parked: the review leg hit the model output cap ` +
1434
+ `(${err.outputTokens} tokens) and was truncated before emitting its ` +
1435
+ 'verdict. The candidate tasks were saved on the work branch; the lock was ' +
1436
+ 'released and a question surfaced.'
1437
+ );
1438
+ }
1439
+ return (
1440
+ `Tasking '${slug}' parked: the review leg produced no parseable verdict ` +
1441
+ `(${err.message}). The candidate tasks were saved on the work branch; the ` +
1442
+ 'lock was released and a question surfaced.'
1443
+ );
1444
+ }
1445
+
1446
+ /**
1447
+ * PERSIST the tasker's candidate tasks (priority: do not discard the tasker's work
1448
+ * on the review-failure path). Commits the new-or-changed `work/tasks/backlog/*.md`
1449
+ * (this run's own output vs `before`) to the work branch under a marker commit,
1450
+ * then pushes the branch to the arbiter BEST-EFFORT (a push failure is noted, not
1451
+ * fatal — the local branch commit still survives a worktree teardown and is
1452
+ * recoverable by `git checkout work/spec-<slug>`). Returns the repo-relative
1453
+ * candidate paths persisted (empty when there were none). Never throws — a git
1454
+ * failure is noted and the bounce proceeds (the lock is still released).
1455
+ */
1456
+ async function persistTaskingCandidates(
1457
+ cwd: string,
1458
+ slug: string,
1459
+ before: Map<string, string>,
1460
+ arbiter: string,
1461
+ env: NodeJS.ProcessEnv | undefined,
1462
+ note: (message: string) => void,
1463
+ ): Promise<string[]> {
1464
+ const candidatePaths = newOrChangedStagedTasks(cwd, before);
1465
+ if (candidatePaths.length === 0) {
1466
+ return [];
1467
+ }
1468
+ const branch = workBranchRef('spec', slug);
1469
+ try {
1470
+ await gitHard(['add', '--', ...candidatePaths], cwd, env);
1471
+ // Commit only if something is actually staged (a clean tree → nothing to save).
1472
+ const diffCached = await gitSoft(['diff', '--cached', '--quiet'], cwd, env);
1473
+ if (diffCached.status === 0) {
1474
+ return candidatePaths; // nothing new staged (already committed by a prior step).
1475
+ }
1476
+ await gitHard(
1477
+ [
1478
+ 'commit',
1479
+ '-q',
1480
+ '-m',
1481
+ `chore(tasking): save candidate tasks for '${slug}' (review leg failed; not landed)`,
1482
+ ],
1483
+ cwd,
1484
+ env,
1485
+ );
1486
+ } catch (err) {
1487
+ note(
1488
+ `Could not commit the candidate tasks to the work branch for recovery ` +
1489
+ `(best-effort; the bounce still proceeds): ${
1490
+ err instanceof Error ? err.message : String(err)
1491
+ }`,
1492
+ );
1493
+ return candidatePaths;
1494
+ }
1495
+ // Push the work branch best-effort so the candidate tasks survive a worktree
1496
+ // teardown on an isolated run (the branch ref survives locally too, but a push
1497
+ // makes them remote-recoverable). A push failure is noted, never fatal.
1498
+ try {
1499
+ const pushed = await gitSoft(['push', arbiter, branch], cwd, env);
1500
+ if (pushed.status !== 0) {
1501
+ note(
1502
+ `Could not push the candidate-tasks work branch '${branch}' to ${arbiter} ` +
1503
+ `(best-effort; the local commit still persists for recovery).`,
1504
+ );
1505
+ }
1506
+ } catch (err) {
1507
+ note(
1508
+ `Could not push the candidate-tasks work branch '${branch}' to ${arbiter} ` +
1509
+ `(best-effort; the local commit still persists for recovery): ${
1510
+ err instanceof Error ? err.message : String(err)
1511
+ }`,
1512
+ );
1513
+ }
1514
+ return candidatePaths;
1515
+ }
1516
+
1335
1517
  /**
1336
1518
  * Mark a candidate task file `needsAnswers: true` and record its open questions in
1337
1519
  * its body (the loop's uncertain-task routing outcome). The runner writes the
@@ -149,6 +149,10 @@ interface SessionLogRecord {
149
149
  message?: {
150
150
  role?: unknown;
151
151
  content?: unknown;
152
+ /** The Anthropic-API turn-termination reason (mirrored by pi into the session log). */
153
+ stop_reason?: unknown;
154
+ /** The turn's token usage (mirrored by pi). `output` OR `output_tokens` is the produced-token count. */
155
+ usage?: unknown;
152
156
  };
153
157
  }
154
158
 
@@ -286,10 +290,10 @@ function assistantContentText(content: unknown): string {
286
290
  /**
287
291
  * Extract the **last assistant message's text** from a pi session `.jsonl`
288
292
  * (task `harness-agent-output`) — the agent's final ANSWER, surfaced through
289
- * the harness seam as `LaunchResult.output`. It REUSES this module's session-log
290
- * shape walk (one parser, not two): it scans the `{type:"message",
291
- * message:{role:"assistant", content[]}}` records, takes the LAST one carrying
292
- * non-empty `text`, and returns its concatenated text.
293
+ * the harness seam as `LaunchResult.output`. A thin delegate to
294
+ * {@link lastAssistantTurn} (one walk, returning the `.text`); the turn reader
295
+ * ALSO carries the `stop_reason`/`usage` the pi adapter uses for the
296
+ * `LaunchResult.outputCapped` cap-truncation signal.
293
297
  *
294
298
  * Kept as a PURE `string → string | undefined` function on purpose: the pi
295
299
  * adapter reads the session file and passes the JSONL text in, but a future
@@ -307,7 +311,33 @@ function assistantContentText(content: unknown): string {
307
311
  * line in a just-closed log must not crash the reader.
308
312
  */
309
313
  export function lastAssistantText(jsonl: string): string | undefined {
310
- let last: string | undefined;
314
+ return lastAssistantTurn(jsonl).text;
315
+ }
316
+
317
+ /**
318
+ * The last assistant turn's TEXT + its Anthropic-API turn-termination signal
319
+ * (`stop_reason` + `usage.output`/`usage.output_tokens` token count). Reused by
320
+ * the pi adapter to populate BOTH `LaunchResult.output` (the `.text`) AND
321
+ * `LaunchResult.outputCapped` (the cap-truncation signal — see
322
+ * {@link isOutputCappedTurn}). One walk over the `.jsonl`, not two. `text` is the
323
+ * LAST assistant turn carrying non-empty text (a tool-only turn does not
324
+ * supersede it); `stopReason`/`outputTokens` come from THAT same last-text turn when
325
+ * the record carries them, else `undefined`. Returns an all-`undefined` object
326
+ * for an empty / assistant-text-less log.
327
+ */
328
+ export interface LastAssistantTurn {
329
+ /** The last assistant turn's concatenated `text` parts (its answer). */
330
+ text?: string;
331
+ /** The turn's `stop_reason` (raw — `null`, `'end_turn'`, `'max_tokens'`, …). */
332
+ stopReason?: string | null;
333
+ /** The turn's produced output-token count (`usage.output` OR `usage.output_tokens`). */
334
+ outputTokens?: number;
335
+ }
336
+
337
+ export function lastAssistantTurn(jsonl: string): LastAssistantTurn {
338
+ let lastText: string | undefined;
339
+ let lastStopReason: string | null | undefined = undefined;
340
+ let lastOutputTokens: number | undefined = undefined;
311
341
  for (const line of jsonl.split('\n')) {
312
342
  const trimmed = line.trim();
313
343
  if (trimmed === '') {
@@ -332,10 +362,56 @@ export function lastAssistantText(jsonl: string): string | undefined {
332
362
  }
333
363
  const text = assistantContentText(message.content);
334
364
  if (text !== '') {
335
- last = text; // a later text turn supersedes an earlier one.
365
+ lastText = text; // a later text turn supersedes an earlier one.
366
+ lastStopReason = readStopReason(message.stop_reason);
367
+ lastOutputTokens = readOutputTokens(message.usage);
336
368
  }
337
369
  }
338
- return last;
370
+ return {
371
+ text: lastText,
372
+ stopReason: lastStopReason,
373
+ outputTokens: lastOutputTokens,
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Is this assistant turn's signal an OUTPUT-CAP truncation — the turn did NOT
379
+ * end naturally? `stop_reason` `null`/`None`/`undefined` (the turn was cut off) OR
380
+ * `'max_tokens'` (the model hit its output-token cap), together with a positive
381
+ * produced-token count. The `null`/`None` form is what pi's session log records
382
+ * when the `--print` run is truncated before the turn closes (observation
383
+ * `tasker-review-edits-payload-caps-the-verdict-response`); `'max_tokens'` is the
384
+ * standard API cap signal. Both name the same structural cause: the verdict never
385
+ * finished.
386
+ */
387
+ export function isOutputCappedTurn(turn: LastAssistantTurn): boolean {
388
+ const cappedReason =
389
+ turn.stopReason === null ||
390
+ turn.stopReason === undefined ||
391
+ turn.stopReason === 'None' ||
392
+ turn.stopReason === 'max_tokens';
393
+ return cappedReason && (turn.outputTokens ?? 0) > 0;
394
+ }
395
+
396
+ /** Read `stop_reason` defensively as a string-or-null (pi may emit `None`/`null`). */
397
+ function readStopReason(raw: unknown): string | null | undefined {
398
+ if (raw === null) {
399
+ return null;
400
+ }
401
+ if (typeof raw === 'string') {
402
+ return raw;
403
+ }
404
+ return undefined;
405
+ }
406
+
407
+ /** Read the produced output-token count from `usage.output` OR `usage.output_tokens`. */
408
+ function readOutputTokens(raw: unknown): number | undefined {
409
+ if (typeof raw !== 'object' || raw === null) {
410
+ return undefined;
411
+ }
412
+ const usage = raw as Record<string, unknown>;
413
+ const value = usage.output ?? usage.output_tokens;
414
+ return typeof value === 'number' ? value : undefined;
339
415
  }
340
416
 
341
417
  export interface SessionTailerOptions {