@shanepadgett/tau-agent 0.7.2 → 0.9.1
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/appshot/index.ts +3 -13
- package/extensions/context/README.md +16 -1
- package/extensions/context/definitions.ts +41 -0
- package/extensions/context/index.ts +77 -25
- package/extensions/context/settings.ts +33 -0
- package/extensions/context/sync.ts +77 -37
- package/extensions/context/validation.ts +73 -0
- package/extensions/image-gen/index.ts +1 -8
- package/extensions/publish/README.md +1 -1
- package/extensions/publish/index.ts +1 -0
- package/extensions/runtime-context/README.md +5 -0
- package/extensions/runtime-context/context.ts +138 -0
- package/extensions/runtime-context/index.ts +84 -0
- package/extensions/silent-command-runner/README.md +1 -1
- package/extensions/silent-command-runner/index.ts +9 -51
- package/extensions/soul/README.md +2 -2
- package/extensions/soul/index.ts +3 -6
- package/extensions/soul/prompt.ts +3 -232
- package/extensions/subagent/index.ts +10 -15
- package/extensions/tau-help/help.md +11 -3
- package/extensions/tau-help/index.ts +12 -0
- package/extensions/tool-loader/README.md +13 -0
- package/extensions/tool-loader/index.ts +134 -0
- package/extensions/turn-budget/index.ts +4 -10
- package/extensions/web/codesearch.ts +1 -7
- package/extensions/web/webfetch.ts +1 -7
- package/extensions/web/websearch.ts +1 -7
- package/package.json +2 -2
- package/schemas/tau.schema.json +25 -0
- package/shared/glob.ts +34 -0
- package/shared/model-fallback/index.ts +7 -4
|
@@ -116,11 +116,7 @@ function registerAppshotTools(pi: ExtensionAPI, runHelper: RunHelper): void {
|
|
|
116
116
|
name: "list_windows",
|
|
117
117
|
label: "List Windows",
|
|
118
118
|
description:
|
|
119
|
-
"List visible normal macOS windows as compact TOON with window IDs, titles, application identity, process IDs, and bounds. Requires macOS 14 or newer and Screen & System Audio Recording permission.",
|
|
120
|
-
promptSnippet: "Discover visible macOS application windows and their IDs",
|
|
121
|
-
promptGuidelines: [
|
|
122
|
-
"Use list_windows to discover graphical applications and select an exact window before calling screenshot_window.",
|
|
123
|
-
],
|
|
119
|
+
"List visible normal macOS windows as compact TOON with window IDs, titles, application identity, process IDs, and bounds. Use list_windows to discover exact window IDs and application PIDs before screenshot_window or activate_app. Requires macOS 14 or newer and Screen & System Audio Recording permission.",
|
|
124
120
|
parameters: listWindowsSchema,
|
|
125
121
|
async execute(_toolCallId, _params, signal) {
|
|
126
122
|
if (process.platform !== "darwin") throw new Error("list_windows is only available on macOS");
|
|
@@ -158,9 +154,7 @@ function registerAppshotTools(pi: ExtensionAPI, runHelper: RunHelper): void {
|
|
|
158
154
|
name: "screenshot_window",
|
|
159
155
|
label: "Screenshot Window",
|
|
160
156
|
description:
|
|
161
|
-
"Capture one visible macOS window by
|
|
162
|
-
promptSnippet: "Capture and inspect an exact visible macOS window by window ID",
|
|
163
|
-
promptGuidelines: ["Call list_windows before screenshot_window; pass its exact window_id and a PNG path."],
|
|
157
|
+
"Capture one visible macOS window by an exact ID returned by list_windows, resize it to fit within 1568×1568 pixels, save it to the required PNG path, and inspect the image. Call list_windows first.",
|
|
164
158
|
parameters: screenshotWindowSchema,
|
|
165
159
|
async execute(_toolCallId, params: ScreenshotWindowParams, signal, onUpdate, ctx) {
|
|
166
160
|
if (process.platform !== "darwin") throw new Error("screenshot_window is only available on macOS");
|
|
@@ -219,11 +213,7 @@ function registerAppshotTools(pi: ExtensionAPI, runHelper: RunHelper): void {
|
|
|
219
213
|
name: "activate_app",
|
|
220
214
|
label: "Activate App",
|
|
221
215
|
description:
|
|
222
|
-
"Bring a running macOS application and its windows to the foreground by process ID. Use
|
|
223
|
-
promptSnippet: "Bring a listed macOS application to the foreground by process ID",
|
|
224
|
-
promptGuidelines: [
|
|
225
|
-
"Use activate_app only when foregrounding a listed application is needed for visual validation because it changes user focus.",
|
|
226
|
-
],
|
|
216
|
+
"Bring a running macOS application and its windows to the foreground by a process ID returned by list_windows. Use only when foregrounding is required for visual validation because activate_app changes user focus.",
|
|
227
217
|
parameters: activateAppSchema,
|
|
228
218
|
async execute(_toolCallId, params, signal) {
|
|
229
219
|
if (process.platform !== "darwin") throw new Error("activate_app is only available on macOS");
|
|
@@ -2,7 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Context stores reusable repository work scopes in `.pi/contexts`. Folder names become selector tabs, TOML files become concepts, and TOML sections become selectable entries.
|
|
4
4
|
|
|
5
|
-
Use `/context` to select entries and inject their files through Tau autoread. Use `/context-sync` to reconcile affected scopes from the current Git changes.
|
|
5
|
+
Use `/context` to select entries and inject their files through Tau autoread. Use `/context-sync` to reconcile affected scopes from the current Git changes. Tau validates context membership after agent turns and asks the agent to sync uncovered changed files or stale references automatically.
|
|
6
|
+
|
|
7
|
+
Validation is enabled by default. Configure it globally or per project in Tau settings:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"extensions": {
|
|
12
|
+
"context": {
|
|
13
|
+
"validation": {
|
|
14
|
+
"enabled": true,
|
|
15
|
+
"ignoreGlobs": ["generated/**"]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
```
|
|
6
21
|
|
|
7
22
|
```toml
|
|
8
23
|
name = "Player"
|
|
@@ -2,6 +2,7 @@ import { access, readFile, readdir, stat } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { parse } from "smol-toml";
|
|
5
|
+
import { matchGlob } from "../../shared/glob.ts";
|
|
5
6
|
|
|
6
7
|
export interface ContextEntry {
|
|
7
8
|
id: string;
|
|
@@ -15,6 +16,46 @@ export interface ContextEntry {
|
|
|
15
16
|
path: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
const CONTEXT_IGNORED_FILENAMES = new Set([
|
|
20
|
+
"bun.lock",
|
|
21
|
+
"bun.lockb",
|
|
22
|
+
"Cargo.lock",
|
|
23
|
+
"composer.lock",
|
|
24
|
+
"flake.lock",
|
|
25
|
+
"Gemfile.lock",
|
|
26
|
+
"go.sum",
|
|
27
|
+
"mix.lock",
|
|
28
|
+
"npm-shrinkwrap.json",
|
|
29
|
+
"package-lock.json",
|
|
30
|
+
"Package.resolved",
|
|
31
|
+
"Pipfile.lock",
|
|
32
|
+
"pnpm-lock.yaml",
|
|
33
|
+
"Podfile.lock",
|
|
34
|
+
"poetry.lock",
|
|
35
|
+
"pubspec.lock",
|
|
36
|
+
"uv.lock",
|
|
37
|
+
"yarn.lock",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export function isContextEligiblePath(path: string, ignoreGlobs: readonly string[] = []): boolean {
|
|
41
|
+
return (
|
|
42
|
+
path !== "LICENSE" &&
|
|
43
|
+
!CONTEXT_IGNORED_FILENAMES.has(basename(path)) &&
|
|
44
|
+
!ignoreGlobs.some((glob) => matchGlob(glob, path)) &&
|
|
45
|
+
path !== ".pi/tau/ideas.jsonl" &&
|
|
46
|
+
path !== ".working" &&
|
|
47
|
+
!path.startsWith(".working/") &&
|
|
48
|
+
path !== ".pi/contexts" &&
|
|
49
|
+
!path.startsWith(".pi/contexts/")
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isSensitiveContextPath(path: string): boolean {
|
|
54
|
+
const name = basename(path);
|
|
55
|
+
if (name === ".env.example" || name === ".env.sample") return false;
|
|
56
|
+
return name === ".env" || name.startsWith(".env.") || /\.(?:pem|key|crt|p12|pfx)$/i.test(name);
|
|
57
|
+
}
|
|
58
|
+
|
|
18
59
|
export async function pathExists(path: string): Promise<boolean> {
|
|
19
60
|
try {
|
|
20
61
|
await access(path);
|
|
@@ -4,10 +4,14 @@ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { Text } from "@earendil-works/pi-tui";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
import { emitTauEvent } from "../../shared/events.ts";
|
|
7
|
+
import { createGitRunner, loadRepoStatus } from "../../shared/git.ts";
|
|
7
8
|
import { createInjectedContext } from "../../shared/injected-context.ts";
|
|
9
|
+
import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
8
10
|
import { ContextPanel } from "./panel.ts";
|
|
9
11
|
import { findProjectRoot, loadContextEntries, type ContextEntry } from "./definitions.ts";
|
|
12
|
+
import contextSettings from "./settings.ts";
|
|
10
13
|
import { runContextSync, type ContextSyncDetails } from "./sync.ts";
|
|
14
|
+
import { formatContextValidationFailure, validateContextCatalog } from "./validation.ts";
|
|
11
15
|
|
|
12
16
|
const contextSyncParams = Type.Object({}, { additionalProperties: false });
|
|
13
17
|
|
|
@@ -15,13 +19,16 @@ function compactResult(details: ContextSyncDetails) {
|
|
|
15
19
|
const value = {
|
|
16
20
|
outcome: details.outcome,
|
|
17
21
|
summary: details.summary,
|
|
22
|
+
reason: details.reason,
|
|
18
23
|
changedContextFiles: details.changedContextFiles,
|
|
19
24
|
};
|
|
20
25
|
return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details };
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export default function contextExtension(pi: ExtensionAPI): void {
|
|
24
|
-
let
|
|
29
|
+
let validationSettings = contextSettings.defaults.validation;
|
|
30
|
+
let lastValidationFailure: string | undefined;
|
|
31
|
+
const activeToolExecutions = new Map<string, { toolName: string; done: Promise<void>; complete: () => void }>();
|
|
25
32
|
|
|
26
33
|
pi.registerCommand("context", {
|
|
27
34
|
description: "Select repository context entries and inject their files",
|
|
@@ -45,8 +52,6 @@ export default function contextExtension(pi: ExtensionAPI): void {
|
|
|
45
52
|
},
|
|
46
53
|
);
|
|
47
54
|
if (!selected?.length) return;
|
|
48
|
-
active = selected;
|
|
49
|
-
pi.appendEntry("tau.context-selection", { ids: selected.map((entry) => entry.id) });
|
|
50
55
|
const files = [...new Set(selected.flatMap((entry) => entry.files))].sort();
|
|
51
56
|
pi.sendMessage(
|
|
52
57
|
createInjectedContext(
|
|
@@ -92,9 +97,15 @@ export default function contextExtension(pi: ExtensionAPI): void {
|
|
|
92
97
|
defineTool<typeof contextSyncParams, ContextSyncDetails | undefined>({
|
|
93
98
|
name: "context_sync",
|
|
94
99
|
label: "context_sync",
|
|
95
|
-
description:
|
|
100
|
+
description:
|
|
101
|
+
"Synchronize repository context membership after context validation reports changed, stale, or unassigned files.",
|
|
96
102
|
parameters: contextSyncParams,
|
|
97
|
-
async execute(
|
|
103
|
+
async execute(id, _params, _signal, onUpdate, ctx) {
|
|
104
|
+
await Promise.all(
|
|
105
|
+
[...activeToolExecutions]
|
|
106
|
+
.filter(([toolCallId, execution]) => toolCallId !== id && execution.toolName !== "context_sync")
|
|
107
|
+
.map(([, execution]) => execution.done),
|
|
108
|
+
);
|
|
98
109
|
return compactResult(
|
|
99
110
|
await runContextSync(pi, ctx, (status) =>
|
|
100
111
|
onUpdate?.({ content: [{ type: "text", text: status }], details: undefined }),
|
|
@@ -131,26 +142,67 @@ export default function contextExtension(pi: ExtensionAPI): void {
|
|
|
131
142
|
}),
|
|
132
143
|
);
|
|
133
144
|
|
|
145
|
+
pi.on("tool_execution_start", (event) => {
|
|
146
|
+
let complete = () => {};
|
|
147
|
+
const done = new Promise<void>((resolve) => {
|
|
148
|
+
complete = resolve;
|
|
149
|
+
});
|
|
150
|
+
activeToolExecutions.set(event.toolCallId, { toolName: event.toolName, done, complete });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
pi.on("tool_execution_end", (event) => {
|
|
154
|
+
const execution = activeToolExecutions.get(event.toolCallId);
|
|
155
|
+
activeToolExecutions.delete(event.toolCallId);
|
|
156
|
+
execution?.complete();
|
|
157
|
+
});
|
|
158
|
+
|
|
134
159
|
pi.on("session_start", async (_event, ctx) => {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
160
|
+
validationSettings = (await loadTauExtensionSettings(ctx, contextSettings)).validation;
|
|
161
|
+
});
|
|
162
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
163
|
+
validationSettings = (await loadTauExtensionSettings(ctx, contextSettings)).validation;
|
|
164
|
+
});
|
|
165
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
166
|
+
if (
|
|
167
|
+
event.messages.some(
|
|
168
|
+
(message) =>
|
|
169
|
+
typeof message === "object" &&
|
|
170
|
+
message !== null &&
|
|
171
|
+
"role" in message &&
|
|
172
|
+
message.role === "assistant" &&
|
|
173
|
+
"stopReason" in message &&
|
|
174
|
+
message.stopReason === "aborted",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
return;
|
|
178
|
+
if (!validationSettings.enabled || !ctx.isProjectTrusted()) {
|
|
179
|
+
lastValidationFailure = undefined;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const root = await findProjectRoot(ctx.cwd);
|
|
184
|
+
const git = createGitRunner(pi, ctx);
|
|
185
|
+
if (!(await loadRepoStatus(git))) return;
|
|
186
|
+
const failure = formatContextValidationFailure(
|
|
187
|
+
await validateContextCatalog(git, root, validationSettings.ignoreGlobs),
|
|
188
|
+
);
|
|
189
|
+
if (!failure) {
|
|
190
|
+
lastValidationFailure = undefined;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (failure === lastValidationFailure) return;
|
|
194
|
+
lastValidationFailure = failure;
|
|
195
|
+
ctx.ui.notify("Context catalog validation failed", "error");
|
|
196
|
+
pi.sendMessage(
|
|
197
|
+
{ customType: "tau.context-validation", content: failure, display: false },
|
|
198
|
+
{ triggerTurn: true },
|
|
199
|
+
);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
ctx.ui.notify(`Context validation failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
pi.on("session_shutdown", () => {
|
|
205
|
+
for (const execution of activeToolExecutions.values()) execution.complete();
|
|
206
|
+
activeToolExecutions.clear();
|
|
148
207
|
});
|
|
149
|
-
pi.on("before_agent_start", (event) =>
|
|
150
|
-
active.length
|
|
151
|
-
? {
|
|
152
|
-
systemPrompt: `${event.systemPrompt}\n\nTreat the autoread files as the authoritative project context and current snapshots. Do not reread them or search for coverage around them. Start work from them immediately. Explore outside them only when the user's request or concrete evidence in those files requires missing code or information.`,
|
|
153
|
-
}
|
|
154
|
-
: undefined,
|
|
155
|
-
);
|
|
156
208
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineTauExtensionSettings } from "../../shared/settings/define.ts";
|
|
3
|
+
|
|
4
|
+
export default defineTauExtensionSettings({
|
|
5
|
+
key: "context",
|
|
6
|
+
defaults: {
|
|
7
|
+
validation: {
|
|
8
|
+
enabled: true as boolean,
|
|
9
|
+
ignoreGlobs: [] as string[],
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
schema: Type.Object(
|
|
13
|
+
{
|
|
14
|
+
validation: Type.Optional(
|
|
15
|
+
Type.Object(
|
|
16
|
+
{
|
|
17
|
+
enabled: Type.Optional(
|
|
18
|
+
Type.Boolean({ default: true, description: "Validate context membership after agent turns." }),
|
|
19
|
+
),
|
|
20
|
+
ignoreGlobs: Type.Optional(
|
|
21
|
+
Type.Array(Type.String(), {
|
|
22
|
+
default: [],
|
|
23
|
+
description: "Project-relative files excluded from context membership validation and sync.",
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
},
|
|
27
|
+
{ additionalProperties: false },
|
|
28
|
+
),
|
|
29
|
+
),
|
|
30
|
+
},
|
|
31
|
+
{ additionalProperties: false },
|
|
32
|
+
),
|
|
33
|
+
});
|
|
@@ -6,9 +6,12 @@ import { withFileMutationQueue, type ExtensionAPI, type ExtensionContext } from
|
|
|
6
6
|
import { parse, stringify } from "smol-toml";
|
|
7
7
|
import { createGitRunner, loadRepoStatus, type GitRunner } from "../../shared/git.ts";
|
|
8
8
|
import { generateToolValidated, resolveCandidates } from "../../shared/model-fallback/index.ts";
|
|
9
|
+
import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
9
10
|
import { truncAt } from "../../shared/text.ts";
|
|
10
11
|
import { XAI_CHAT_MODEL, XAI_PROVIDER } from "../xai/constants.ts";
|
|
11
12
|
import {
|
|
13
|
+
isContextEligiblePath,
|
|
14
|
+
isSensitiveContextPath,
|
|
12
15
|
loadContextEntries,
|
|
13
16
|
normalizeProjectPath,
|
|
14
17
|
pathExists,
|
|
@@ -16,6 +19,7 @@ import {
|
|
|
16
19
|
validSlug,
|
|
17
20
|
type ContextEntry,
|
|
18
21
|
} from "./definitions.ts";
|
|
22
|
+
import contextSettings from "./settings.ts";
|
|
19
23
|
|
|
20
24
|
const MAX_DIRTY_EVIDENCE = 4_000;
|
|
21
25
|
const MAX_STRUCTURAL_PREVIEW = 1_500;
|
|
@@ -130,14 +134,24 @@ export async function runContextSync(
|
|
|
130
134
|
pi: ExtensionAPI,
|
|
131
135
|
ctx: ExtensionContext,
|
|
132
136
|
onStatus?: (status: string) => void | Promise<void>,
|
|
137
|
+
): Promise<ContextSyncDetails> {
|
|
138
|
+
return withSyncLock(() => runContextSyncLocked(pi, ctx, onStatus));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function runContextSyncLocked(
|
|
142
|
+
pi: ExtensionAPI,
|
|
143
|
+
ctx: ExtensionContext,
|
|
144
|
+
onStatus?: (status: string) => void | Promise<void>,
|
|
133
145
|
): Promise<ContextSyncDetails> {
|
|
134
146
|
if (!ctx.isProjectTrusted()) throw new Error("Context sync requires a trusted project");
|
|
135
147
|
await onStatus?.("Inspecting repository context");
|
|
136
148
|
const git = createGitRunner(pi, ctx);
|
|
137
149
|
const status = await loadRepoStatus(git);
|
|
138
150
|
if (!status) throw new Error("No Git repository found");
|
|
139
|
-
|
|
140
|
-
const evidence = await collectSyncEvidence(git, status.root);
|
|
151
|
+
const settings = await loadTauExtensionSettings(ctx, contextSettings);
|
|
152
|
+
const evidence = await collectSyncEvidence(git, status.root, settings.validation.ignoreGlobs);
|
|
153
|
+
if (evidence.files.length === 0 && evidence.missingPaths.size === 0)
|
|
154
|
+
return noChange("Changed files are outside context catalog scope.");
|
|
141
155
|
const prompt = buildContextSyncPrompt(evidence);
|
|
142
156
|
const plan = await generateToolValidated(
|
|
143
157
|
ctx,
|
|
@@ -150,15 +164,13 @@ export async function runContextSync(
|
|
|
150
164
|
);
|
|
151
165
|
if (plan.outcome === "no-change") return noChange(plan.reason);
|
|
152
166
|
await onStatus?.("Applying context catalog changes");
|
|
153
|
-
return
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
throw new Error("Context catalog changed during context sync. Rerun context sync.");
|
|
161
|
-
});
|
|
167
|
+
return applyContextSyncPlan(evidence.root, plan, evidence.entries, async () => {
|
|
168
|
+
const currentEntries = await loadContextEntries(evidence.root);
|
|
169
|
+
const currentFiles = await collectDirtyFiles(git, evidence.root, currentEntries, settings.validation.ignoreGlobs);
|
|
170
|
+
if ((await computeWorktreeSignature(git, evidence.root, currentFiles)) !== evidence.worktreeSignature)
|
|
171
|
+
throw new Error("Repository changed during context sync. Rerun context sync.");
|
|
172
|
+
if ((await computeCatalogSignature(evidence.root)) !== evidence.catalogSignature)
|
|
173
|
+
throw new Error("Context catalog changed during context sync. Rerun context sync.");
|
|
162
174
|
});
|
|
163
175
|
}
|
|
164
176
|
|
|
@@ -187,9 +199,13 @@ async function withSyncLock<T>(task: () => Promise<T>): Promise<T> {
|
|
|
187
199
|
}
|
|
188
200
|
}
|
|
189
201
|
|
|
190
|
-
async function collectSyncEvidence(
|
|
202
|
+
async function collectSyncEvidence(
|
|
203
|
+
git: GitRunner,
|
|
204
|
+
root: string,
|
|
205
|
+
ignoreGlobs: readonly string[],
|
|
206
|
+
): Promise<SyncEvidence> {
|
|
191
207
|
const entries = await loadContextEntries(root);
|
|
192
|
-
const files = await collectDirtyFiles(git, root, entries);
|
|
208
|
+
const files = await collectDirtyFiles(git, root, entries, ignoreGlobs);
|
|
193
209
|
const dirtyExisting = new Set<string>();
|
|
194
210
|
for (const file of files)
|
|
195
211
|
if (file.kind !== "deleted" && (await isFile(join(root, file.path)))) dirtyExisting.add(file.path);
|
|
@@ -197,9 +213,15 @@ async function collectSyncEvidence(git: GitRunner, root: string): Promise<SyncEv
|
|
|
197
213
|
root,
|
|
198
214
|
files.filter((file) => dirtyExisting.has(file.path)),
|
|
199
215
|
);
|
|
216
|
+
for (const path of dependencies) if (!isContextEligiblePath(path, ignoreGlobs)) dependencies.delete(path);
|
|
217
|
+
const missingPaths = new Set<string>();
|
|
218
|
+
for (const entry of entries)
|
|
219
|
+
for (const path of entry.files)
|
|
220
|
+
if (isContextEligiblePath(path, ignoreGlobs) && !(await isFile(join(root, path)))) missingPaths.add(path);
|
|
200
221
|
const affectedIds = new Set<string>();
|
|
201
222
|
for (const entry of entries) {
|
|
202
223
|
if (
|
|
224
|
+
entry.files.some((path) => missingPaths.has(path)) ||
|
|
203
225
|
entry.files.some(
|
|
204
226
|
(path) => files.some((file) => file.path === path || file.oldPath === path) || dependencies.has(path),
|
|
205
227
|
)
|
|
@@ -208,10 +230,9 @@ async function collectSyncEvidence(git: GitRunner, root: string): Promise<SyncEv
|
|
|
208
230
|
}
|
|
209
231
|
const affectedConcepts = new Set([...affectedIds].map((id) => id.split("/").slice(0, 2).join("/")));
|
|
210
232
|
const siblingEntries = entries.filter((entry) => affectedConcepts.has(`${entry.tab}/${entry.concept}`));
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const siblingFiles = siblingEntries.flatMap((entry) => entry.files).filter((path) => !missingPaths.has(path));
|
|
233
|
+
const siblingFiles = siblingEntries
|
|
234
|
+
.flatMap((entry) => entry.files)
|
|
235
|
+
.filter((path) => !missingPaths.has(path) && isContextEligiblePath(path, ignoreGlobs));
|
|
215
236
|
const eligibleFiles = new Set([...dirtyExisting, ...dependencies, ...siblingFiles]);
|
|
216
237
|
const structuralPreviews = new Map<string, string>();
|
|
217
238
|
for (const path of [...new Set(siblingFiles)].sort()) {
|
|
@@ -247,6 +268,7 @@ async function collectDirtyFiles(
|
|
|
247
268
|
git: GitRunner,
|
|
248
269
|
root: string,
|
|
249
270
|
entries: readonly ContextEntry[],
|
|
271
|
+
ignoreGlobs: readonly string[],
|
|
250
272
|
): Promise<SyncDirtyFile[]> {
|
|
251
273
|
const raw = await git.run(["status", "--porcelain=v2", "-z", "--untracked-files=all"], { cwd: root });
|
|
252
274
|
const parts = raw.split("\0").filter(Boolean);
|
|
@@ -282,7 +304,22 @@ async function collectDirtyFiles(
|
|
|
282
304
|
index++;
|
|
283
305
|
}
|
|
284
306
|
}
|
|
285
|
-
const
|
|
307
|
+
const sensitive = parsed.filter(
|
|
308
|
+
(file) =>
|
|
309
|
+
file.kind !== "deleted" &&
|
|
310
|
+
(isSensitiveContextPath(file.path) || (file.oldPath !== undefined && isSensitiveContextPath(file.oldPath))),
|
|
311
|
+
);
|
|
312
|
+
if (sensitive.length > 0)
|
|
313
|
+
throw new Error(
|
|
314
|
+
`Sensitive files cannot be inspected by context sync:\n${sensitive.map((file) => `- ${file.path}`).join("\n")}`,
|
|
315
|
+
);
|
|
316
|
+
const sorted = parsed
|
|
317
|
+
.filter(
|
|
318
|
+
(file) =>
|
|
319
|
+
isContextEligiblePath(file.path, ignoreGlobs) ||
|
|
320
|
+
(file.oldPath !== undefined && isContextEligiblePath(file.oldPath, ignoreGlobs)),
|
|
321
|
+
)
|
|
322
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
286
323
|
const result: SyncDirtyFile[] = [];
|
|
287
324
|
for (let offset = 0; offset < sorted.length; offset += EVIDENCE_CONCURRENCY) {
|
|
288
325
|
result.push(
|
|
@@ -394,13 +431,7 @@ async function isFile(path: string): Promise<boolean> {
|
|
|
394
431
|
|
|
395
432
|
function buildContextSyncPrompt(evidence: SyncEvidence): string {
|
|
396
433
|
const stale = evidence.entries.flatMap((entry) =>
|
|
397
|
-
entry.files
|
|
398
|
-
.filter(
|
|
399
|
-
(path) =>
|
|
400
|
-
evidence.missingPaths.has(path) &&
|
|
401
|
-
!evidence.files.some((file) => file.path === path || file.oldPath === path),
|
|
402
|
-
)
|
|
403
|
-
.map((path) => `${entry.id}: ${path}`),
|
|
434
|
+
entry.files.filter((path) => evidence.missingPaths.has(path)).map((path) => `${entry.id}: ${path}`),
|
|
404
435
|
);
|
|
405
436
|
const affected = evidence.entries.filter((entry) => evidence.affectedIds.has(entry.id));
|
|
406
437
|
const previews = [...evidence.structuralPreviews].map(([path, preview]) => `${path}\n${preview}`);
|
|
@@ -408,11 +439,11 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
|
|
|
408
439
|
[
|
|
409
440
|
"Synchronize reusable repository context scopes from the supplied Git and catalog evidence.",
|
|
410
441
|
"Call submit_context_sync exactly once and produce no prose response.",
|
|
411
|
-
"Return no-change when
|
|
442
|
+
"Return no-change only when every eligible changed file has context membership and no stale catalog paths remain.",
|
|
412
443
|
"Context entries are reusable work scopes, not inventories of every touched file. Prefer updating an existing entry over creating a near-duplicate. Do not create one entry per file.",
|
|
413
444
|
"Follow direct local dependency candidates only when needed. Do not add package dependencies, generated files, incidental imports, or recursive dependencies.",
|
|
414
445
|
"Reconsider granularity only inside affected concepts. Preserve broad entries when splitting would duplicate files without improving future work.",
|
|
415
|
-
"
|
|
446
|
+
"Every eligible changed file must belong to at least one entry. Remove every stale catalog path. Use only supplied candidate paths.",
|
|
416
447
|
"Example: replace gameplay/player/all with gameplay/player/movement and gameplay/player/input only when every still-useful file from all remains in the final entries.",
|
|
417
448
|
"Changed-file catalog and current memberships:",
|
|
418
449
|
...evidence.files.map(
|
|
@@ -432,7 +463,7 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
|
|
|
432
463
|
),
|
|
433
464
|
"Changed files with no membership:",
|
|
434
465
|
evidence.files
|
|
435
|
-
.filter((file) => file.
|
|
466
|
+
.filter((file) => file.kind !== "deleted" && file.memberships.length === 0)
|
|
436
467
|
.map((file) => file.path)
|
|
437
468
|
.join("\n") || "(none)",
|
|
438
469
|
"Affected entries:",
|
|
@@ -441,7 +472,7 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
|
|
|
441
472
|
[...evidence.affectedConcepts].sort().join("\n") || "(none)",
|
|
442
473
|
"Resolved direct dependency candidates:",
|
|
443
474
|
[...evidence.dependencies].sort().join("\n") || "(none)",
|
|
444
|
-
"
|
|
475
|
+
"Stale catalog paths that must be removed:",
|
|
445
476
|
stale.join("\n") || "(none)",
|
|
446
477
|
"Dirty files:",
|
|
447
478
|
...evidence.files.map(
|
|
@@ -462,6 +493,9 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
|
|
|
462
493
|
if (!reason) throw new Error("Context sync reason is required.");
|
|
463
494
|
if (input.outcome === "no-change") {
|
|
464
495
|
if ("changes" in input) throw new Error("no-change must not include changes.");
|
|
496
|
+
const uncovered = evidence.files.filter((file) => file.kind !== "deleted" && file.memberships.length === 0);
|
|
497
|
+
if (uncovered.length > 0 || evidence.missingPaths.size > 0)
|
|
498
|
+
throw new Error("no-change cannot leave uncovered changed files or stale catalog paths.");
|
|
465
499
|
return { outcome: "no-change", reason };
|
|
466
500
|
}
|
|
467
501
|
if (!Array.isArray(input.changes) || input.changes.length === 0)
|
|
@@ -541,6 +575,12 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
|
|
|
541
575
|
if (evidence.eligibleFiles.has(path) && ![...final.values()].some((files) => files.includes(path)))
|
|
542
576
|
throw new Error(`Deleting entry would orphan surviving file: ${path}`);
|
|
543
577
|
}
|
|
578
|
+
for (const path of evidence.dirtyExisting)
|
|
579
|
+
if (![...final.values()].some((files) => files.includes(path)))
|
|
580
|
+
throw new Error(`Context sync would leave changed file uncovered: ${path}`);
|
|
581
|
+
for (const files of final.values())
|
|
582
|
+
for (const path of files)
|
|
583
|
+
if (evidence.missingPaths.has(path)) throw new Error(`Context sync would leave stale path: ${path}`);
|
|
544
584
|
return { outcome: "apply", reason, changes };
|
|
545
585
|
}
|
|
546
586
|
|
|
@@ -627,16 +667,16 @@ export async function applyContextSyncPlan(
|
|
|
627
667
|
for (const path of paths) originals.set(path, (await pathExists(path)) ? await readFile(path) : undefined);
|
|
628
668
|
const temporaryFiles = new Map<string, string>();
|
|
629
669
|
try {
|
|
630
|
-
for (const path of paths) {
|
|
631
|
-
const output = outputs.get(path);
|
|
632
|
-
if (output === undefined) continue;
|
|
633
|
-
await mkdir(dirname(path), { recursive: true });
|
|
634
|
-
const temporary = `${path}.${process.pid}.${Date.now()}.${temporaryFiles.size}.tmp`;
|
|
635
|
-
await writeFile(temporary, output, "utf8");
|
|
636
|
-
temporaryFiles.set(path, temporary);
|
|
637
|
-
}
|
|
638
670
|
await acquireMutationQueues(lockPaths, async () => {
|
|
639
671
|
await verifyFreshness?.();
|
|
672
|
+
for (const path of paths) {
|
|
673
|
+
const output = outputs.get(path);
|
|
674
|
+
if (output === undefined) continue;
|
|
675
|
+
await mkdir(dirname(path), { recursive: true });
|
|
676
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.${temporaryFiles.size}.tmp`;
|
|
677
|
+
await writeFile(temporary, output, "utf8");
|
|
678
|
+
temporaryFiles.set(path, temporary);
|
|
679
|
+
}
|
|
640
680
|
const changed: string[] = [];
|
|
641
681
|
try {
|
|
642
682
|
for (const path of paths) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { GitRunner } from "../../shared/git.ts";
|
|
4
|
+
import { isContextEligiblePath, isSensitiveContextPath, loadContextEntries } from "./definitions.ts";
|
|
5
|
+
|
|
6
|
+
export interface ContextValidationResult {
|
|
7
|
+
stale: Array<{ path: string; ids: string[] }>;
|
|
8
|
+
uncovered: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function validateContextCatalog(
|
|
12
|
+
git: GitRunner,
|
|
13
|
+
root: string,
|
|
14
|
+
ignoreGlobs: readonly string[],
|
|
15
|
+
): Promise<ContextValidationResult> {
|
|
16
|
+
const entries = await loadContextEntries(root);
|
|
17
|
+
const memberships = new Map<string, string[]>();
|
|
18
|
+
for (const entry of entries)
|
|
19
|
+
for (const file of entry.files) memberships.set(file, [...(memberships.get(file) ?? []), entry.id]);
|
|
20
|
+
|
|
21
|
+
const stale: Array<{ path: string; ids: string[] }> = [];
|
|
22
|
+
for (const [path, ids] of memberships) {
|
|
23
|
+
if (!isContextEligiblePath(path, ignoreGlobs)) continue;
|
|
24
|
+
try {
|
|
25
|
+
if (!(await stat(join(root, path))).isFile()) stale.push({ path, ids });
|
|
26
|
+
} catch {
|
|
27
|
+
stale.push({ path, ids });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const status = await git.run(["status", "--porcelain=v2", "-z", "--untracked-files=all"], { cwd: root });
|
|
32
|
+
const records = status.split("\0");
|
|
33
|
+
const dirty = new Set<string>();
|
|
34
|
+
for (let index = 0; index < records.length; index++) {
|
|
35
|
+
const record = records[index];
|
|
36
|
+
if (!record) continue;
|
|
37
|
+
if (record.startsWith("? ")) {
|
|
38
|
+
dirty.add(record.slice(2).replaceAll("\\", "/"));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const fields = record.split(" ");
|
|
42
|
+
const state = fields[1] ?? "..";
|
|
43
|
+
if (record.startsWith("1 ") && !state.includes("D")) {
|
|
44
|
+
dirty.add(fields.slice(8).join(" ").replaceAll("\\", "/"));
|
|
45
|
+
} else if (record.startsWith("2 ")) {
|
|
46
|
+
if (!state.includes("D")) dirty.add(fields.slice(9).join(" ").replaceAll("\\", "/"));
|
|
47
|
+
index += 1;
|
|
48
|
+
} else if (record.startsWith("u ")) {
|
|
49
|
+
dirty.add(fields.slice(10).join(" ").replaceAll("\\", "/"));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const uncovered = [...dirty]
|
|
53
|
+
.filter(
|
|
54
|
+
(path) => !isSensitiveContextPath(path) && isContextEligiblePath(path, ignoreGlobs) && !memberships.has(path),
|
|
55
|
+
)
|
|
56
|
+
.sort((left, right) => left.localeCompare(right));
|
|
57
|
+
return { stale: stale.sort((left, right) => left.path.localeCompare(right.path)), uncovered };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatContextValidationFailure(result: ContextValidationResult): string | undefined {
|
|
61
|
+
if (result.stale.length === 0 && result.uncovered.length === 0) return undefined;
|
|
62
|
+
const output = ["Context catalog validation failed."];
|
|
63
|
+
if (result.stale.length)
|
|
64
|
+
output.push(
|
|
65
|
+
"",
|
|
66
|
+
"Stale context file references:",
|
|
67
|
+
...result.stale.map((item) => `- ${item.path} (${item.ids.sort().join(", ")})`),
|
|
68
|
+
);
|
|
69
|
+
if (result.uncovered.length)
|
|
70
|
+
output.push("", "Changed files with no context membership:", ...result.uncovered.map((path) => `- ${path}`));
|
|
71
|
+
output.push("", "Run context_sync to reconcile the context catalog.");
|
|
72
|
+
return output.join("\n");
|
|
73
|
+
}
|
|
@@ -41,14 +41,7 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
|
41
41
|
name: "image_gen",
|
|
42
42
|
label: "Image Generation",
|
|
43
43
|
description:
|
|
44
|
-
"Generate a
|
|
45
|
-
promptSnippet: "Generate or edit raster images with Grok Imagine",
|
|
46
|
-
promptGuidelines: [
|
|
47
|
-
"Use image_gen when the user asks for a generated raster image or an AI edit of local raster images.",
|
|
48
|
-
"Omit referenced_image_paths when image_gen should create a new image.",
|
|
49
|
-
"Pass one to three local paths in referenced_image_paths when image_gen should edit or compose existing images.",
|
|
50
|
-
"Omit path for temporary external storage. Pass path only when the user wants the generated image saved in their repository or another explicit location.",
|
|
51
|
-
],
|
|
44
|
+
"Generate a requested raster image or AI-edit existing images with the xAI Grok subscription OAuth login. Omit referenced_image_paths to generate; pass one to three local paths to edit or compose. Omit path to use Tau's external image store; pass path only when the user explicitly requests a repository file or other destination. Returns the image for inspection.",
|
|
52
45
|
parameters: imageGenSchema,
|
|
53
46
|
async execute(_toolCallId, params: ImageGenParams, signal, onUpdate, ctx) {
|
|
54
47
|
signal?.throwIfAborted();
|
|
@@ -8,4 +8,4 @@ Create a tagged Tau release and monitor its GitHub Actions npm publish run.
|
|
|
8
8
|
/publish
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
`/publish` requires a clean Git working tree
|
|
11
|
+
`/publish` requires a clean Git working tree, authenticated GitHub CLI, and public approved npm package sources. It recommends a semantic version bump from commits since the previous release tag, asks for confirmation, then pushes a release tag. GitHub Actions publishes the TUI package before the agent package through npm trusted publishing.
|
|
@@ -55,6 +55,7 @@ async function publish(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<
|
|
|
55
55
|
if (repo.fileCount > 0)
|
|
56
56
|
throw new Error("Working tree is not clean. Commit or stash every change before publishing.");
|
|
57
57
|
await run(pi, ctx, "gh", ["auth", "status"], repo.root);
|
|
58
|
+
await run(pi, ctx, "npm", ["run", "check:package-sources"], repo.root);
|
|
58
59
|
|
|
59
60
|
const manifests = await loadManifests(repo.root);
|
|
60
61
|
const currentVersion = sharedVersion(manifests);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Runtime Context
|
|
2
|
+
|
|
3
|
+
Supplies Tau with the current local date and an initial root directory snapshot as hidden session context. The snapshot remains fixed until the extension runtime restarts, while the date updates when the local day changes.
|
|
4
|
+
|
|
5
|
+
After changing this extension, run `/reload` before testing the new behavior.
|