Skip to content

Commit eb34a60

Browse files
authored
feat(pi-subagent): add opinionated delegated development (#169)
1 parent 0e60247 commit eb34a60

26 files changed

Lines changed: 606 additions & 134 deletions

docs/adr/016-pi-subagent-architecture.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Delegation needs portable user-owned capabilities, shared model policy, bounded
1414
- Model and thinking routes remain separate from Role capability. They resolve through `@henryqw/pi-task-models`; fallback is attempted only before launch, and a started child is never retried.
1515
- Each `delegate_task` runs one bounded task in an ephemeral `pi --mode json -p --no-session` child with abort propagation, configurable idle/hard deadlines, and bounded concurrency. Main owns decomposition, integration, and cross-cutting decisions.
1616
- Main-visible streaming updates, results, and errors are capped at 50 KiB. Unused JSON event payloads are discarded before buffering.
17+
- The bundled delegated-development Skill is Main-side policy only: it fails before delegation without a committed Git `HEAD`, reviews only an exact verified base-to-tip committed patch with referenced files, and merges only the reviewed commit. It does not change the generic non-Git/unborn-`HEAD` fallback.
1718
- Generic managed Herdr hosting is a separate library surface for workers that must survive orchestrator pauses or restarts. Callers retain domain prompts, state, and lifecycle decisions.
1819
- The TUI widget projects active ephemeral children and briefly shows terminal status; it owns no lifecycle state.
1920

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/pi-auto-dag/CONTEXT.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ System-owned identity binding reviewer verdict to run, review kind, Local Issue,
3737
_Avoid_: Reviewer correlation field, model token
3838

3939
**Review Packet**:
40-
Canonical fresh reviewer handoff containing delivery context, Local Issue, worktree, base, and Required Gate. Existing reviewers receive only changed gate, findings, or resolution data.
41-
_Avoid_: Reviewer prompt variant, echoed orchestration state
40+
Canonical fresh reviewer handoff containing delivery context, Local Issue, worktree, base, Required Gate, and a read-only exact binary patch artifact. The artifact is atomically persisted privately under the run directory after base/commit verification and identifies its path, base, commit, byte count, SHA-256, and deterministic child-branch or explicit integration-head context; its patch body is never in the prompt. Existing reviewers receive only changed gate, findings, or resolution data.
41+
_Avoid_: Reviewer prompt variant, inline diff, echoed orchestration state
4242

4343
**Local Issue**:
4444
Immutable work definition identified by a stable string within a Delivery Graph.

packages/pi-auto-dag/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ Pass the exact graph object to `auto_dag_execute`. It is immutable for the durat
102102

103103
1. The caller normalizes an approved plan into the exact graph and calls `auto_dag_execute`. Planning is externally owned (for example by `/ship`).
104104
2. Auto DAG validates structure, then shows one TUI confirmation bound to the SHA-256 of the exact graph. It display-escapes every exact `testing` command that will later run through `sh -c`, alongside both fixed Roles. Execution never starts without this confirmation.
105-
3. Ready tasks share one Git base at the frozen HEAD. Each gets a child worktree, the fixed implementer Role, one required commit, and the exact `testing` command through `sh -c`. The fixed reviewer Role sees the commit and gate evidence; approval requires system-owned exit code `0`.
105+
3. Ready tasks share one Git base at the frozen HEAD. Each gets a child worktree, the fixed implementer Role, one required commit, and the exact `testing` command through `sh -c`. A fresh fixed reviewer Role receives the commit, gate evidence, and a read-only run-private exact binary patch reference (path, base, commit, size, SHA-256); the patch body is never in its prompt. Approval requires system-owned exit code `0`.
106106
4. After each wave passes, commits are cherry-picked by Local Issue ID and the next wave starts. A cherry-pick conflict returns that task to its workers.
107107
5. After every implementation task, Final Check runs on clean integration `HEAD`; product failure repairs through the owning Local Issue.
108108
6. When all gates pass, Auto DAG pushes one branch and opens exactly one PR. Ownership ends there; post-PR sweep and CI status are caller-owned (for example `/ship`).

packages/pi-auto-dag/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@henryqw/pi-auto-dag",
3-
"version": "8.0.3",
3+
"version": "8.0.4",
44
"description": "Execute Delivery Graphs through Pi and Herdr.",
55
"keywords": [
66
"pi-package",
@@ -31,7 +31,7 @@
3131
"@earendil-works/pi-coding-agent": "^0.84.3"
3232
},
3333
"dependencies": {
34-
"@henryqw/pi-subagent": "^4.0.0",
34+
"@henryqw/pi-subagent": "^4.1.0",
3535
"proper-lockfile": "^4.1.2",
3636
"typebox": "1.3.15"
3737
},

packages/pi-auto-dag/src/final-repair.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { assertAttachedBranch, deleteExpectedBranch, ensureChildWorktree, findAp
1515
import { assertRunBoundary } from "./intake.ts";
1616
import type { LocalIssue, ProjectConfig, RunState, RunTaskState, SubmitReviewEnvelope, WorkerEnvelope } from "./model.ts";
1717
import { actionTicketPath, ensureActionTicket, type ReviewKind } from "./review-ticket.ts";
18-
import { reviewPrompt, type ReviewPromptMode } from "./review.ts";
18+
import { persistReviewPatch, reviewPrompt, reviewPromptMode, type ReviewPromptMode } from "./review.ts";
1919
import { issueById, replaceTask, task, type Uuid } from "./state.ts";
2020
import {
2121
clearLifecycleCleanupBlock,
@@ -396,18 +396,31 @@ async function ensureFinalRepairReviewer(
396396
});
397397
current = task(state, issue.id);
398398
const needsInstruction = Boolean(current.reviewer_instruction_pending) || mode === "review" || started !== "existing";
399-
const promptMode: ReviewPromptMode = !needsInstruction
400-
? "resend"
401-
: started !== "existing" || current.review_rounds === 1
402-
? "full"
403-
: "update";
399+
const base = nonEmptyString(current.repair_base, "final repair base");
400+
const promptMode: ReviewPromptMode = reviewPromptMode(
401+
needsInstruction,
402+
started,
403+
current.review_packet_base,
404+
current.review_packet_commit,
405+
base,
406+
commit,
407+
);
404408
await ensureActionTicket(
405409
actionTicketPath(state.main_worktree, state.run_id, issue.id, "lifecycle", "reviewer"),
406410
{ attempt: current.attempts, review_round: positiveInteger(current.review_rounds, "final-gate repair review round"), role: "reviewer", review_id: lifecycleReviewId(state, issue, current, "final_repair") },
407411
state.main_worktree,
408412
state.run_id,
409413
options.uuid,
410414
);
415+
const patch = promptMode === "full" ? await persistReviewPatch({
416+
runner: options.runner,
417+
mainWorktree: state.main_worktree,
418+
runId: state.run_id,
419+
worktree: nonEmptyString(current.worktree, "final repair worktree"),
420+
base,
421+
commit,
422+
context: { type: "child_branch", branch: nonEmptyString(current.branch, "final repair branch") },
423+
}) : undefined;
411424
const amendments = gateCommandAmendments(state, issue.id);
412425
await promptManagedSubagent(workerHost(state), agent, reviewPrompt({
413426
kind: "final_repair",
@@ -416,15 +429,20 @@ async function ensureFinalRepairReviewer(
416429
worktree: nonEmptyString(current.worktree, "final repair worktree"),
417430
base: nonEmptyString(current.repair_base, "final repair base"),
418431
gate: requiredTaskGate(current, commit, "Final-gate repair"),
432+
patch,
419433
prior_findings: current.review_findings,
420434
resolution: state.resolutions[owner.id],
421435
context: {
422436
owner_issue: workerIssueContext(owner, false),
423437
...(amendments.length ? { gate_command_amendments: amendments } : {}),
424438
},
425439
}, promptMode), workerHostOptions(options));
426-
if (needsInstruction) {
427-
state = await save(replaceTask(state, issue.id, { ...task(state, issue.id), reviewer_instruction_pending: undefined }), options);
440+
if (needsInstruction || promptMode === "full") {
441+
state = await save(replaceTask(state, issue.id, {
442+
...task(state, issue.id),
443+
reviewer_instruction_pending: undefined,
444+
...(promptMode === "full" ? { review_packet_base: base, review_packet_commit: commit } : {}),
445+
}), options);
428446
}
429447
return state;
430448
}
@@ -538,10 +556,14 @@ async function ensureRepairWorktree(state: RunState, issue: LocalIssue, options:
538556

539557
async function verifyRepairCommit(state: RunState, issue: LocalIssue, commit: string, options: FinalRepairOptions): Promise<string> {
540558
const current = task(state, issue.id);
559+
const branch = nonEmptyString(current.branch, "final-gate repair branch");
560+
if (branch !== repairBranch(state, repairOwner(state, current).id, positiveInteger(current.repair_attempt, "final-gate repair attempt"))) {
561+
throw new Error(`Final-gate repair branch is not its deterministic child branch: ${branch}`);
562+
}
541563
return await verifyOneCommit(
542564
state,
543565
nonEmptyString(current.worktree, "final-gate repair worktree"),
544-
nonEmptyString(current.branch, "final-gate repair branch"),
566+
branch,
545567
nonEmptyString(current.repair_base, "final-gate repair base"),
546568
commit,
547569
"Final-gate repair",

packages/pi-auto-dag/src/implementation-workers.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { assertRunBoundary } from "./intake.ts";
1515
import { assertAttachedBranch, ensureChildWorktree, verifySingleCommit } from "./git.ts";
1616
import type { LocalIssue, ProjectConfig, RunState, RunTaskState } from "./model.ts";
1717
import { actionTicketPath, ensureActionTicket, reviewId } from "./review-ticket.ts";
18-
import { recordGateExecution, reviewPrompt as reviewWorkerPrompt, type ReviewPromptMode } from "./review.ts";
18+
import { persistReviewPatch, recordGateExecution, reviewPrompt as reviewWorkerPrompt, reviewPromptMode, type ReviewPatchReference, type ReviewPromptMode } from "./review.ts";
1919
import { replaceTask, task, type Uuid } from "./state.ts";
2020
import { hasReviewFindings, lifecycleWorkerLaunch, saveRunState as save } from "./worker-protocol.ts";
2121
import { workerAgentName, workerDeliveryContext, workerHost, workerHostOptions, workerIssueContext, type RoleLaunchResolver, type WorkerLaunch, type WorkerRole } from "./worker.ts";
@@ -226,24 +226,38 @@ export async function ensureReviewer(
226226
state = await save(replaceTask(state, issue.id, { ...current, reviewer_instruction_pending: true }), options);
227227
current = task(state, issue.id);
228228
}
229-
const promptMode: ReviewPromptMode = !needsInstruction
230-
? "resend"
231-
: started !== "existing" || current.review_rounds === 1
232-
? "full"
233-
: "update";
229+
const base = nonEmptyString(current.wave_base, `Run Task ${issue.id} wave_base`);
230+
const promptMode: ReviewPromptMode = reviewPromptMode(
231+
needsInstruction,
232+
started,
233+
current.review_packet_base,
234+
current.review_packet_commit,
235+
base,
236+
commit,
237+
);
234238
await ensureActionTicket(
235239
actionTicketPath(state.main_worktree, state.run_id, issue.id, "implementation", "reviewer"),
236240
{ attempt: current.attempts, review_round: positiveInteger(current.review_rounds, `Run Task ${issue.id} review round`), role: "reviewer", review_id: taskReviewId(state, issue.id, current) },
237241
state.main_worktree,
238242
state.run_id,
239243
options.uuid,
240244
);
241-
await promptManagedSubagent(workerHost(state), agent, reviewerPrompt(state, issue, current, promptMode), workerHostOptions(options));
242-
if (task(state, issue.id).reviewer_instruction_pending || task(state, issue.id).resolution_pending) {
245+
const patch = promptMode === "full" ? await persistReviewPatch({
246+
runner: options.runner,
247+
mainWorktree: state.main_worktree,
248+
runId: state.run_id,
249+
worktree: nonEmptyString(current.worktree, `Run Task ${issue.id} worktree`),
250+
base,
251+
commit,
252+
context: { type: "child_branch", branch: nonEmptyString(current.branch, `Run Task ${issue.id} branch`) },
253+
}) : undefined;
254+
await promptManagedSubagent(workerHost(state), agent, reviewerPrompt(state, issue, current, promptMode, patch), workerHostOptions(options));
255+
if (task(state, issue.id).reviewer_instruction_pending || task(state, issue.id).resolution_pending || promptMode === "full") {
243256
state = await save(replaceTask(state, issue.id, {
244257
...task(state, issue.id),
245258
reviewer_instruction_pending: undefined,
246259
resolution_pending: undefined,
260+
...(promptMode === "full" ? { review_packet_base: base, review_packet_commit: commit } : {}),
247261
}), options);
248262
}
249263
return state;
@@ -359,6 +373,7 @@ function reviewerPrompt(
359373
issue: LocalIssue,
360374
current: RunTaskState,
361375
mode: ReviewPromptMode,
376+
patch?: ReviewPatchReference,
362377
): Record<string, unknown> {
363378
const amendments = gateCommandAmendments(state, issue.id);
364379
return reviewWorkerPrompt({
@@ -368,6 +383,7 @@ function reviewerPrompt(
368383
worktree: nonEmptyString(current.worktree, `Run Task ${issue.id} worktree`),
369384
base: nonEmptyString(current.wave_base, `Run Task ${issue.id} wave_base`),
370385
gate: requiredTaskGate(current, nonEmptyString(current.commit, `Run Task ${issue.id} review commit`), `Run Task ${issue.id}`),
386+
patch,
371387
prior_findings: current.review_findings,
372388
resolution: state.resolutions[issue.id],
373389
...(amendments.length ? { context: { gate_command_amendments: amendments } } : {}),

packages/pi-auto-dag/src/model.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ export interface RunTaskState {
120120
review_exit_code?: number;
121121
review_stdout?: GateOutputEvidence;
122122
review_stderr?: GateOutputEvidence;
123+
/** Base and commit last delivered to the reviewer in a full packet. */
124+
review_packet_base?: string;
125+
review_packet_commit?: string;
123126
/** Failed Required Gate evidence invalidated by explicit infrastructure retry. */
124127
required_gate_invalidations?: RequiredGateInvalidation[];
125128
review_findings?: string[];

packages/pi-auto-dag/src/pr-lifecycle.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { assertRunBoundary } from "./intake.ts";
88
import type { LocalIssue, ProjectConfig, PullRequestIdentity, RunState, RunTaskState, SubmitReviewEnvelope, WorkerEnvelope } from "./model.ts";
99
import { assertSamePullRequest, parsePullRequest, viewOpenPullRequest } from "./pull-request.ts";
1010
import { actionTicketPath, ensureActionTicket, type ReviewKind } from "./review-ticket.ts";
11-
import { reviewPrompt, type ReviewPromptMode } from "./review.ts";
11+
import { persistReviewPatch, reviewPrompt, reviewPromptMode, type ReviewPromptMode } from "./review.ts";
1212
import { replaceTask, task, type Uuid } from "./state.ts";
1313
import {
1414
finalCheck,
@@ -104,6 +104,7 @@ async function ensureFinalReviewer(
104104
let current = task(state, issue.id);
105105
const commit = nonEmptyString(current.commit, "final-check commit");
106106
if (commit !== state.integration_head) throw new Error("Final-check commit does not match the integration HEAD");
107+
await commandOutput(options.runner, "git", ["merge-base", "--is-ancestor", state.source_commit, commit], state.main_worktree);
107108
state = await ensureFinalGate(state, issue, commit, config.required_gate_timeout_ms, options);
108109
current = task(state, issue.id);
109110
const gate = requiredTaskGate(current, commit, "Final check");
@@ -138,18 +139,31 @@ async function ensureFinalReviewer(
138139
});
139140
current = task(state, issue.id);
140141
const needsInstruction = Boolean(current.reviewer_instruction_pending) || mode === "review" || started !== "existing";
141-
const promptMode: ReviewPromptMode = !needsInstruction
142-
? "resend"
143-
: started !== "existing" || current.review_rounds === 1
144-
? "full"
145-
: "update";
142+
const base = state.source_commit;
143+
const promptMode: ReviewPromptMode = reviewPromptMode(
144+
needsInstruction,
145+
started,
146+
current.review_packet_base,
147+
current.review_packet_commit,
148+
base,
149+
commit,
150+
);
146151
await ensureActionTicket(
147152
actionTicketPath(state.main_worktree, state.run_id, issue.id, "lifecycle", "reviewer"),
148153
{ attempt: current.attempts, review_round: positiveInteger(current.review_rounds, "final-check review round"), role: "reviewer", review_id: lifecycleReviewId(state, issue, current, "final_check") },
149154
state.main_worktree,
150155
state.run_id,
151156
options.uuid,
152157
);
158+
const patch = promptMode === "full" ? await persistReviewPatch({
159+
runner: options.runner,
160+
mainWorktree: state.main_worktree,
161+
runId: state.run_id,
162+
worktree: state.main_worktree,
163+
base,
164+
commit,
165+
context: { type: "integration_head" },
166+
}) : undefined;
153167
const amendments = gateCommandAmendments(state, issue.id);
154168
await promptManagedSubagent(workerHost(state), agent, reviewPrompt({
155169
kind: "final_check",
@@ -158,10 +172,15 @@ async function ensureFinalReviewer(
158172
worktree: state.main_worktree,
159173
base: state.source_commit,
160174
gate: requiredTaskGate(current, commit, "Final check"),
175+
patch,
161176
...(amendments.length ? { context: { gate_command_amendments: amendments } } : {}),
162177
}, promptMode), workerHostOptions(options));
163-
if (needsInstruction) {
164-
state = await save(replaceTask(state, issue.id, { ...task(state, issue.id), reviewer_instruction_pending: undefined }), options);
178+
if (needsInstruction || promptMode === "full") {
179+
state = await save(replaceTask(state, issue.id, {
180+
...task(state, issue.id),
181+
reviewer_instruction_pending: undefined,
182+
...(promptMode === "full" ? { review_packet_base: base, review_packet_commit: commit } : {}),
183+
}), options);
165184
}
166185
return state;
167186
}

0 commit comments

Comments
 (0)