@henryqw/pi-subagent 23.0.1 → 24.0.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/CONTEXT.md +1 -1
- package/README.md +6 -4
- package/dist/herdr-runtime.d.ts +1 -0
- package/dist/herdr-runtime.js +50 -4
- package/dist/index.d.ts +5 -6
- package/dist/index.js +37 -59
- package/dist/launch-runtime.d.ts +0 -2
- package/dist/launch-runtime.js +2 -9
- package/dist/mcp-role.d.ts +0 -1
- package/dist/mcp-role.js +0 -11
- package/dist/runner.d.ts +1 -0
- package/dist/runner.js +152 -53
- package/dist/schema.d.ts +9 -8
- package/dist/schema.js +6 -6
- package/dist/worktree.d.ts +0 -2
- package/dist/worktree.js +0 -4
- package/docs/orchestration.md +4 -2
- package/extensions/admission.ts +23 -31
- package/extensions/config.ts +2 -8
- package/extensions/isolated.ts +1 -2
- package/extensions/result-transport.ts +15 -32
- package/extensions/role-mcp.ts +3 -7
- package/extensions/role-tools.ts +2 -8
- package/extensions/subagent-command.ts +5 -5
- package/extensions/subagent.ts +14 -25
- package/extensions/workflow.ts +0 -1
- package/package.json +1 -1
- package/skills/pi-subagent/SKILL.md +2 -2
- package/dist/working-evidence.d.ts +0 -25
- package/dist/working-evidence.js +0 -261
|
@@ -5,31 +5,21 @@ const EVIDENCE_PREVIEW_CODE_POINTS = 256;
|
|
|
5
5
|
|
|
6
6
|
export type WorkflowTransportStatus = "pending" | "running" | "succeeded" | "rejected" | "skipped";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/** `assistantOutput` is read for running/succeeded entries and `failure` for rejected ones. */
|
|
9
|
+
export type WorkflowTransportEntry = {
|
|
9
10
|
id: WorkflowEntry["id"];
|
|
10
11
|
index: WorkflowEntry["index"];
|
|
11
12
|
name: WorkflowEntry["delegation"]["name"];
|
|
12
13
|
role: WorkflowEntry["delegation"]["role"];
|
|
13
14
|
model?: string;
|
|
14
15
|
thinkingLevel?: string;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export type WorkflowTransportEntry =
|
|
18
|
-
| TransportEntryBase & { status: "pending" | "skipped"; assistantOutput?: never; failure?: never }
|
|
19
|
-
| TransportEntryBase & { status: "running" | "succeeded"; assistantOutput: string; failure?: never }
|
|
20
|
-
| TransportEntryBase & { status: "rejected"; assistantOutput?: never; failure: string };
|
|
21
|
-
|
|
22
|
-
export type WorkflowTransportEntryDetails = {
|
|
23
|
-
id: string;
|
|
24
|
-
index: number;
|
|
25
|
-
name: string;
|
|
26
|
-
role: string;
|
|
27
16
|
status: WorkflowTransportStatus;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
thinkingLevel?: string;
|
|
17
|
+
assistantOutput?: string;
|
|
18
|
+
failure?: string;
|
|
31
19
|
};
|
|
32
20
|
|
|
21
|
+
export type WorkflowTransportEntryDetails = Omit<WorkflowTransportEntry, "assistantOutput" | "failure"> & { summary?: string };
|
|
22
|
+
|
|
33
23
|
export type WorkflowTransportDetails = {
|
|
34
24
|
mode: WorkflowMode;
|
|
35
25
|
entries: WorkflowTransportEntryDetails[];
|
|
@@ -61,15 +51,12 @@ function workflowTitle(mode: WorkflowMode): string {
|
|
|
61
51
|
}
|
|
62
52
|
|
|
63
53
|
function statusCounts(entries: readonly WorkflowTransportEntry[]): string[] {
|
|
64
|
-
const count = (
|
|
65
|
-
return
|
|
66
|
-
[
|
|
67
|
-
[
|
|
68
|
-
|
|
69
|
-
]
|
|
70
|
-
const total = count([...statuses]);
|
|
71
|
-
return total ? [`${total} ${word}`] : [];
|
|
72
|
-
});
|
|
54
|
+
const count = (status: WorkflowTransportStatus) => entries.filter((entry) => entry.status === status).length;
|
|
55
|
+
return [
|
|
56
|
+
...(count("rejected") ? [`${count("rejected")} failed`] : []),
|
|
57
|
+
...(count("succeeded") ? [`${count("succeeded")} completed`] : []),
|
|
58
|
+
...(count("skipped") ? [`${count("skipped")} skipped`] : []),
|
|
59
|
+
];
|
|
73
60
|
}
|
|
74
61
|
|
|
75
62
|
export function displaySummary(text: string): string {
|
|
@@ -80,17 +67,13 @@ export function displaySummary(text: string): string {
|
|
|
80
67
|
|
|
81
68
|
export type WorkflowEntryStatusPresentation = { glyph: string; fallback: string };
|
|
82
69
|
|
|
83
|
-
const ENTRY_STATUS_PRESENTATION = {
|
|
70
|
+
export const ENTRY_STATUS_PRESENTATION = {
|
|
84
71
|
pending: { glyph: "○", fallback: "queued" },
|
|
85
72
|
running: { glyph: "◌", fallback: "working" },
|
|
86
73
|
succeeded: { glyph: "✓", fallback: "completed" },
|
|
87
74
|
rejected: { glyph: "✗", fallback: "failed" },
|
|
88
75
|
skipped: { glyph: "–", fallback: "skipped" },
|
|
89
|
-
} as const satisfies Record<
|
|
90
|
-
|
|
91
|
-
export function presentWorkflowEntryStatus(status: WorkflowTransportEntryDetails["status"]): WorkflowEntryStatusPresentation {
|
|
92
|
-
return ENTRY_STATUS_PRESENTATION[status];
|
|
93
|
-
}
|
|
76
|
+
} as const satisfies Record<WorkflowTransportStatus, WorkflowEntryStatusPresentation>;
|
|
94
77
|
|
|
95
78
|
function sourceFor(entry: WorkflowTransportEntry): string | undefined {
|
|
96
79
|
if (entry.status === "rejected") return entry.failure;
|
|
@@ -122,7 +105,7 @@ export function formatWorkflowResult(mode: WorkflowMode, entries: readonly Workf
|
|
|
122
105
|
const lines = [
|
|
123
106
|
[`${workflowTitle(mode)} ${failed ? "failed" : "completed"}`, ...statusCounts(ordered)].join(" · "),
|
|
124
107
|
...positioned.map(({ entry, position }) => {
|
|
125
|
-
const { glyph, fallback } =
|
|
108
|
+
const { glyph, fallback } = ENTRY_STATUS_PRESENTATION[entry.status];
|
|
126
109
|
const summary = displaySummary(sourceFor(entry) ?? "") || fallback;
|
|
127
110
|
return `${glyph} [${position}/${ordered.length}] ${entry.name} · ${entry.role} — ${summary}`;
|
|
128
111
|
}),
|
package/extensions/role-mcp.ts
CHANGED
|
@@ -2,19 +2,15 @@ import { join } from "node:path";
|
|
|
2
2
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { createMcpAdapter } from "pi-mcp-adapter";
|
|
4
4
|
import { loadMcpConfig } from "pi-mcp-adapter/config";
|
|
5
|
-
import {
|
|
6
|
-
parseRoleMcpAllowlist,
|
|
7
|
-
ROLE_MCP_POLICY_FLAG,
|
|
8
|
-
roleMcpFlagValue,
|
|
9
|
-
selectRoleMcpConfig,
|
|
10
|
-
} from "@henryqw/pi-subagent";
|
|
5
|
+
import { roleMcpAllowlistFromArgv, ROLE_MCP_POLICY_FLAG, selectRoleMcpConfig } from "@henryqw/pi-subagent";
|
|
11
6
|
|
|
12
7
|
export default function roleMcp(pi: ExtensionAPI): void {
|
|
13
8
|
pi.registerFlag(ROLE_MCP_POLICY_FLAG, {
|
|
14
9
|
description: "Internal Pi Subagent Role MCP policy",
|
|
15
10
|
type: "string",
|
|
16
11
|
});
|
|
17
|
-
|
|
12
|
+
// Pi binds extension flag values after factories load.
|
|
13
|
+
const allowlist = roleMcpAllowlistFromArgv(process.argv);
|
|
18
14
|
const config = selectRoleMcpConfig(loadMcpConfig(join(getAgentDir(), "mcp.json"), process.cwd()), allowlist);
|
|
19
15
|
createMcpAdapter({ config })(pi);
|
|
20
16
|
}
|
package/extensions/role-tools.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { isAbsolute } from "node:path";
|
|
|
3
3
|
import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import {
|
|
5
5
|
CHILD_EXCLUDED_TOOL_NAMES,
|
|
6
|
-
EXECUTION_BUDGET_FLAG,
|
|
7
6
|
EXECUTION_BUDGET_ENV,
|
|
8
7
|
PI_SUBAGENT_PROCESS_LEASE,
|
|
9
8
|
ROLE_TOOL_POLICY_FLAG,
|
|
@@ -48,9 +47,8 @@ function configuredTools(value: unknown): string[] {
|
|
|
48
47
|
return [...new Set(parsed.map((name) => name.trim()))];
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
function executionBudget(value: string |
|
|
50
|
+
function executionBudget(value: string | undefined): EphemeralSubagentExecutionBudget | undefined {
|
|
52
51
|
if (value === undefined) return;
|
|
53
|
-
if (typeof value !== "string") throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
|
|
54
52
|
let parsed: unknown;
|
|
55
53
|
try {
|
|
56
54
|
parsed = JSON.parse(value);
|
|
@@ -106,11 +104,7 @@ export default function roleTools(pi: ExtensionAPI): void {
|
|
|
106
104
|
description: "Internal Pi Subagent Role tool policy",
|
|
107
105
|
type: "string",
|
|
108
106
|
});
|
|
109
|
-
|
|
110
|
-
description: "Internal Pi Subagent execution budget",
|
|
111
|
-
type: "string",
|
|
112
|
-
});
|
|
113
|
-
const budget = executionBudget(pi.getFlag(EXECUTION_BUDGET_FLAG) ?? process.env[EXECUTION_BUDGET_ENV]);
|
|
107
|
+
const budget = executionBudget(process.env[EXECUTION_BUDGET_ENV]);
|
|
114
108
|
let handoffSent = false;
|
|
115
109
|
pi.on("session_start", () => {
|
|
116
110
|
const selected = configuredTools(pi.getFlag(ROLE_TOOL_POLICY_FLAG));
|
|
@@ -45,8 +45,8 @@ const errorText = (error: unknown): string => clean(error instanceof Error ? err
|
|
|
45
45
|
const completed = (status: string) => ["completed", "success", "failed", "failure", "aborted", "integrated"].includes(status);
|
|
46
46
|
const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
|
|
47
47
|
|
|
48
|
-
type
|
|
49
|
-
async function choose<T>(ctx: ExtensionContext, title: string, items: readonly
|
|
48
|
+
type Choice<T> = { label: string; value: T };
|
|
49
|
+
async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Choice<T>[]): Promise<T | undefined> {
|
|
50
50
|
// Numeric suffixes are UI-only. Never parse display text into an identity.
|
|
51
51
|
const labels = items.map((item, index) => `${clean(item.label, 145)} [${index + 1}]`);
|
|
52
52
|
const selected = await ctx.ui.select(title, labels);
|
|
@@ -100,7 +100,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
100
100
|
if (unavailable) ctx.ui.notify(`Isolated inventory unavailable (${unavailable}). Direct branch recovery remains available; check the canonical Git checkout/configuration.`, "warning");
|
|
101
101
|
if (inventory?.invalidIds.length) for (const id of inventory.invalidIds) ctx.ui.notify(`Unreadable isolated state ID: ${JSON.stringify(id)}. Preserve this file; inspect the state store before making changes.`, "warning");
|
|
102
102
|
type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "close" };
|
|
103
|
-
const selections:
|
|
103
|
+
const selections: Choice<Selection>[] = [
|
|
104
104
|
...direct.filter((task) => history || !completed(task.status)).map((task) => ({ label: `Direct · ${task.name} · ${task.status} · ${task.id}`, value: { kind: "direct" as const, task } })),
|
|
105
105
|
...(inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status)).map((request) => ({ label: `Isolated · ${request.name} · ${request.status} · ${request.id}`, value: { kind: "isolated" as const, request, root: inventory!.root } })),
|
|
106
106
|
{ label: "Refresh", value: { kind: "refresh" as const } },
|
|
@@ -108,7 +108,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
108
108
|
{ label: "Close", value: { kind: "close" as const } },
|
|
109
109
|
];
|
|
110
110
|
const taskChoices = selections.splice(0, selections.length - 3);
|
|
111
|
-
const rank = ({ value }:
|
|
111
|
+
const rank = ({ value }: Choice<Selection>) => value.kind === "direct" ? priority(value.task.status)
|
|
112
112
|
: value.kind === "isolated" ? priority(value.request.status) : 3;
|
|
113
113
|
taskChoices.sort((a, b) => rank(a) - rank(b));
|
|
114
114
|
selections.unshift(...taskChoices);
|
|
@@ -129,7 +129,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
129
129
|
if (!valid()) return;
|
|
130
130
|
type Action = "inspect" | "send" | "edit" | "back";
|
|
131
131
|
const eligible = request.tasks.filter((task) => task.kind === "changeset" && adapter.canFollowup(root, request.id, task.id));
|
|
132
|
-
const actions:
|
|
132
|
+
const actions: Choice<Action>[] = [
|
|
133
133
|
{ label: "Inspect status and recovery", value: "inspect" },
|
|
134
134
|
...(eligible.length ? [{ label: "Send follow-up instructions", value: "send" as const }, { label: "Edit queued instructions (withdraw ALL pending; Cancel leaves withdrawn)", value: "edit" as const }] : []),
|
|
135
135
|
{ label: "Back / refresh", value: "back" },
|
package/extensions/subagent.ts
CHANGED
|
@@ -3,12 +3,12 @@ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-c
|
|
|
3
3
|
import { type Component, Text, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { availableTaskModels, loadTaskModelsConfig, modelReference, registerModelTask, resolveAvailableModel, type ResolvedTaskRoute, taskThinkingLevels } from "@henryqw/pi-task-models";
|
|
5
5
|
import { capEphemeralSubagentOutput as capOutput, createEphemeralSubagentExecutor, DELEGATE_TASK, formatDuration, loadRoles, prepareRoleLaunch, ROLE_TOOL_POLICY_FLAG, type Role } from "@henryqw/pi-subagent";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { readSubagentConfig, resolveExecutionPolicy, type EffectiveExecutionPolicy } from "./config.ts";
|
|
7
|
+
import { registerCheckoutAdmission, roleCanWrite, roleIsReadOnlyScout } from "./admission.ts";
|
|
8
8
|
import { registerIsolatedExtension } from "./isolated.ts";
|
|
9
9
|
import { registerSubagentCommand, type DirectTask } from "./subagent-command.ts";
|
|
10
10
|
import { MODEL_CLASS_GUIDANCE } from "./model-class-policy.ts";
|
|
11
|
-
import {
|
|
11
|
+
import { ENTRY_STATUS_PRESENTATION, formatWorkflowResult, type BackgroundWorkflowTransportDetails, type WorkflowTransportEntry } from "./result-transport.ts";
|
|
12
12
|
import { DelegateTaskSchema, identifyWorkflowEntries, parseDelegateTask, runForegroundWorkflow, type Delegation, type ParsedWorkflow, type WorkflowEntry } from "./workflow.ts";
|
|
13
13
|
import { createDirectHerdr, type DirectHandle, type DirectTab } from "../dist/direct-herdr.js";
|
|
14
14
|
import { materializeTransientLaunch } from "../dist/launch-runtime.js";
|
|
@@ -140,7 +140,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
140
140
|
const glyph = details.recovery ? "■" : details.outcome === "completed" ? "✓" : "✗";
|
|
141
141
|
const color = details.recovery ? "warning" : details.outcome === "completed" ? "success" : "error";
|
|
142
142
|
const rows = details.entries.map(({ name, role, status, summary }) => {
|
|
143
|
-
const { glyph, fallback } =
|
|
143
|
+
const { glyph, fallback } = ENTRY_STATUS_PRESENTATION[status];
|
|
144
144
|
return `${glyph} ${name} · ${role} — ${details.recovery ? fallback : summary || fallback}`;
|
|
145
145
|
});
|
|
146
146
|
const raw = content.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, " ");
|
|
@@ -159,13 +159,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
159
159
|
try {
|
|
160
160
|
initialPolicy = resolveExecutionPolicy(loadedConfig);
|
|
161
161
|
} catch {
|
|
162
|
-
initialPolicy = {
|
|
163
|
-
maxSubagents: DEFAULT_EXECUTION_POLICY.maxSubagents,
|
|
164
|
-
maxTurns: DEFAULT_EXECUTION_POLICY.maxTurns,
|
|
165
|
-
childIdleMs: DEFAULT_TIMEOUT_CONFIG.idleMinutes * 60_000,
|
|
166
|
-
childMaxMs: DEFAULT_TIMEOUT_CONFIG.maxMinutes * 60_000,
|
|
167
|
-
maxCorrections: DEFAULT_EXECUTION_POLICY.maxCorrections,
|
|
168
|
-
};
|
|
162
|
+
initialPolicy = resolveExecutionPolicy({ source: "missing", config: {} });
|
|
169
163
|
}
|
|
170
164
|
const executor = createEphemeralSubagentExecutor({
|
|
171
165
|
maxConcurrency: initialPolicy.maxSubagents,
|
|
@@ -179,13 +173,15 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
179
173
|
const parsed = parseDelegateTask(input);
|
|
180
174
|
if (parsed.mode === "isolated") return true;
|
|
181
175
|
const roles = new Map(loadRoles().map((role) => [role.name, role]));
|
|
182
|
-
return parsed.workflow.delegations.some((delegation) =>
|
|
176
|
+
return parsed.workflow.delegations.some((delegation) => {
|
|
177
|
+
const role = roles.get(delegation.role);
|
|
178
|
+
return !role || roleCanWrite(role);
|
|
179
|
+
});
|
|
183
180
|
} catch {
|
|
184
181
|
return true;
|
|
185
182
|
}
|
|
186
183
|
};
|
|
187
|
-
|
|
188
|
-
admission.register(pi, canWrite);
|
|
184
|
+
registerCheckoutAdmission(pi, canWrite);
|
|
189
185
|
const isolatedSurface = registerIsolatedExtension(pi, {
|
|
190
186
|
executor,
|
|
191
187
|
policy: initialPolicy,
|
|
@@ -513,9 +509,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
513
509
|
const prepared = entry.delegation.model === undefined ? route : prepareRoleLaunch(pi, context, {
|
|
514
510
|
role, route: replaceRouteModel(context, entry.delegation.model, route),
|
|
515
511
|
});
|
|
516
|
-
|
|
517
|
-
states.set(entry.id, { id: base.id, index: base.index, name: base.name, role: base.role,
|
|
518
|
-
model: modelReference(prepared.model), thinkingLevel: prepared.thinkingLevel, status: "running", assistantOutput: "" });
|
|
512
|
+
states.set(entry.id, { ...states.get(entry.id)!, model: modelReference(prepared.model), thinkingLevel: prepared.thinkingLevel, status: "running", assistantOutput: "" });
|
|
519
513
|
startWidgetItem(entry.id, taskId, role.name, prepared.model.id, prepared.thinkingLevel, entry.delegation.name, ctx);
|
|
520
514
|
const transient = await materializeTransientLaunch({ launch: prepared, prompt: prepared.systemPrompt,
|
|
521
515
|
promptArgIndex: prepared.promptArgIndex }, activeSignal);
|
|
@@ -578,17 +572,12 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
578
572
|
const tokens = await handle.usageTokens();
|
|
579
573
|
if (tokens !== undefined && widgetItems.has(entry.id)) widgetItems.get(entry.id)!.tokens = tokens;
|
|
580
574
|
} catch { /* Optional telemetry. */ }
|
|
581
|
-
|
|
582
|
-
states.set(entry.id, { id: base.id, index: base.index, name: base.name, role: base.role,
|
|
583
|
-
...(base.model ? { model: base.model } : {}), ...(base.thinkingLevel ? { thinkingLevel: base.thinkingLevel } : {}),
|
|
584
|
-
status: "succeeded", assistantOutput: answer });
|
|
575
|
+
states.set(entry.id, { ...states.get(entry.id)!, status: "succeeded", assistantOutput: answer });
|
|
585
576
|
finishWidgetItem(entry.id, "success");
|
|
586
577
|
return answer;
|
|
587
578
|
} catch (error) {
|
|
588
|
-
const base = states.get(entry.id)!;
|
|
589
|
-
states.set(entry.id, {
|
|
590
|
-
...(base.model ? { model: base.model } : {}), ...(base.thinkingLevel ? { thinkingLevel: base.thinkingLevel } : {}),
|
|
591
|
-
status: "rejected", failure: `${tabByEntry.has(entry.id) ? `recover from Herdr tab ${tabByEntry.get(entry.id)!.tabId}, agent ${tabByEntry.get(entry.id)!.name}, session ${tabByEntry.get(entry.id)!.sessionFile}: ` : ""}${capOutput(error instanceof Error ? error.message : String(error))}` });
|
|
579
|
+
const { assistantOutput: _partial, ...base } = states.get(entry.id)!;
|
|
580
|
+
states.set(entry.id, { ...base, status: "rejected", failure: `${tabByEntry.has(entry.id) ? `recover from Herdr tab ${tabByEntry.get(entry.id)!.tabId}, agent ${tabByEntry.get(entry.id)!.name}, session ${tabByEntry.get(entry.id)!.sessionFile}: ` : ""}${capOutput(error instanceof Error ? error.message : String(error))}` });
|
|
592
581
|
finishWidgetItem(entry.id, "failure");
|
|
593
582
|
throw error;
|
|
594
583
|
} finally { release(); }
|
package/extensions/workflow.ts
CHANGED
|
@@ -43,7 +43,6 @@ export const DirectWorkflowSchema = Type.Object({
|
|
|
43
43
|
}, { additionalProperties: false, description: "Direct mode: exactly one compact single, tasks, or chain workflow" });
|
|
44
44
|
|
|
45
45
|
export const DelegateTaskSchema = Type.Union([DirectWorkflowSchema, ExecuteRequestSchema], { type: "object" });
|
|
46
|
-
export const WorkflowSchema = DirectWorkflowSchema;
|
|
47
46
|
|
|
48
47
|
export type Delegation = Static<typeof DelegationSchema> & { kind: "text" };
|
|
49
48
|
export type WorkflowMode = "single" | "parallel" | "chain";
|
package/package.json
CHANGED
|
@@ -26,12 +26,12 @@ Use one compact `role`/`name`/`task` packet, `tasks` for independent packets, or
|
|
|
26
26
|
|
|
27
27
|
# Isolated requests
|
|
28
28
|
|
|
29
|
-
Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks. The root must have a `package.json` test script; `pnpm test` is added to final checks if missing and runs against the combined tip before Main promotion. Text context may name only completed text tasks
|
|
29
|
+
Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks. The root must have a `package.json` test script; `pnpm test` is added to final checks if missing and runs against the combined tip before Main promotion. Text context may name only completed text tasks, preserves declared order, and implies a scheduling dependency; do not repeat an edge in `dependsOn` and `contextFrom`.
|
|
30
30
|
|
|
31
31
|
Every changeset advances automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for sibling workers. There is no guaranteed post-completion editing window, and late follow-ups fail visibly.
|
|
32
32
|
|
|
33
33
|
A ready candidate does not integrate automatically. Each checked worker triggers a follow-up while its siblings continue; inspect `subagent_status` and immediately use `subagent_stage` with the exact `generation`, `taskId`, `attempt`, candidate `tip`, and `expectedTip` to select and merge it into the owned integration checkout. Stage and resolve are available during the active wave; wait for the wave to settle before rejecting or revising. Resolve conflicts in the integration checkout and verify with `resolve`; never edit Main to resolve them. For dependents, `subagent_integrate advance` acknowledges a durable background wave, then reports checked candidates as they finish. When selected work is staged and the wave settles, use `subagent_integrate validate` on the exact `combinedTip` for full root checks and optional final judgment; `promote` requires passing evidence and unchanged Main. One Main-authored committed correction to a definitively failed combined validation can be recorded with `correct`, then revalidated. Only proven promotion triggers selected-worker termination and verified cleanup. Failures, ambiguity, conflicts, interruption, child-limit exhaustion, or Main drift retain work and never waive checks or identity guards. The extension never pushes, opens a pull request, publishes, or deploys.
|
|
34
34
|
|
|
35
|
-
After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once: it acquires the productive lease, classifies orphaned interruptions, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations;
|
|
35
|
+
After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once: it acquires the productive lease, classifies orphaned interruptions, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations; release them first. For workers without candidates, abort cleans only proven unchanged, clean owned resources. If cleanup is deferred or blocked, inspect status and repeat abort after resolving the blocker. Dirty or committed work and uncertain allocations remain for manual recovery. Old v4 state is rejected (current state is v5), not migrated. Status, abort, process I/O, termination, and verified post-promotion cleanup keep finite safety budgets.
|
|
36
36
|
|
|
37
37
|
All launches use the global policy in `config/pi-subagent/config.json`: productive concurrency, per-child turns/tokens, and idle timeout. Ephemeral children also have a hard runtime limit; isolated requests have a correction cap. Productive requests have no whole-run wall-clock deadline. Request fields cannot replenish or override limits.
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
export interface WorkingCheckoutState {
|
|
2
|
-
head: string;
|
|
3
|
-
indexHash: string;
|
|
4
|
-
rawManifestHash: string;
|
|
5
|
-
}
|
|
6
|
-
export interface WorkingSnapshotIdentity extends WorkingCheckoutState {
|
|
7
|
-
tree: string;
|
|
8
|
-
}
|
|
9
|
-
export interface WorkingCheckoutBaseline extends WorkingSnapshotIdentity {
|
|
10
|
-
worktree: string;
|
|
11
|
-
}
|
|
12
|
-
export interface PreparedWorkingChangeEvidence {
|
|
13
|
-
baseline: WorkingCheckoutBaseline;
|
|
14
|
-
identity: WorkingSnapshotIdentity;
|
|
15
|
-
changedPaths: string[];
|
|
16
|
-
patchPath: string;
|
|
17
|
-
rawEvidencePath: string;
|
|
18
|
-
cleanup(): Promise<void>;
|
|
19
|
-
}
|
|
20
|
-
/** Capture mutation-detection identity without requiring a clean checkout or constructing Git objects. */
|
|
21
|
-
export declare function captureWorkingCheckoutState(cwd: string, signal?: AbortSignal): Promise<WorkingCheckoutState>;
|
|
22
|
-
export declare function captureWorkingCheckoutBaseline(cwd: string, signal?: AbortSignal): Promise<WorkingCheckoutBaseline>;
|
|
23
|
-
export declare function prepareWorkingChangeEvidence(baseline: WorkingCheckoutBaseline, signal?: AbortSignal): Promise<PreparedWorkingChangeEvidence>;
|
|
24
|
-
export declare function sameWorkingCheckoutState(left: WorkingCheckoutState, right: WorkingCheckoutState): boolean;
|
|
25
|
-
export declare function sameWorkingSnapshot(left: WorkingSnapshotIdentity, right: WorkingSnapshotIdentity): boolean;
|
package/dist/working-evidence.js
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { constants } from "node:fs";
|
|
4
|
-
import { chmod, copyFile, lstat, mkdir, mkdtemp, open, readFile, readlink, realpath, rm, writeFile } from "node:fs/promises";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
7
|
-
import { inspectIndexFlags } from "./worktree.js";
|
|
8
|
-
import { runGit as runCapturedGit } from "./git-process.js";
|
|
9
|
-
import { REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS } from "./review-evidence.js";
|
|
10
|
-
const GIT_TIMEOUT_MS = 30_000;
|
|
11
|
-
const OUTPUT_LIMIT = REVIEW_MAX_PATCH_BYTES + 1;
|
|
12
|
-
const MANIFEST_MAX_BYTES = 512 * 1024;
|
|
13
|
-
function hash(value) {
|
|
14
|
-
return createHash("sha256").update(value).digest("hex");
|
|
15
|
-
}
|
|
16
|
-
function line(value, field) {
|
|
17
|
-
const text = value.toString("utf8").replace(/\r?\n$/, "");
|
|
18
|
-
if (!text || text.includes("�") || /[\r\n\0]/.test(text))
|
|
19
|
-
throw new Error(`Git returned malformed ${field}.`);
|
|
20
|
-
return text;
|
|
21
|
-
}
|
|
22
|
-
function oid(value, field) {
|
|
23
|
-
const result = line(value, field);
|
|
24
|
-
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(result))
|
|
25
|
-
throw new Error(`Git returned invalid ${field}.`);
|
|
26
|
-
return result;
|
|
27
|
-
}
|
|
28
|
-
async function command(args, cwd, env = process.env, signal, limit = OUTPUT_LIMIT) {
|
|
29
|
-
signal?.throwIfAborted();
|
|
30
|
-
const child = spawn("git", args, { cwd, env, signal, timeout: GIT_TIMEOUT_MS, stdio: ["ignore", "pipe", "pipe"] });
|
|
31
|
-
const stdout = [];
|
|
32
|
-
const stderr = [];
|
|
33
|
-
let stdoutBytes = 0;
|
|
34
|
-
let stderrBytes = 0;
|
|
35
|
-
let overflow = false;
|
|
36
|
-
const collect = (chunks, chunk, current) => {
|
|
37
|
-
const available = limit - current;
|
|
38
|
-
if (available <= 0) {
|
|
39
|
-
overflow = true;
|
|
40
|
-
return 0;
|
|
41
|
-
}
|
|
42
|
-
chunks.push(chunk.subarray(0, available));
|
|
43
|
-
if (chunk.length > available)
|
|
44
|
-
overflow = true;
|
|
45
|
-
return Math.min(chunk.length, available);
|
|
46
|
-
};
|
|
47
|
-
child.stdout.on("data", (chunk) => { stdoutBytes += collect(stdout, chunk, stdoutBytes); if (overflow)
|
|
48
|
-
child.kill(); });
|
|
49
|
-
child.stderr.on("data", (chunk) => { stderrBytes += collect(stderr, chunk, stderrBytes); if (overflow)
|
|
50
|
-
child.kill(); });
|
|
51
|
-
const code = await new Promise((resolveCode, reject) => {
|
|
52
|
-
child.once("error", reject);
|
|
53
|
-
child.once("close", resolveCode);
|
|
54
|
-
});
|
|
55
|
-
signal?.throwIfAborted();
|
|
56
|
-
if (overflow)
|
|
57
|
-
throw new Error(`Working-change evidence exceeds ${limit - 1} bytes.`);
|
|
58
|
-
return { code: code ?? -1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) };
|
|
59
|
-
}
|
|
60
|
-
async function git(args, cwd, env, signal) {
|
|
61
|
-
const result = await command(args, cwd, env, signal);
|
|
62
|
-
if (result.code !== 0) {
|
|
63
|
-
const detail = result.stderr.toString("utf8").slice(0, 200).trim();
|
|
64
|
-
throw new Error(`git ${args.join(" ")} failed with exit ${result.code}${detail ? `: ${detail}` : ""}`);
|
|
65
|
-
}
|
|
66
|
-
return result.stdout;
|
|
67
|
-
}
|
|
68
|
-
function paths(value) {
|
|
69
|
-
if (!value.length)
|
|
70
|
-
return [];
|
|
71
|
-
const text = value.toString("utf8");
|
|
72
|
-
if (text.includes("�") || value.at(-1) !== 0)
|
|
73
|
-
throw new Error("Git returned malformed working-change paths.");
|
|
74
|
-
const result = text.slice(0, -1).split("\0");
|
|
75
|
-
if (result.length > REVIEW_MAX_PATHS)
|
|
76
|
-
throw new Error(`Working-change evidence exceeds ${REVIEW_MAX_PATHS} paths.`);
|
|
77
|
-
for (const path of result) {
|
|
78
|
-
if (!path || path.includes("\0") || isAbsolute(path) || path.split(/[\\/]/).some((part) => !part || part === "." || part === "..")) {
|
|
79
|
-
throw new Error(`Git returned malformed or unsupported path: ${JSON.stringify(path)}`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return result;
|
|
83
|
-
}
|
|
84
|
-
async function indexPath(worktree, signal) {
|
|
85
|
-
const raw = line(await git(["rev-parse", "--git-path", "index"], worktree, undefined, signal), "index path");
|
|
86
|
-
const path = isAbsolute(raw) ? raw : resolve(worktree, raw);
|
|
87
|
-
return await realpath(path);
|
|
88
|
-
}
|
|
89
|
-
async function repositoryObjects(worktree, signal) {
|
|
90
|
-
const raw = line(await git(["rev-parse", "--git-common-dir"], worktree, undefined, signal), "common Git directory");
|
|
91
|
-
const directory = isAbsolute(raw) ? raw : resolve(worktree, raw);
|
|
92
|
-
return await realpath(join(directory, "objects"));
|
|
93
|
-
}
|
|
94
|
-
async function assertNoExternalFilters(worktree, signal) {
|
|
95
|
-
const result = await command(["config", "--get-regexp", "^filter\\..*\\.(clean|process)$"], worktree, process.env, signal, 64 * 1024);
|
|
96
|
-
if (result.code === 1 && result.stdout.length === 0)
|
|
97
|
-
return;
|
|
98
|
-
if (result.code !== 0)
|
|
99
|
-
throw new Error("Could not inspect effective Git content filters.");
|
|
100
|
-
throw new Error("Checked direct changesets reject configured Git clean/process filters; use isolated mode or remove the filter configuration before retrying.");
|
|
101
|
-
}
|
|
102
|
-
async function assertSupportedIndex(worktree, signal) {
|
|
103
|
-
const flags = await inspectIndexFlags(worktree, runCapturedGit, signal);
|
|
104
|
-
if (flags.failure)
|
|
105
|
-
throw new Error(`Working-change index inspection failed: ${flags.failure}`);
|
|
106
|
-
if (flags.hidden)
|
|
107
|
-
throw new Error("Checked direct changesets reject assume-unchanged or skip-worktree index entries.");
|
|
108
|
-
const stage = (await git(["ls-files", "--stage", "-z"], worktree, undefined, signal)).toString("utf8");
|
|
109
|
-
if (stage.includes("�") || /(?:^|\0)160000 /.test(stage))
|
|
110
|
-
throw new Error("Checked direct changesets reject Git links.");
|
|
111
|
-
}
|
|
112
|
-
async function rawManifest(worktree, signal) {
|
|
113
|
-
const listed = paths(await git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], worktree, undefined, signal));
|
|
114
|
-
const entries = [];
|
|
115
|
-
for (const path of [...new Set(listed)].sort()) {
|
|
116
|
-
signal?.throwIfAborted();
|
|
117
|
-
const absolute = join(worktree, path);
|
|
118
|
-
let info;
|
|
119
|
-
try {
|
|
120
|
-
info = await lstat(absolute);
|
|
121
|
-
}
|
|
122
|
-
catch (error) {
|
|
123
|
-
if (error.code === "ENOENT") {
|
|
124
|
-
entries.push({ path, kind: "absent" });
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
throw error;
|
|
128
|
-
}
|
|
129
|
-
if (info.isSymbolicLink()) {
|
|
130
|
-
const target = await readlink(absolute, { encoding: "buffer" });
|
|
131
|
-
entries.push({ path, kind: "symlink", mode: info.mode & 0o7777, size: target.length, hash: hash(target) });
|
|
132
|
-
}
|
|
133
|
-
else if (info.isFile()) {
|
|
134
|
-
const contents = await readFile(absolute);
|
|
135
|
-
entries.push({ path, kind: "file", mode: info.mode & 0o7777, size: contents.length, hash: hash(contents) });
|
|
136
|
-
}
|
|
137
|
-
else {
|
|
138
|
-
throw new Error(`Checked direct evidence rejects unsupported working path type: ${path}`);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
const json = `${JSON.stringify(entries)}\n`;
|
|
142
|
-
if (Buffer.byteLength(json, "utf8") > MANIFEST_MAX_BYTES)
|
|
143
|
-
throw new Error(`Working-change raw manifest exceeds ${MANIFEST_MAX_BYTES} bytes.`);
|
|
144
|
-
return { json, digest: hash(json) };
|
|
145
|
-
}
|
|
146
|
-
async function actualIdentity(worktree, signal) {
|
|
147
|
-
const [head, index] = await Promise.all([
|
|
148
|
-
git(["rev-parse", "--verify", "HEAD"], worktree, undefined, signal),
|
|
149
|
-
readFile(await indexPath(worktree, signal)),
|
|
150
|
-
]);
|
|
151
|
-
return { head: oid(head, "HEAD"), indexHash: hash(index) };
|
|
152
|
-
}
|
|
153
|
-
async function privateSnapshot(worktree, directory, signal) {
|
|
154
|
-
const privateIndex = join(directory, "index");
|
|
155
|
-
const objectDirectory = join(directory, "objects");
|
|
156
|
-
await mkdir(objectDirectory, { mode: 0o700 });
|
|
157
|
-
await copyFile(await indexPath(worktree, signal), privateIndex, constants.COPYFILE_EXCL);
|
|
158
|
-
await chmod(privateIndex, 0o600);
|
|
159
|
-
const env = {
|
|
160
|
-
...process.env,
|
|
161
|
-
GIT_INDEX_FILE: privateIndex,
|
|
162
|
-
GIT_OBJECT_DIRECTORY: objectDirectory,
|
|
163
|
-
GIT_ALTERNATE_OBJECT_DIRECTORIES: await repositoryObjects(worktree, signal),
|
|
164
|
-
GIT_CONFIG_NOSYSTEM: "1",
|
|
165
|
-
};
|
|
166
|
-
await git(["add", "-A", "--", "."], worktree, env, signal);
|
|
167
|
-
const tree = oid(await git(["write-tree"], worktree, env, signal), "private working tree");
|
|
168
|
-
const manifest = await rawManifest(worktree, signal);
|
|
169
|
-
return { tree, manifest };
|
|
170
|
-
}
|
|
171
|
-
async function makeDirectory() {
|
|
172
|
-
const directory = await mkdtemp(join(tmpdir(), "pi-subagent-working-review-"));
|
|
173
|
-
await chmod(directory, 0o700);
|
|
174
|
-
let cleaning;
|
|
175
|
-
return { directory, cleanup: () => cleaning ??= rm(directory, { recursive: true, force: true }) };
|
|
176
|
-
}
|
|
177
|
-
async function capture(worktree, directory, signal) {
|
|
178
|
-
await assertNoExternalFilters(worktree, signal);
|
|
179
|
-
await assertSupportedIndex(worktree, signal);
|
|
180
|
-
const before = await actualIdentity(worktree, signal);
|
|
181
|
-
const snapshot = await privateSnapshot(worktree, directory, signal);
|
|
182
|
-
const afterManifest = await rawManifest(worktree, signal);
|
|
183
|
-
const after = await actualIdentity(worktree, signal);
|
|
184
|
-
if (before.head !== after.head || before.indexHash !== after.indexHash || snapshot.manifest.digest !== afterManifest.digest) {
|
|
185
|
-
throw new Error("Working checkout changed while direct evidence was being captured.");
|
|
186
|
-
}
|
|
187
|
-
return {
|
|
188
|
-
identity: { ...before, tree: snapshot.tree, rawManifestHash: snapshot.manifest.digest },
|
|
189
|
-
manifest: snapshot.manifest.json,
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
async function canonicalRoot(cwd, signal) {
|
|
193
|
-
const canonical = await realpath(cwd);
|
|
194
|
-
return await realpath(line(await git(["rev-parse", "--show-toplevel"], canonical, undefined, signal), "checkout root"));
|
|
195
|
-
}
|
|
196
|
-
/** Capture mutation-detection identity without requiring a clean checkout or constructing Git objects. */
|
|
197
|
-
export async function captureWorkingCheckoutState(cwd, signal) {
|
|
198
|
-
const worktree = await canonicalRoot(cwd, signal);
|
|
199
|
-
const before = await actualIdentity(worktree, signal);
|
|
200
|
-
const manifest = await rawManifest(worktree, signal);
|
|
201
|
-
const after = await actualIdentity(worktree, signal);
|
|
202
|
-
if (before.head !== after.head || before.indexHash !== after.indexHash) {
|
|
203
|
-
throw new Error("Working checkout changed while direct state was being captured.");
|
|
204
|
-
}
|
|
205
|
-
return { ...before, rawManifestHash: manifest.digest };
|
|
206
|
-
}
|
|
207
|
-
export async function captureWorkingCheckoutBaseline(cwd, signal) {
|
|
208
|
-
const worktree = await canonicalRoot(cwd, signal);
|
|
209
|
-
const branch = await command(["symbolic-ref", "-q", "HEAD"], worktree, process.env, signal, 64 * 1024);
|
|
210
|
-
if (branch.code !== 0)
|
|
211
|
-
throw new Error("Checked direct changesets require an attached branch.");
|
|
212
|
-
if ((await git(["status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=none"], worktree, undefined, signal)).length) {
|
|
213
|
-
throw new Error("Checked direct changesets require a clean initial checkout; settle existing work or choose authorized isolated mode. No files were stashed, reset, or deleted.");
|
|
214
|
-
}
|
|
215
|
-
const owned = await makeDirectory();
|
|
216
|
-
try {
|
|
217
|
-
const captured = await capture(worktree, owned.directory, signal);
|
|
218
|
-
return { worktree, ...captured.identity };
|
|
219
|
-
}
|
|
220
|
-
finally {
|
|
221
|
-
await owned.cleanup();
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
export async function prepareWorkingChangeEvidence(baseline, signal) {
|
|
225
|
-
const owned = await makeDirectory();
|
|
226
|
-
try {
|
|
227
|
-
const captured = await capture(baseline.worktree, owned.directory, signal);
|
|
228
|
-
if (captured.identity.head !== baseline.head)
|
|
229
|
-
throw new Error("Checked direct changesets cannot move HEAD or create commits.");
|
|
230
|
-
const env = {
|
|
231
|
-
...process.env,
|
|
232
|
-
GIT_OBJECT_DIRECTORY: join(owned.directory, "objects"),
|
|
233
|
-
GIT_ALTERNATE_OBJECT_DIRECTORIES: await repositoryObjects(baseline.worktree, signal),
|
|
234
|
-
GIT_CONFIG_NOSYSTEM: "1",
|
|
235
|
-
};
|
|
236
|
-
const raw = await git(["diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--raw", "-z", baseline.tree, captured.identity.tree], baseline.worktree, env, signal);
|
|
237
|
-
if (raw.toString("utf8").includes("160000"))
|
|
238
|
-
throw new Error("Checked direct changesets reject changed Git links.");
|
|
239
|
-
const changedPaths = paths(await git(["diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--name-only", "-z", baseline.tree, captured.identity.tree], baseline.worktree, env, signal));
|
|
240
|
-
const patch = await git(["--no-pager", "diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--binary", baseline.tree, captured.identity.tree], baseline.worktree, env, signal);
|
|
241
|
-
if (patch.length > REVIEW_MAX_PATCH_BYTES)
|
|
242
|
-
throw new Error(`Working-change evidence exceeds ${REVIEW_MAX_PATCH_BYTES} bytes.`);
|
|
243
|
-
const patchPath = join(owned.directory, "review.patch");
|
|
244
|
-
const rawEvidencePath = join(owned.directory, "raw-manifest.json");
|
|
245
|
-
const patchFile = await open(patchPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
246
|
-
await patchFile.writeFile(patch);
|
|
247
|
-
await patchFile.close();
|
|
248
|
-
await writeFile(rawEvidencePath, captured.manifest, { mode: 0o600, flag: "wx" });
|
|
249
|
-
return { baseline, identity: captured.identity, changedPaths, patchPath, rawEvidencePath, cleanup: owned.cleanup };
|
|
250
|
-
}
|
|
251
|
-
catch (error) {
|
|
252
|
-
await owned.cleanup();
|
|
253
|
-
throw error;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
export function sameWorkingCheckoutState(left, right) {
|
|
257
|
-
return left.head === right.head && left.indexHash === right.indexHash && left.rawManifestHash === right.rawManifestHash;
|
|
258
|
-
}
|
|
259
|
-
export function sameWorkingSnapshot(left, right) {
|
|
260
|
-
return sameWorkingCheckoutState(left, right) && left.tree === right.tree;
|
|
261
|
-
}
|