@shanepadgett/tau-agent 0.34.0 → 0.36.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/extensions/checkpoint/checkpoint-budget.ts +1 -14
- package/extensions/checkpoint/checkpoint.ts +2 -0
- package/extensions/checkpoint/index.ts +1 -1
- package/extensions/checkpoint/messages.ts +1 -0
- package/extensions/commit/commit-plan.ts +2 -2
- package/extensions/explore/README.md +2 -2
- package/extensions/explore/guidance.ts +2 -1
- package/extensions/explore/tools/render.ts +9 -15
- package/extensions/explore/tools/show.ts +22 -4
- package/extensions/patch/render.ts +19 -3
- package/extensions/reference/index.ts +2 -0
- package/extensions/reference/panel.ts +66 -15
- package/extensions/review/README.md +14 -6
- package/extensions/review/index.ts +67 -120
- package/extensions/review/model.ts +13 -33
- package/extensions/review/session.ts +9 -23
- package/extensions/script-runner/index.ts +14 -0
- package/extensions/soul/README.md +2 -2
- package/extensions/soul/index.ts +4 -4
- package/extensions/soul/prompt.ts +6 -10
- package/extensions/soul/settings.ts +6 -3
- package/extensions/subagent/agents/scout.md +1 -1
- package/extensions/subagent/agents/web-research.md +2 -2
- package/extensions/subagent/run.ts +6 -2
- package/extensions/tau-help/help.md +7 -3
- package/extensions/tool-approval/README.md +20 -0
- package/extensions/tool-approval/index.ts +270 -0
- package/extensions/tool-approval/settings.ts +24 -0
- package/extensions/web/tool-output.ts +4 -6
- package/package.json +2 -2
- package/schemas/tau.schema.json +18 -2
- package/shared/isolated-session.ts +1 -1
- package/shared/model-effort.ts +14 -3
- package/shared/text.ts +21 -0
- package/extensions/review/panel.ts +0 -99
|
@@ -15,14 +15,12 @@ export function createCheckpointBudget(initialLimit = DEFAULT_CHECKPOINT_TOKEN_L
|
|
|
15
15
|
let limit = validateLimit(initialLimit);
|
|
16
16
|
let highestNoticed: CheckpointBudgetLevel = 0;
|
|
17
17
|
let forced = false;
|
|
18
|
-
let baselineTokens: number | null = 0;
|
|
19
18
|
|
|
20
19
|
return {
|
|
21
20
|
configure(nextLimit: number): void {
|
|
22
21
|
limit = validateLimit(nextLimit);
|
|
23
22
|
highestNoticed = 0;
|
|
24
23
|
forced = false;
|
|
25
|
-
baselineTokens = 0;
|
|
26
24
|
},
|
|
27
25
|
|
|
28
26
|
beginTurn(tokens: number | null): CheckpointBudgetNoticeLevel | undefined {
|
|
@@ -40,23 +38,12 @@ export function createCheckpointBudget(initialLimit = DEFAULT_CHECKPOINT_TOKEN_L
|
|
|
40
38
|
reset(): void {
|
|
41
39
|
highestNoticed = 0;
|
|
42
40
|
forced = false;
|
|
43
|
-
baselineTokens = null;
|
|
44
41
|
},
|
|
45
42
|
};
|
|
46
43
|
|
|
47
44
|
function observe(tokens: number | null): CheckpointBudgetNoticeLevel | undefined {
|
|
48
45
|
if (tokens === null) return undefined;
|
|
49
|
-
|
|
50
|
-
baselineTokens = tokens;
|
|
51
|
-
return undefined;
|
|
52
|
-
}
|
|
53
|
-
if (tokens < baselineTokens) {
|
|
54
|
-
baselineTokens = tokens;
|
|
55
|
-
highestNoticed = 0;
|
|
56
|
-
forced = false;
|
|
57
|
-
return undefined;
|
|
58
|
-
}
|
|
59
|
-
const level = levelFor(tokens - baselineTokens, limit);
|
|
46
|
+
const level = levelFor(tokens, limit);
|
|
60
47
|
if (level === 100) forced = true;
|
|
61
48
|
if (level <= highestNoticed) return undefined;
|
|
62
49
|
highestNoticed = level;
|
|
@@ -105,6 +105,8 @@ function createCheckpointTool(pi: Pick<ExtensionAPI, "events" | "sendMessage">)
|
|
|
105
105
|
"Record concrete findings in facts and governing choices in decisions before checkpointing.",
|
|
106
106
|
"Write continue as the immediate resume directive after wake: first moves, what not to re-explore, traps to avoid. Put the backlog in work.",
|
|
107
107
|
"Use read or outline for files needed now; use deferred with a condition for files that can wait.",
|
|
108
|
+
"Preserve important discovery output before it leaves tool context. When shell or other command output is large, expensive to reproduce, or part of unfinished investigation, capture stdout and stderr in a private session-scoped temporary file; report its path, exit status, and a bounded summary instead of relying on the transcript.",
|
|
109
|
+
"Query preserved output with targeted search or ranged reads. Include artifacts needed after pruning in files, usually with read ranges or deferred with a condition, and record the artifact path and purpose in facts or continue. Do not persist routine bounded output, secrets, or whole large logs unnecessarily.",
|
|
108
110
|
],
|
|
109
111
|
parameters: checkpointParams,
|
|
110
112
|
renderShell: "self",
|
|
@@ -50,7 +50,7 @@ export default function checkpointExtension(pi: ExtensionAPI): void {
|
|
|
50
50
|
if (event.toolName === CHECKPOINT_TOOL && !event.isError) checkpointSucceeded = true;
|
|
51
51
|
});
|
|
52
52
|
pi.on("tool_call", (event) => {
|
|
53
|
-
if (!budget.shouldBlockTool(event.toolName, CHECKPOINT_TOOL)) return;
|
|
53
|
+
if (checkpointSucceeded || !budget.shouldBlockTool(event.toolName, CHECKPOINT_TOOL)) return;
|
|
54
54
|
return {
|
|
55
55
|
block: true,
|
|
56
56
|
reason: formatCheckpointMessage(
|
|
@@ -83,6 +83,7 @@ function keepCheckpointPair(
|
|
|
83
83
|
|
|
84
84
|
function expandPairMessages(entry: SessionEntry, message: AgentMessage): AgentMessage[] {
|
|
85
85
|
if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) {
|
|
86
|
+
if (message.role === "assistant" && extractConversationText(message) === "") return [message];
|
|
86
87
|
return [createMessageIdMetadata(entry.id, message), message];
|
|
87
88
|
}
|
|
88
89
|
return [message];
|
|
@@ -54,7 +54,7 @@ export async function generatePlan(
|
|
|
54
54
|
const prompt = buildPlanPrompt(evidence, previousPlan, regenerationNote);
|
|
55
55
|
return generateToolValidated(
|
|
56
56
|
ctx,
|
|
57
|
-
await resolveEffortCandidates(ctx, commitEffort(evidence.files), true),
|
|
57
|
+
await resolveEffortCandidates(ctx, commitEffort(evidence.files), { includeParentModel: true }),
|
|
58
58
|
prompt,
|
|
59
59
|
COMMIT_PLAN_TOOL,
|
|
60
60
|
(input) => commitGroupsFromToolInput(input, evidence.files),
|
|
@@ -84,7 +84,7 @@ export async function regenerateMessage(
|
|
|
84
84
|
const prompt = buildMessagePrompt(evidence, selected, previousPlan, selectedGroupId, regenerationNote);
|
|
85
85
|
return generateValidated(
|
|
86
86
|
ctx,
|
|
87
|
-
await resolveEffortCandidates(ctx, commitEffort(selected), true),
|
|
87
|
+
await resolveEffortCandidates(ctx, commitEffort(selected), { includeParentModel: true }),
|
|
88
88
|
prompt,
|
|
89
89
|
requireCommitMessage,
|
|
90
90
|
undefined,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Explore
|
|
2
2
|
|
|
3
|
-
Explore gives Tau 12 structural source tools: outlines, declaration slices, discovery, structural search, graph and relationship queries, impact, and context packs.
|
|
3
|
+
Explore gives Tau 12 structural source tools: outlines, declaration slices, discovery, structural search, graph and relationship queries, impact, and context packs. `show` takes a top-level `targets` array of path + declaration-name objects, with an optional line to disambiguate.
|
|
4
4
|
|
|
5
5
|
Pi keeps ordinary filesystem tools (`ls`, `find`, `grep`, `read`). Explore adds structure on top of supported source languages via in-process tree-sitter (WASM) on every platform supported by the Node runtime. Registered languages share the same tools and exploration workflow.
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ When `explore.read.enabled` is on (default), a full Pi `read` or autoread of a r
|
|
|
11
11
|
## Tools
|
|
12
12
|
|
|
13
13
|
- `outline` — declarations and structure for a file, one-level directory, or recursive subtree (no bodies).
|
|
14
|
-
- `show` — exact signature / docs / declaration / declaration+imports for
|
|
14
|
+
- `show` — exact signature / docs / declaration / declaration+imports for one or more targets in its top-level `targets` array.
|
|
15
15
|
- `discover` — find reusable declarations across a repo/package/subtree by name, kind, or docs (signatures only).
|
|
16
16
|
- `deps` / `reverse_deps` — file import graph forward and reverse.
|
|
17
17
|
- `callers` / `callees` / `references` / `implementations` — symbol relationship sites.
|
|
@@ -4,7 +4,8 @@ const GUIDANCE = `## Explore
|
|
|
4
4
|
Shape-backed languages: \`markdown\`, \`typescript\`, \`tsx\`, \`go\`, \`rust\`, \`c_sharp\`, \`java\`, \`kotlin\`, \`swift\`.
|
|
5
5
|
|
|
6
6
|
Structural tools (\`outline\`, \`show\`, \`discover\`, \`ast_search\`, deps/relationships, \`impact\`, \`context\`) apply to those languages. Other files: harness \`read\` / \`grep\` / \`find\` / \`ls\`.
|
|
7
|
-
Full \`read\` of a large registered source returns outline + follow-up hint, not the body — use ranged \`read\` or \`show
|
|
7
|
+
Full \`read\` of a large registered source returns outline + follow-up hint, not the body — use ranged \`read\` or \`show\`.
|
|
8
|
+
\`show\` always takes a top-level \`targets\` array, even for one declaration: \`{"targets":[{"path":"...","name":"..."}],"view":"declaration"}\`.`;
|
|
8
9
|
|
|
9
10
|
export function registerExploreGuidance(pi: ExtensionAPI): void {
|
|
10
11
|
pi.on("before_agent_start", (event) => ({ systemPrompt: `${event.systemPrompt}\n\n${GUIDANCE}` }));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { formatSize, keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
3
|
import { formatToolRowTitle, type ToolRowStateStore } from "../../../shared/tool-row-state.ts";
|
|
4
|
+
import { renderToolOutputPreview } from "../../../shared/text.ts";
|
|
4
5
|
|
|
5
6
|
export type ExploreToolDetails = {
|
|
6
7
|
declarationCount: number;
|
|
@@ -94,28 +95,21 @@ export function renderExploreResult(
|
|
|
94
95
|
context: { lastComponent?: Component; isError: boolean },
|
|
95
96
|
): Text {
|
|
96
97
|
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
98
|
+
const output = result.content
|
|
99
|
+
.filter((item): item is { type: string; text: string } => item.type === "text" && typeof item.text === "string")
|
|
100
|
+
.map((item) => item.text)
|
|
101
|
+
.join("\n");
|
|
97
102
|
if (!expanded && !context.isError) {
|
|
98
103
|
const count = result.details?.declarationCount ?? 0;
|
|
99
104
|
const noun = count === 1 ? "declaration" : "declarations";
|
|
100
105
|
const bytes = result.details === undefined ? "" : `, ${formatSize(result.details.returnedBytes)} returned`;
|
|
106
|
+
const summary = theme.fg("muted", `${count} ${noun}${bytes}`);
|
|
107
|
+
const preview = renderToolOutputPreview(output, false, theme);
|
|
101
108
|
text.setText(
|
|
102
|
-
|
|
103
|
-
keyHint("app.tools.expand", "to expand") +
|
|
104
|
-
theme.fg("muted", ")"),
|
|
109
|
+
preview ? `${summary}\n${preview}` : `${summary} (` + keyHint("app.tools.expand", "to expand") + ")",
|
|
105
110
|
);
|
|
106
111
|
return text;
|
|
107
112
|
}
|
|
108
|
-
|
|
109
|
-
.filter((item): item is { type: string; text: string } => item.type === "text" && typeof item.text === "string")
|
|
110
|
-
.map((item) => item.text)
|
|
111
|
-
.join("\n");
|
|
112
|
-
text.setText(
|
|
113
|
-
output
|
|
114
|
-
? output
|
|
115
|
-
.split("\n")
|
|
116
|
-
.map((line) => theme.fg("toolOutput", line))
|
|
117
|
-
.join("\n")
|
|
118
|
-
: "",
|
|
119
|
-
);
|
|
113
|
+
text.setText(renderToolOutputPreview(output, true, theme));
|
|
120
114
|
return text;
|
|
121
115
|
}
|
|
@@ -12,7 +12,7 @@ import { ExploreCallComponent, renderExploreResult, shrinkingListVariants, type
|
|
|
12
12
|
|
|
13
13
|
const showTargetSchema = Type.Object(
|
|
14
14
|
{
|
|
15
|
-
path: Type.String({ description: "Defining file" }),
|
|
15
|
+
path: Type.String({ description: "Defining file for this target" }),
|
|
16
16
|
name: Type.String({ minLength: 1, description: "Decl name; dotted Type.method ok" }),
|
|
17
17
|
line: Type.Optional(Type.Integer({ minimum: 1, description: "1-based line inside decl range" })),
|
|
18
18
|
},
|
|
@@ -23,7 +23,8 @@ const showParams = Type.Object(
|
|
|
23
23
|
{
|
|
24
24
|
targets: Type.Array(showTargetSchema, {
|
|
25
25
|
minItems: 1,
|
|
26
|
-
description:
|
|
26
|
+
description:
|
|
27
|
+
'Required top-level array, even for one declaration. For one target: [{"path":"...","name":"..."}].',
|
|
27
28
|
}),
|
|
28
29
|
view: StringEnum(["signature", "signatureWithDocs", "declaration", "declarationWithImports"] as const, {
|
|
29
30
|
description: "signature | signatureWithDocs | declaration | declarationWithImports",
|
|
@@ -68,13 +69,30 @@ export function createShowTool(rowState: ToolRowStateStore, engineFor: (cwd: str
|
|
|
68
69
|
name: "show",
|
|
69
70
|
label: "show",
|
|
70
71
|
description:
|
|
71
|
-
|
|
72
|
-
promptSnippet: "
|
|
72
|
+
'Show one or more declarations. Arguments always require a top-level `targets` array, even for one target: `{"targets":[{"path":"...","name":"..."}],"view":"declaration"}`. Whole batch fails on any missing/ambiguous target (candidate list, no partials). Views: signature → signatureWithDocs → declaration → declarationWithImports. Over budget throws — request fewer targets (bodies are not truncated).',
|
|
73
|
+
promptSnippet: "Show declarations using a top-level targets array",
|
|
73
74
|
promptGuidelines: [
|
|
75
|
+
"show always requires a top-level targets array, even for one declaration; do not pass path or name at the root.",
|
|
74
76
|
"Cheapest view that answers; declarationWithImports only when edits need imports.",
|
|
75
77
|
"Pin with path and line when names collide — do not guess.",
|
|
76
78
|
],
|
|
77
79
|
parameters: showParams,
|
|
80
|
+
prepareArguments(args) {
|
|
81
|
+
if (args === null || typeof args !== "object" || Array.isArray(args)) {
|
|
82
|
+
return args as Static<typeof showParams>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const input = args as Record<string, unknown>;
|
|
86
|
+
if ("targets" in input || typeof input.path !== "string" || typeof input.name !== "string") {
|
|
87
|
+
return input as Static<typeof showParams>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { path, name, line, ...rest } = input;
|
|
91
|
+
return {
|
|
92
|
+
...rest,
|
|
93
|
+
targets: [{ path, name, ...(line === undefined ? {} : { line }) }],
|
|
94
|
+
} as Static<typeof showParams>;
|
|
95
|
+
},
|
|
78
96
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
79
97
|
const engine = engineFor(ctx.cwd);
|
|
80
98
|
const abort = signal ?? new AbortController().signal;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { formatToolRowTitle, type ToolRowStateStore } from "../../shared/tool-row-state.js";
|
|
4
4
|
import { type ApplyPatchSummary, deriveStats } from "./executor.ts";
|
|
@@ -23,6 +23,7 @@ const REPLACE_FILE_MARKER = "*** Replace File: ";
|
|
|
23
23
|
const DELETE_FILE_MARKER = "*** Delete File: ";
|
|
24
24
|
const UPDATE_FILE_MARKER = "*** Update File: ";
|
|
25
25
|
const MOVE_TO_MARKER = "*** Move to: ";
|
|
26
|
+
const PATCH_PREVIEW_OPERATIONS = 10;
|
|
26
27
|
|
|
27
28
|
function topLevelDirective(line: string): string {
|
|
28
29
|
return line.trim();
|
|
@@ -156,6 +157,21 @@ function renderOpLine(op: PreviewOp, status: OpStatus | undefined, theme: Theme)
|
|
|
156
157
|
return ind ? `${label} ${ind}` : label;
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
function renderPreviewLines(preview: PreviewOp[], statuses: Map<number, OpStatus> | undefined, theme: Theme): string[] {
|
|
161
|
+
const lines = preview
|
|
162
|
+
.slice(0, PATCH_PREVIEW_OPERATIONS)
|
|
163
|
+
.map((op) => renderOpLine(op, statuses?.get(op.sectionIndex), theme));
|
|
164
|
+
const remaining = preview.length - lines.length;
|
|
165
|
+
if (remaining > 0) {
|
|
166
|
+
lines.push(
|
|
167
|
+
`${theme.fg("muted", `... (${remaining} more operations, ${preview.length} total,`)} ` +
|
|
168
|
+
keyHint("app.tools.expand", "to expand") +
|
|
169
|
+
theme.fg("muted", ")"),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return lines;
|
|
173
|
+
}
|
|
174
|
+
|
|
159
175
|
interface RenderCallContext {
|
|
160
176
|
expanded: boolean;
|
|
161
177
|
executionStarted: boolean;
|
|
@@ -198,7 +214,7 @@ export function renderPatchCall(args: { input?: string } | undefined, theme: The
|
|
|
198
214
|
text.setText(header);
|
|
199
215
|
return text;
|
|
200
216
|
}
|
|
201
|
-
const lines = preview
|
|
217
|
+
const lines = renderPreviewLines(preview, undefined, theme);
|
|
202
218
|
text.setText([header, ...lines].join("\n"));
|
|
203
219
|
return text;
|
|
204
220
|
}
|
|
@@ -234,7 +250,7 @@ export function renderPatchResult(
|
|
|
234
250
|
return text;
|
|
235
251
|
}
|
|
236
252
|
|
|
237
|
-
const lines = preview
|
|
253
|
+
const lines = renderPreviewLines(preview, statuses, theme);
|
|
238
254
|
text.setText([header, ...lines].join("\n"));
|
|
239
255
|
return text;
|
|
240
256
|
}
|
|
@@ -55,6 +55,8 @@ export default function referenceExtension(pi: ExtensionAPI): void {
|
|
|
55
55
|
if (!references) return;
|
|
56
56
|
|
|
57
57
|
ctx.ui.setEditorText(buildReferenceDraft(references));
|
|
58
|
+
// Pi does not request a render when setEditorText runs after custom UI closes.
|
|
59
|
+
ctx.ui.setStatus("reference", undefined);
|
|
58
60
|
},
|
|
59
61
|
});
|
|
60
62
|
}
|
|
@@ -34,7 +34,12 @@ export interface ReferenceItem {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
interface ReferenceListItem extends ReferenceItem, SelectableListItem {
|
|
37
|
-
state?: "updating" | "updated" | "failed" | "switching";
|
|
37
|
+
state?: "loading" | "updating" | "updated" | "failed" | "switching";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ReferenceLocation {
|
|
41
|
+
name: string;
|
|
42
|
+
path: string;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
interface CloneProgress {
|
|
@@ -71,9 +76,9 @@ export async function showReferencePanel(
|
|
|
71
76
|
editor: ReferenceEditor,
|
|
72
77
|
branchChoices: number,
|
|
73
78
|
): Promise<ReferenceItem[] | undefined> {
|
|
74
|
-
let initial:
|
|
79
|
+
let initial: ReferenceLocation[];
|
|
75
80
|
try {
|
|
76
|
-
initial = await
|
|
81
|
+
initial = await loadReferenceLocations();
|
|
77
82
|
} catch (error) {
|
|
78
83
|
ctx.ui.notify(`Reference load failed: ${errorText(error)}`, "error");
|
|
79
84
|
initial = [];
|
|
@@ -134,7 +139,7 @@ class ReferencePanel implements Component {
|
|
|
134
139
|
ctx: ExtensionCommandContext,
|
|
135
140
|
editor: ReferenceEditor,
|
|
136
141
|
branchChoices: number,
|
|
137
|
-
initial: readonly
|
|
142
|
+
initial: readonly ReferenceLocation[],
|
|
138
143
|
done: (result: ReferenceItem[] | undefined) => void,
|
|
139
144
|
) {
|
|
140
145
|
this.tui = tui;
|
|
@@ -144,7 +149,9 @@ class ReferencePanel implements Component {
|
|
|
144
149
|
this.editor = editor;
|
|
145
150
|
this.branchChoices = branchChoices;
|
|
146
151
|
this.done = done;
|
|
147
|
-
this.refs = initial.map((item) =>
|
|
152
|
+
this.refs = initial.map((item) =>
|
|
153
|
+
toListItem({ ...item, displayName: item.name, dirty: false, branch: "" }, "loading"),
|
|
154
|
+
);
|
|
148
155
|
this.list = this.createList(this.refs);
|
|
149
156
|
this.body = {
|
|
150
157
|
render: (width) => this.renderBody(width),
|
|
@@ -158,6 +165,7 @@ class ReferencePanel implements Component {
|
|
|
158
165
|
footer: { kind: "hints", hints: this.footerHints() },
|
|
159
166
|
};
|
|
160
167
|
this.panel = new ToolPanel(theme, this.panelConfig);
|
|
168
|
+
void this.hydrateInitialReferences(initial);
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
render(width: number): string[] {
|
|
@@ -596,6 +604,38 @@ class ReferencePanel implements Component {
|
|
|
596
604
|
this.syncPanel();
|
|
597
605
|
}
|
|
598
606
|
|
|
607
|
+
private async hydrateInitialReferences(locations: readonly ReferenceLocation[]): Promise<void> {
|
|
608
|
+
if (locations.length === 0) return;
|
|
609
|
+
const results = await Promise.all(
|
|
610
|
+
locations.map(async (location) => {
|
|
611
|
+
try {
|
|
612
|
+
return {
|
|
613
|
+
ok: true as const,
|
|
614
|
+
location,
|
|
615
|
+
item: await loadReference(this.git, location.name, location.path),
|
|
616
|
+
};
|
|
617
|
+
} catch (error) {
|
|
618
|
+
return { ok: false as const, location, error };
|
|
619
|
+
}
|
|
620
|
+
}),
|
|
621
|
+
);
|
|
622
|
+
const loaded = new Map<string, ReferenceItem>();
|
|
623
|
+
const failures: string[] = [];
|
|
624
|
+
for (const result of results) {
|
|
625
|
+
if (result.ok) loaded.set(result.location.path, result.item);
|
|
626
|
+
else failures.push(`${result.location.name}: ${errorText(result.error)}`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
this.refs = this.refs.map((item) => {
|
|
630
|
+
if (item.state !== "loading") return item;
|
|
631
|
+
const metadata = loaded.get(item.path);
|
|
632
|
+
return metadata ? toListItem(metadata) : { ...item, state: "failed" };
|
|
633
|
+
});
|
|
634
|
+
this.list.setItems(this.refs);
|
|
635
|
+
this.syncPanel();
|
|
636
|
+
if (failures.length > 0) this.ctx.ui.notify(`Reference metadata load failed:\n${failures.join("\n")}`, "error");
|
|
637
|
+
}
|
|
638
|
+
|
|
599
639
|
private syncPanel(): void {
|
|
600
640
|
this.panelConfig.secondary = this.secondaryText();
|
|
601
641
|
this.panelConfig.header = this.headerLines();
|
|
@@ -748,28 +788,39 @@ function compareReferenceItems(left: ReferenceListItem, right: ReferenceListItem
|
|
|
748
788
|
return left.name.localeCompare(right.name);
|
|
749
789
|
}
|
|
750
790
|
|
|
751
|
-
async function
|
|
791
|
+
async function loadReferenceLocations(): Promise<ReferenceLocation[]> {
|
|
752
792
|
await mkdir(REFERENCES_DIR, { recursive: true });
|
|
753
|
-
|
|
793
|
+
return (await readdir(REFERENCES_DIR, { withFileTypes: true }))
|
|
754
794
|
.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".clone-"))
|
|
755
795
|
.map((entry) => ({ name: entry.name, path: join(REFERENCES_DIR, entry.name) }))
|
|
756
796
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
797
|
+
}
|
|
757
798
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
return
|
|
799
|
+
async function loadReferences(git: GitRunner): Promise<ReferenceItem[]> {
|
|
800
|
+
const refs = await loadReferenceLocations();
|
|
801
|
+
return Promise.all(refs.map((ref) => loadReference(git, ref.name, ref.path)));
|
|
761
802
|
}
|
|
762
803
|
|
|
763
804
|
async function loadReference(git: GitRunner, name: string, path: string): Promise<ReferenceItem> {
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
805
|
+
const [status, remoteUrl] = await Promise.all([
|
|
806
|
+
git.run(["status", "--porcelain=v2", "--branch"], { cwd: path, optional: true }),
|
|
807
|
+
git.run(["config", "--get", "remote.origin.url"], { cwd: path, optional: true }),
|
|
808
|
+
]);
|
|
809
|
+
let branch = "";
|
|
810
|
+
let commit = "";
|
|
811
|
+
let dirty = false;
|
|
812
|
+
for (const line of status.split("\n")) {
|
|
813
|
+
if (line.startsWith("# branch.head ")) branch = line.slice("# branch.head ".length);
|
|
814
|
+
else if (line.startsWith("# branch.oid ")) commit = line.slice("# branch.oid ".length).slice(0, 7);
|
|
815
|
+
else if (line && !line.startsWith("# ")) dirty = true;
|
|
816
|
+
}
|
|
817
|
+
if (branch === "(detached)") branch = commit ? `detached ${commit}` : "";
|
|
767
818
|
return {
|
|
768
819
|
name,
|
|
769
820
|
displayName: referenceDisplayName(remoteUrl, name),
|
|
770
821
|
path,
|
|
771
|
-
dirty
|
|
772
|
-
branch
|
|
822
|
+
dirty,
|
|
823
|
+
branch,
|
|
773
824
|
};
|
|
774
825
|
}
|
|
775
826
|
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
# Review
|
|
2
2
|
|
|
3
|
-
Run `/review` to inspect current repository's staged, unstaged, and untracked work in a fresh isolated session.
|
|
3
|
+
Run `/review` without arguments to inspect the current repository's staged, unstaged, and untracked work in a fresh isolated session. This form stops when the working tree is clean.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- `architecture` performs a nuclear maintainability review and accepts substantial redesign when ownership, reuse, or structure is poor.
|
|
7
|
-
- `correctness` checks runtime bugs and failures after architecture is accepted.
|
|
5
|
+
Add review direction after the command to review the requested part of the repository instead, regardless of whether the working tree has changes:
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
```text
|
|
8
|
+
/review focus on cancellation, cleanup, and data loss
|
|
9
|
+
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Choose one focused review type:
|
|
12
|
+
|
|
13
|
+
- `Simplify` looks for code and concepts that can disappear or reuse what already exists.
|
|
14
|
+
- `Architecture` reconsiders ownership, boundaries, reuse, and overall structure.
|
|
15
|
+
- `Correctness` checks concrete runtime bugs and failure paths after accepting the architecture.
|
|
16
|
+
|
|
17
|
+
Then choose which logged-in provider runs the review. OpenAI Codex uses `gpt-5.6-sol` and Anthropic uses `claude-opus-5`, both at high thinking. Only providers you are logged in to appear. With no logged-in provider, the review uses the current model.
|
|
18
|
+
|
|
19
|
+
Tau writes each result as Markdown under `.pi/tau/reviews/`. Review results do not enter the parent agent context. Reference the Markdown file later when you want an agent to use it.
|
|
@@ -1,147 +1,94 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type { ExtensionAPI, ExtensionContext
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { createGitRunner, loadRepoStatus } from "../../shared/git.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { resolveEffortProviders } from "../../shared/model-effort.ts";
|
|
6
6
|
import { errorText } from "../../shared/text.ts";
|
|
7
|
-
import {
|
|
8
|
-
formatReviewMarkdown,
|
|
9
|
-
isReviewRecord,
|
|
10
|
-
isReviewMode,
|
|
11
|
-
REVIEW_ENTRY_TYPE,
|
|
12
|
-
type ReviewMode,
|
|
13
|
-
type ReviewRecord,
|
|
14
|
-
} from "./model.ts";
|
|
15
|
-
import { ReviewProgressPanel, ReviewResultPanel, type ReviewResultAction } from "./panel.ts";
|
|
7
|
+
import { formatReviewMarkdown, type ReviewMode } from "./model.ts";
|
|
16
8
|
import { runReview } from "./session.ts";
|
|
17
9
|
|
|
10
|
+
const REVIEW_TYPES = [
|
|
11
|
+
{ label: "Simplify", mode: "simplify" },
|
|
12
|
+
{ label: "Architecture", mode: "architecture" },
|
|
13
|
+
{ label: "Correctness", mode: "correctness" },
|
|
14
|
+
] as const satisfies ReadonlyArray<{ label: string; mode: ReviewMode }>;
|
|
15
|
+
|
|
18
16
|
export default function reviewExtension(pi: ExtensionAPI): void {
|
|
19
17
|
pi.registerCommand("review", {
|
|
20
|
-
description: "
|
|
18
|
+
description: "Write an isolated requested or Git-change review to Markdown",
|
|
21
19
|
handler: async (args, ctx) => {
|
|
22
20
|
if (ctx.mode !== "tui" || !ctx.isProjectTrusted()) {
|
|
23
21
|
ctx.ui.notify("/review requires a trusted TUI project", "warning");
|
|
24
22
|
return;
|
|
25
23
|
}
|
|
26
24
|
await ctx.waitForIdle();
|
|
27
|
-
const
|
|
28
|
-
if (requested === "show") {
|
|
29
|
-
await showLatestReview(pi, ctx);
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
const mode = await resolveReviewMode(requested, ctx);
|
|
33
|
-
if (!mode) return;
|
|
25
|
+
const direction = args.trim();
|
|
34
26
|
const status = await loadRepoStatus(createGitRunner(pi, ctx));
|
|
35
27
|
if (!status) {
|
|
36
28
|
ctx.ui.notify("/review requires a Git repository", "warning");
|
|
37
29
|
return;
|
|
38
30
|
}
|
|
39
|
-
if (status.fileCount === 0) {
|
|
31
|
+
if (!direction && status.fileCount === 0) {
|
|
40
32
|
ctx.ui.notify("Nothing to review: working tree is clean", "info");
|
|
41
33
|
return;
|
|
42
34
|
}
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
35
|
+
const selectedType = await ctx.ui.select(
|
|
36
|
+
"Review type",
|
|
37
|
+
REVIEW_TYPES.map(({ label }) => label),
|
|
38
|
+
);
|
|
39
|
+
if (!selectedType) return;
|
|
40
|
+
const mode = REVIEW_TYPES.find(({ label }) => label === selectedType)?.mode;
|
|
41
|
+
if (!mode) return;
|
|
42
|
+
const providers = resolveEffortProviders(ctx, "deep");
|
|
43
|
+
let preferred: ReviewModelChoice | undefined;
|
|
44
|
+
if (providers.length > 0) {
|
|
45
|
+
const labels = providers.map((provider) => `${provider.label} · ${provider.candidates[0]?.model.id ?? ""}`);
|
|
46
|
+
const selected = await ctx.ui.select("Review provider", labels);
|
|
47
|
+
if (!selected) return;
|
|
48
|
+
const provider = providers[labels.indexOf(selected)];
|
|
49
|
+
const candidate = provider?.candidates[0];
|
|
50
|
+
if (provider && candidate) {
|
|
51
|
+
preferred = { model: `${provider.provider}/${candidate.model.id}`, thinkingLevel: candidate.reasoning };
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
ctx.ui.notify("No logged-in review provider. Using current model.", "warning");
|
|
55
|
+
}
|
|
56
|
+
const signal = ctx.signal ?? new AbortController().signal;
|
|
57
|
+
ctx.ui.setStatus("review", `running ${mode} review`);
|
|
58
|
+
try {
|
|
59
|
+
const output = await runReview({
|
|
60
|
+
ctx,
|
|
61
|
+
root: status.root,
|
|
62
|
+
mode,
|
|
63
|
+
direction,
|
|
64
|
+
preferredModel: preferred?.model,
|
|
65
|
+
preferredThinkingLevel: preferred?.thinkingLevel,
|
|
66
|
+
parentThinkingLevel: pi.getThinkingLevel(),
|
|
67
|
+
signal,
|
|
68
|
+
});
|
|
69
|
+
const createdAt = new Date().toISOString();
|
|
70
|
+
const directory = join(status.root, ".pi", "tau", "reviews");
|
|
71
|
+
const timestamp = createdAt.replace(/[^0-9A-Za-z-]/g, "-");
|
|
72
|
+
const path = join(directory, `${timestamp}-${mode}-review.md`);
|
|
73
|
+
await mkdir(directory, { recursive: true });
|
|
74
|
+
await writeFile(path, formatReviewMarkdown({ ...output, mode, direction, createdAt }), {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
mode: 0o600,
|
|
77
|
+
});
|
|
78
|
+
ctx.ui.notify(`Review written to ${path}`, "info");
|
|
79
|
+
} catch (error) {
|
|
80
|
+
ctx.ui.notify(
|
|
81
|
+
signal.aborted ? "Review cancelled" : `Review failed: ${errorText(error)}`,
|
|
82
|
+
signal.aborted ? "info" : "error",
|
|
83
|
+
);
|
|
84
|
+
} finally {
|
|
85
|
+
ctx.ui.setStatus("review", undefined);
|
|
86
|
+
}
|
|
47
87
|
},
|
|
48
88
|
});
|
|
49
89
|
}
|
|
50
90
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (entry?.type !== "custom" || entry.customType !== REVIEW_ENTRY_TYPE) continue;
|
|
55
|
-
if (isReviewRecord(entry.data)) return entry.data;
|
|
56
|
-
}
|
|
57
|
-
return undefined;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function showLatestReview(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
61
|
-
const latest = latestReview(ctx.sessionManager.getBranch());
|
|
62
|
-
if (!latest) {
|
|
63
|
-
ctx.ui.notify("No review exists on this session branch", "warning");
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
await showReview(pi, ctx, latest);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function resolveReviewMode(requested: string, ctx: ExtensionContext): Promise<ReviewMode | undefined> {
|
|
70
|
-
if (requested) {
|
|
71
|
-
if (!isReviewMode(requested)) {
|
|
72
|
-
ctx.ui.notify("Usage: /review [simplify|architecture|correctness|show]", "warning");
|
|
73
|
-
return undefined;
|
|
74
|
-
}
|
|
75
|
-
return requested;
|
|
76
|
-
}
|
|
77
|
-
const selected = await ctx.ui.select("Review mode", ["Simplify", "Architecture", "Correctness"]);
|
|
78
|
-
if (!selected) return undefined;
|
|
79
|
-
const normalized = selected.toLowerCase();
|
|
80
|
-
return isReviewMode(normalized) ? normalized : undefined;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function runReviewWithPanel(
|
|
84
|
-
pi: ExtensionAPI,
|
|
85
|
-
ctx: ExtensionContext,
|
|
86
|
-
mode: ReviewMode,
|
|
87
|
-
root: string,
|
|
88
|
-
): Promise<ReviewRecord | undefined> {
|
|
89
|
-
const controller = new AbortController();
|
|
90
|
-
const signal = ctx.signal ? AbortSignal.any([controller.signal, ctx.signal]) : controller.signal;
|
|
91
|
-
let failure: string | undefined;
|
|
92
|
-
const output = await ctx.ui.custom<ReviewRecord | undefined>((tui, theme, keys, done) => {
|
|
93
|
-
const panel = new ReviewProgressPanel(tui, theme, mode, keys, () => controller.abort());
|
|
94
|
-
void runReview({
|
|
95
|
-
ctx,
|
|
96
|
-
root,
|
|
97
|
-
mode,
|
|
98
|
-
parentThinkingLevel: pi.getThinkingLevel(),
|
|
99
|
-
signal,
|
|
100
|
-
onProgress: (line) => panel.update(line),
|
|
101
|
-
})
|
|
102
|
-
.then((result) => done({ ...result, mode, root, createdAt: new Date().toISOString() }))
|
|
103
|
-
.catch((error: unknown) => {
|
|
104
|
-
failure = errorText(error);
|
|
105
|
-
done(undefined);
|
|
106
|
-
});
|
|
107
|
-
return panel;
|
|
108
|
-
});
|
|
109
|
-
if (output) return output;
|
|
110
|
-
ctx.ui.notify(
|
|
111
|
-
signal.aborted ? "Review cancelled" : `Review failed: ${failure ?? "unknown error"}`,
|
|
112
|
-
signal.aborted ? "info" : "error",
|
|
113
|
-
);
|
|
114
|
-
return undefined;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function showReview(pi: ExtensionAPI, ctx: ExtensionContext, review: ReviewRecord): Promise<void> {
|
|
118
|
-
const action = await ctx.ui.custom<ReviewResultAction>(
|
|
119
|
-
(tui, theme, keys, done) => new ReviewResultPanel(tui, theme, keys, review, done),
|
|
120
|
-
{
|
|
121
|
-
overlay: true,
|
|
122
|
-
overlayOptions: { anchor: "top-center", width: "80%", minWidth: 68, maxHeight: "90%", margin: 1 },
|
|
123
|
-
},
|
|
124
|
-
);
|
|
125
|
-
if (action === "send") {
|
|
126
|
-
try {
|
|
127
|
-
await pi.sendMessage(
|
|
128
|
-
createInjectedContext(formatReviewMarkdown(review), { source: "review", title: `${review.mode} review` }),
|
|
129
|
-
{ triggerTurn: false, deliverAs: "nextTurn" },
|
|
130
|
-
);
|
|
131
|
-
ctx.ui.notify("Review queued for agent's next turn", "info");
|
|
132
|
-
} catch (error) {
|
|
133
|
-
ctx.ui.notify(`Failed to send review: ${errorText(error)}`, "error");
|
|
134
|
-
}
|
|
135
|
-
} else if (action === "export") {
|
|
136
|
-
try {
|
|
137
|
-
const directory = join(review.root, ".pi", "tau", "reviews");
|
|
138
|
-
await mkdir(directory, { recursive: true });
|
|
139
|
-
const timestamp = review.createdAt.replace(/[^0-9A-Za-z-]/g, "-");
|
|
140
|
-
const path = join(directory, `${timestamp}-${review.mode}.md`);
|
|
141
|
-
await writeFile(path, formatReviewMarkdown(review), { encoding: "utf8", mode: 0o600 });
|
|
142
|
-
ctx.ui.notify(`Review exported to ${path}`, "info");
|
|
143
|
-
} catch (error) {
|
|
144
|
-
ctx.ui.notify(`Failed to export review: ${errorText(error)}`, "error");
|
|
145
|
-
}
|
|
146
|
-
}
|
|
91
|
+
interface ReviewModelChoice {
|
|
92
|
+
model: string;
|
|
93
|
+
thinkingLevel: NonNullable<ExtensionContext["thinkingLevel"]>;
|
|
147
94
|
}
|