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/dist/harness.d.ts +15 -0
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.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/REVIEW-PROTOCOL.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-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/REVIEW-PROTOCOL.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/item-lock.ts +93 -19
- package/src/pi-harness.ts +29 -15
- package/src/repo-mirror.ts +22 -0
- 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
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 {
|