@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.
@@ -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 its list_windows ID, resize it to fit within 1568×1568 pixels, save it as PNG, and inspect the image.",
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 a PID returned by list_windows.",
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. Sync is automatic, constrained to the changed scope, and may report `no-change` when existing mappings already fit.
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 active: ContextEntry[] = [];
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: "Synchronize repository context from current Git changes.",
100
+ description:
101
+ "Synchronize repository context membership after context validation reports changed, stale, or unassigned files.",
96
102
  parameters: contextSyncParams,
97
- async execute(_id, _params, _signal, onUpdate, ctx) {
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
- const root = await findProjectRoot(ctx.cwd);
136
- const entries = await loadContextEntries(root);
137
- let selectionData: unknown;
138
- for (const entry of ctx.sessionManager.getBranch())
139
- if (entry.type === "custom" && entry.customType === "tau.context-selection") selectionData = entry.data;
140
- const ids =
141
- typeof selectionData === "object" &&
142
- selectionData !== null &&
143
- "ids" in selectionData &&
144
- Array.isArray(selectionData.ids)
145
- ? selectionData.ids.filter((id: unknown): id is string => typeof id === "string")
146
- : [];
147
- active = entries.filter((entry) => ids.includes(entry.id));
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
- if (status.fileCount === 0) return noChange("Existing context mappings already fit the changed scope.");
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 withSyncLock(async () => {
154
- return applyContextSyncPlan(evidence.root, plan, evidence.entries, async () => {
155
- const currentEntries = await loadContextEntries(evidence.root);
156
- const currentFiles = await collectDirtyFiles(git, evidence.root, currentEntries);
157
- if ((await computeWorktreeSignature(git, evidence.root, currentFiles)) !== evidence.worktreeSignature)
158
- throw new Error("Repository changed during context sync. Rerun context sync.");
159
- if ((await computeCatalogSignature(evidence.root)) !== evidence.catalogSignature)
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(git: GitRunner, root: string): Promise<SyncEvidence> {
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 missingPaths = new Set<string>();
212
- for (const entry of entries)
213
- for (const path of entry.files) if (!(await isFile(join(root, path)))) missingPaths.add(path);
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 sorted = parsed.sort((a, b) => a.path.localeCompare(b.path));
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 existing mappings already support likely future work.",
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
- "New or unmapped changed files may remain unmapped. Do not clean unrelated stale paths. Use only supplied candidate paths.",
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.memberships.length === 0 && file.oldMemberships.length === 0)
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
- "Unrelated stale catalog paths (evidence only; do not modify):",
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 new raster image from a prompt, or edit one to three local raster images. Uses the xAI Grok subscription OAuth login, saves the result under Tau's external image store unless an explicit path is provided, and returns the image for inspection.",
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 and authenticated GitHub CLI. 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.
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.