@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.
Files changed (35) hide show
  1. package/extensions/checkpoint/checkpoint-budget.ts +1 -14
  2. package/extensions/checkpoint/checkpoint.ts +2 -0
  3. package/extensions/checkpoint/index.ts +1 -1
  4. package/extensions/checkpoint/messages.ts +1 -0
  5. package/extensions/commit/commit-plan.ts +2 -2
  6. package/extensions/explore/README.md +2 -2
  7. package/extensions/explore/guidance.ts +2 -1
  8. package/extensions/explore/tools/render.ts +9 -15
  9. package/extensions/explore/tools/show.ts +22 -4
  10. package/extensions/patch/render.ts +19 -3
  11. package/extensions/reference/index.ts +2 -0
  12. package/extensions/reference/panel.ts +66 -15
  13. package/extensions/review/README.md +14 -6
  14. package/extensions/review/index.ts +67 -120
  15. package/extensions/review/model.ts +13 -33
  16. package/extensions/review/session.ts +9 -23
  17. package/extensions/script-runner/index.ts +14 -0
  18. package/extensions/soul/README.md +2 -2
  19. package/extensions/soul/index.ts +4 -4
  20. package/extensions/soul/prompt.ts +6 -10
  21. package/extensions/soul/settings.ts +6 -3
  22. package/extensions/subagent/agents/scout.md +1 -1
  23. package/extensions/subagent/agents/web-research.md +2 -2
  24. package/extensions/subagent/run.ts +6 -2
  25. package/extensions/tau-help/help.md +7 -3
  26. package/extensions/tool-approval/README.md +20 -0
  27. package/extensions/tool-approval/index.ts +270 -0
  28. package/extensions/tool-approval/settings.ts +24 -0
  29. package/extensions/web/tool-output.ts +4 -6
  30. package/package.json +2 -2
  31. package/schemas/tau.schema.json +18 -2
  32. package/shared/isolated-session.ts +1 -1
  33. package/shared/model-effort.ts +14 -3
  34. package/shared/text.ts +21 -0
  35. 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
- if (baselineTokens === null) {
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. Targets use path + declaration name, with an optional line to disambiguate.
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 path+name targets.
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
- theme.fg("muted", `${count} ${noun}${bytes} (`) +
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
- const output = result.content
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: "path+name targets (optional line pin)",
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
- "Exact signature or source for path+name targets. 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).",
72
- promptSnippet: "Signature or source for path+name targets",
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 { Theme } from "@earendil-works/pi-coding-agent";
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.map((op) => renderOpLine(op, "pending", theme));
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.map((op) => renderOpLine(op, statuses.get(op.sectionIndex), theme));
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: ReferenceItem[];
79
+ let initial: ReferenceLocation[];
75
80
  try {
76
- initial = await loadReferences(git);
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 ReferenceItem[],
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) => toListItem(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 loadReferences(git: GitRunner): Promise<ReferenceItem[]> {
791
+ async function loadReferenceLocations(): Promise<ReferenceLocation[]> {
752
792
  await mkdir(REFERENCES_DIR, { recursive: true });
753
- const refs = (await readdir(REFERENCES_DIR, { withFileTypes: true }))
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
- const references: ReferenceItem[] = [];
759
- for (const ref of refs) references.push(await loadReference(git, ref.name, ref.path));
760
- return references;
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 branch = await git.run(["branch", "--show-current"], { cwd: path, optional: true });
765
- const commit = branch ? "" : await git.run(["rev-parse", "--short", "HEAD"], { cwd: path, optional: true });
766
- const remoteUrl = await git.run(["config", "--get", "remote.origin.url"], { cwd: path, optional: true });
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: (await git.run(["status", "--porcelain=v1"], { cwd: path, optional: true })).length > 0,
772
- branch: branch || (commit ? `detached ${commit}` : ""),
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. Choose one focused pass:
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
- - `simplify` looks for code and concepts that can disappear or reuse what already exists.
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
- Run a mode directly with `/review simplify`, `/review architecture`, or `/review correctness`. Run `/review show` to reopen latest result on current session branch.
7
+ ```text
8
+ /review focus on cancellation, cleanup, and data loss
9
+ ```
10
10
 
11
- Results stay outside parent agent context. In result view, press `a` to send review to agent's next turn or `e` to export Markdown under `.pi/tau/reviews/`.
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, SessionEntry } from "@earendil-works/pi-coding-agent";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { createGitRunner, loadRepoStatus } from "../../shared/git.ts";
5
- import { createInjectedContext } from "../../shared/injected-context.ts";
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: "Run or show an isolated simplify, architecture, or correctness review",
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 requested = args.trim().toLowerCase();
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 output = await runReviewWithPanel(pi, ctx, mode, status.root);
44
- if (!output) return;
45
- pi.appendEntry(REVIEW_ENTRY_TYPE, output);
46
- await showReview(pi, ctx, output);
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
- function latestReview(entries: readonly SessionEntry[]): ReviewRecord | undefined {
52
- for (let index = entries.length - 1; index >= 0; index -= 1) {
53
- const entry = entries[index];
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
  }