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
package/src/integration-core.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
reviewRoundsExhaustedReason,
|
|
29
29
|
} from './review-gate.js';
|
|
30
30
|
import {type IntegrateResult, type ReviewProvider} from './integrator.js';
|
|
31
|
+
import {extractDecisionsBlock} from './agent-stop.js';
|
|
31
32
|
import {ledgerWrite} from './ledger-write.js';
|
|
32
33
|
import {selectProvider} from './github.js';
|
|
33
34
|
import type {IntegrationMode} from './config.js';
|
|
@@ -1104,6 +1105,20 @@ export async function performIntegration(
|
|
|
1104
1105
|
);
|
|
1105
1106
|
}
|
|
1106
1107
|
|
|
1108
|
+
// 2b. TRANSCRIBE the agent's `## Decisions` block into the DONE RECORD (the
|
|
1109
|
+
// builder's rationale channel, WORK-CONTRACT "Where a BUILDER's RATIONALE
|
|
1110
|
+
// lives"). The builder does NO git and MUST NOT edit the task body, and the
|
|
1111
|
+
// done-move is ours, so "record your rationale in the done record" is an
|
|
1112
|
+
// instruction it CANNOT obey directly. It emits a `## Decisions` block on the
|
|
1113
|
+
// one surface it owns (its final report, reaching us as `input.body`), and we
|
|
1114
|
+
// append it here: AFTER the move (so we write the record at its DONE path) and
|
|
1115
|
+
// BEFORE the `git add -A` below (so it rides the SAME atomic completion commit,
|
|
1116
|
+
// not a second one). Skipped for a TASKING transition (`lifecycle`), which
|
|
1117
|
+
// lands a spec: no done record, and no building agent.
|
|
1118
|
+
if (!lifecycle) {
|
|
1119
|
+
transcribeDecisionsIntoDoneRecord({cwd, slug, output: input.body, note});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1107
1122
|
// 3. Commit: git add -A (the agent's uncommitted work + the move) into ONE
|
|
1108
1123
|
// atomic commit. Nothing to commit is FATAL (no-op-is-fatal, like claim.sh).
|
|
1109
1124
|
await gitHard(['add', '-A'], cwd, env);
|
|
@@ -2530,6 +2545,68 @@ const CAPTURE_NOTE_DIRS = [
|
|
|
2530
2545
|
workFolderPrefix('findings'),
|
|
2531
2546
|
] as const;
|
|
2532
2547
|
|
|
2548
|
+
/**
|
|
2549
|
+
* TRANSCRIBE the build agent's `## Decisions` block into the DONE RECORD: the
|
|
2550
|
+
* runner half of the builder's rationale channel (WORK-CONTRACT.md, "Where a
|
|
2551
|
+
* BUILDER's RATIONALE lives").
|
|
2552
|
+
*
|
|
2553
|
+
* The builder MUST surface a non-obvious in-scope decision for ratification, but
|
|
2554
|
+
* every home the contract used to offer it was UNREACHABLE: it does no git, it must
|
|
2555
|
+
* not edit the task body (`CLAIM-PROTOCOL.md`), and the done-move, the completion
|
|
2556
|
+
* commit and the PR body are all the RUNNER's. So an acceptance criterion like
|
|
2557
|
+
* "rationale recorded in the done record" was structurally unsatisfiable, and the
|
|
2558
|
+
* rationale scattered into a JSDoc, a changeset, or an invented
|
|
2559
|
+
* `work/notes/observations/decisions-<slug>.md` note (a BACKWARD artifact in a LIVE,
|
|
2560
|
+
* FORWARD bucket, so it can never be discharged). Reviewers then re-raised the
|
|
2561
|
+
* identical "no Decisions block in the done record" finding on task after task.
|
|
2562
|
+
*
|
|
2563
|
+
* The fix is structural, not exhortative: the builder emits the block on the ONE
|
|
2564
|
+
* surface it owns (its final report, which reaches us as `input.body`), and the
|
|
2565
|
+
* runner, already the owner of the done-move, appends it here. Properties that
|
|
2566
|
+
* matter:
|
|
2567
|
+
*
|
|
2568
|
+
* - **Same commit.** The caller invokes this AFTER the `git mv` (so the record is
|
|
2569
|
+
* written at its `tasks/done/` path) and BEFORE `git add -A` (so it rides the
|
|
2570
|
+
* ONE atomic completion commit, never a second, dangling one).
|
|
2571
|
+
* - **Verbatim.** The agent's prose is appended unaltered under a `## Decisions`
|
|
2572
|
+
* heading; we add structure, never interpretation.
|
|
2573
|
+
* - **Idempotent.** A record that ALREADY carries a `## Decisions` section is left
|
|
2574
|
+
* untouched, so a `requeue` continue, or a re-run over an already-done-moved
|
|
2575
|
+
* branch (`source: 'done'`), cannot append the same block twice.
|
|
2576
|
+
* - **Best-effort.** No block, no record on disk, or an unreadable/unwritable file
|
|
2577
|
+
* is a silent no-op: a rationale note must NEVER fail an otherwise-green
|
|
2578
|
+
* completion (the same stance as {@link reportScoopedNotes}).
|
|
2579
|
+
*/
|
|
2580
|
+
function transcribeDecisionsIntoDoneRecord(params: {
|
|
2581
|
+
cwd: string;
|
|
2582
|
+
slug: string;
|
|
2583
|
+
output: string | undefined;
|
|
2584
|
+
note: (message: string) => void;
|
|
2585
|
+
}): void {
|
|
2586
|
+
const {cwd, slug, output, note} = params;
|
|
2587
|
+
const decisions = extractDecisionsBlock(output);
|
|
2588
|
+
if (decisions === undefined) {
|
|
2589
|
+
return; // the common case: the agent recorded no in-scope decision.
|
|
2590
|
+
}
|
|
2591
|
+
const path = workItemPath(cwd, 'done', slug);
|
|
2592
|
+
if (!existsSync(path)) {
|
|
2593
|
+
return; // no done record to annotate (a lifecycle/degenerate shape).
|
|
2594
|
+
}
|
|
2595
|
+
try {
|
|
2596
|
+
const body = readFileSync(path, 'utf8');
|
|
2597
|
+
if (/^##\s+Decisions\s*$/m.test(body)) {
|
|
2598
|
+
return; // already transcribed (a continue/re-run): never duplicate.
|
|
2599
|
+
}
|
|
2600
|
+
const separator = body.endsWith('\n') ? '\n' : '\n\n';
|
|
2601
|
+
writeFileSync(path, `${body}${separator}## Decisions\n\n${decisions}\n`);
|
|
2602
|
+
note(
|
|
2603
|
+
`Recorded the agent's "## Decisions" block in ${workItemRel('done', `${slug}.md`)}.`,
|
|
2604
|
+
);
|
|
2605
|
+
} catch {
|
|
2606
|
+
// Best-effort: never fail a green completion over a rationale note.
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2533
2610
|
/**
|
|
2534
2611
|
* SCOOP + REPORT the agent-authored CAPTURED NOTES this run's atomic commit is
|
|
2535
2612
|
* landing (task `runner-scoops-captured-notes`). A rung's agent writes
|
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
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
527
|
-
* `sessionFile` — the agent's final ANSWER
|
|
528
|
-
* (
|
|
529
|
-
*
|
|
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
|
|
532
|
-
* not two). An absent file (pi never wrote it) yields `
|
|
533
|
-
*
|
|
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
|
|
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
|
|
561
|
+
return {}; // no session log on disk — no answer to surface.
|
|
552
562
|
}
|
|
553
|
-
|
|
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
|
package/src/repo-mirror.ts
CHANGED
|
@@ -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).
|
package/src/review-gate.ts
CHANGED
|
@@ -126,12 +126,18 @@ export function buildReviewPrompt(slug: string): string {
|
|
|
126
126
|
`design choice the agent made on its own while building: a CROSS-TASK`,
|
|
127
127
|
`INTERACTION (a choice affecting another command/flag/task's behaviour), a new`,
|
|
128
128
|
`ERROR/REFUSAL, or a user-visible DEFAULT. The agent SHOULD have recorded these`,
|
|
129
|
-
`in a "## Decisions" block in its
|
|
129
|
+
`in a "## Decisions" block in its report, which the runner transcribes into the`,
|
|
130
|
+
`done record (work/tasks/done/<slug>.md) and which also rides the PR description.`,
|
|
131
|
+
`START from that block (ratify`,
|
|
130
132
|
`each entry) AND hunt for any it MISSED. Flag EACH such decision as a finding`,
|
|
131
|
-
`for the human to RATIFY
|
|
133
|
+
`for the human to RATIFY: "non-blocking" by DEFAULT (the build proceeds; the`,
|
|
132
134
|
`human ratifies or reverses), escalating to "blocking" ONLY if the decision looks`,
|
|
133
135
|
`WRONG or is genuinely load-bearing-and-hard-to-reverse. An un-recorded in-scope`,
|
|
134
|
-
`decision is NOT itself a block
|
|
136
|
+
`decision is NOT itself a block, it is a ratification finding. Do NOT raise a`,
|
|
137
|
+
`finding about WHERE the rationale was written (the done record, the commit body`,
|
|
138
|
+
`and the PR body are the RUNNER's to author, never the builder's): review the`,
|
|
139
|
+
`DECISION, not the filing. If rationale is missing entirely, that is the`,
|
|
140
|
+
`ratification finding above.`,
|
|
135
141
|
``,
|
|
136
142
|
`ALSO CHECK CONCEPTUAL COHERENCE — does this diff fit the system's existing`,
|
|
137
143
|
`LANGUAGE? For each concept / flag / config key / status / verb it introduces or`,
|
package/src/review-verdict.ts
CHANGED
|
@@ -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
|
-
/**
|
|
44
|
-
|
|
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
|
|
250
|
-
|
|
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", "
|
|
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"]} ],',
|