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.
- package/dist/harness.d.ts +15 -0
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js.map +1 -1
- package/dist/integration-core.d.ts.map +1 -1
- package/dist/integration-core.js +69 -0
- package/dist/integration-core.js.map +1 -1
- package/dist/item-lock.d.ts.map +1 -1
- package/dist/item-lock.js +89 -15
- package/dist/item-lock.js.map +1 -1
- package/dist/pi-harness.d.ts.map +1 -1
- package/dist/pi-harness.js +26 -15
- package/dist/pi-harness.js.map +1 -1
- package/dist/protocol/CLAIM-PROTOCOL.md +22 -13
- package/dist/protocol/REVIEW-PROTOCOL.md +1 -1
- package/dist/protocol/TASKING-PROTOCOL.md +1 -0
- package/dist/protocol/WORK-CONTRACT.md +6 -0
- package/dist/protocol/task-template.md +1 -1
- package/dist/repo-mirror.d.ts.map +1 -1
- package/dist/repo-mirror.js +17 -0
- package/dist/repo-mirror.js.map +1 -1
- package/dist/review-gate.d.ts.map +1 -1
- package/dist/review-gate.js +9 -3
- package/dist/review-gate.js.map +1 -1
- package/dist/review-verdict.d.ts +46 -2
- package/dist/review-verdict.d.ts.map +1 -1
- package/dist/review-verdict.js +49 -3
- package/dist/review-verdict.js.map +1 -1
- package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +22 -13
- package/dist/skills/setup/protocol/REVIEW-PROTOCOL.md +1 -1
- package/dist/skills/setup/protocol/TASKING-PROTOCOL.md +1 -0
- package/dist/skills/setup/protocol/WORK-CONTRACT.md +6 -0
- package/dist/skills/setup/protocol/task-template.md +1 -1
- package/dist/tasker-review-loop.d.ts +13 -0
- package/dist/tasker-review-loop.d.ts.map +1 -1
- package/dist/tasker-review-loop.js +120 -8
- package/dist/tasker-review-loop.js.map +1 -1
- package/dist/tasking.d.ts.map +1 -1
- package/dist/tasking.js +161 -17
- package/dist/tasking.js.map +1 -1
- package/dist/watch-session.d.ts +35 -4
- package/dist/watch-session.d.ts.map +1 -1
- package/dist/watch-session.js +54 -7
- package/dist/watch-session.js.map +1 -1
- package/package.json +1 -1
- package/src/harness.ts +15 -0
- package/src/integration-core.ts +77 -0
- package/src/item-lock.ts +93 -19
- package/src/pi-harness.ts +29 -15
- package/src/repo-mirror.ts +22 -0
- package/src/review-gate.ts +9 -3
- package/src/review-verdict.ts +73 -5
- package/src/tasker-review-loop.ts +129 -7
- package/src/tasking.ts +198 -16
- 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,
|
|
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
|
-
|
|
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
|
|
638
|
-
`
|
|
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" —
|
|
646
|
-
`
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
package/src/watch-session.ts
CHANGED
|
@@ -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`.
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|