@shanepadgett/tau-agent 0.13.0 → 0.14.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.
@@ -2,7 +2,7 @@
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. Tau validates context membership after agent turns and asks the agent to sync uncovered changed files or stale references automatically.
5
+ Use `/context` to select entries. Entry `files` are injected through Tau autoread. Entry `anchors` supply lazy navigation paths that the agent can grep or read in ranges when needed. Use `/context-sync` to reconcile affected scopes from the current Git changes. Tau validates both file classes as context membership after agent turns and asks the agent to sync uncovered changed files or stale references automatically.
6
6
 
7
7
  Validation is disabled by default. Enable it globally or per project in Tau settings:
8
8
 
@@ -30,6 +30,7 @@ files = ["src/player/input.ts"]
30
30
  [movement]
31
31
  description = "Player locomotion and collision"
32
32
  files = ["src/player/movement.ts"]
33
+ anchors = ["src/runtime/fetch-handler.ts"]
33
34
  ```
34
35
 
35
- For example, `.pi/contexts/gameplay/player.toml` appears in the `gameplay` tab as the `Player` concept with `input` and `movement` entries.
36
+ For example, `.pi/contexts/gameplay/player.toml` appears in the `gameplay` tab as the `Player` concept with `input` and `movement` entries. When `movement` is selected, Tau autoreads `src/player/movement.ts` and lists `src/runtime/fetch-handler.ts` as an unloaded anchor. If one selected entry marks a path as a file and another marks it as an anchor, autoread wins.
@@ -13,6 +13,7 @@ export interface ContextEntry {
13
13
  name: string;
14
14
  description: string;
15
15
  files: string[];
16
+ anchors: string[];
16
17
  path: string;
17
18
  }
18
19
 
@@ -36,6 +37,7 @@ const CONTEXT_IGNORED_FILENAMES = new Set([
36
37
  "uv.lock",
37
38
  "yarn.lock",
38
39
  ]);
40
+ const CONTEXT_ENTRY_FIELDS = new Set(["description", "files", "anchors"]);
39
41
 
40
42
  export function isContextEligiblePath(path: string, ignoreGlobs: readonly string[] = []): boolean {
41
43
  return (
@@ -97,6 +99,10 @@ function sortedUnique(values: readonly string[]): string[] {
97
99
  return [...new Set(values)].sort((a, b) => a.localeCompare(b));
98
100
  }
99
101
 
102
+ export function contextEntryPaths(entry: Pick<ContextEntry, "files" | "anchors">): string[] {
103
+ return sortedUnique([...entry.files, ...entry.anchors]);
104
+ }
105
+
100
106
  export async function requireFiles(root: string, inputs: readonly string[]): Promise<string[]> {
101
107
  const files = sortedUnique(inputs.map((input) => normalizeProjectPath(root, input)));
102
108
  for (const file of files) {
@@ -132,15 +138,24 @@ export async function loadContextEntries(root: string): Promise<ContextEntry[]>
132
138
  if (!value || typeof value !== "object" || Array.isArray(value))
133
139
  throw new Error(`Invalid context entry: ${path} [${name}]`);
134
140
  const record = value as Record<string, unknown>;
141
+ const unknownField = Object.keys(record).find((field) => !CONTEXT_ENTRY_FIELDS.has(field));
142
+ if (unknownField) throw new Error(`Invalid context entry field: ${path} [${name}] ${unknownField}`);
143
+ const anchors = record.anchors ?? [];
135
144
  if (
136
145
  typeof record.description !== "string" ||
137
146
  !record.description.trim() ||
138
147
  !Array.isArray(record.files) ||
139
- record.files.length === 0 ||
140
- record.files.some((item) => typeof item !== "string")
148
+ record.files.some((item) => typeof item !== "string") ||
149
+ !Array.isArray(anchors) ||
150
+ anchors.some((item) => typeof item !== "string") ||
151
+ (record.files.length === 0 && anchors.length === 0)
141
152
  )
142
153
  throw new Error(`Invalid context entry: ${path} [${name}]`);
143
154
  const entry = validSlug(name, "Context entry");
155
+ const entryFiles = sortedUnique((record.files as string[]).map((item) => normalizeProjectPath(root, item)));
156
+ const entryAnchors = sortedUnique((anchors as string[]).map((item) => normalizeProjectPath(root, item)));
157
+ const overlap = entryFiles.find((item) => entryAnchors.includes(item));
158
+ if (overlap) throw new Error(`Context path cannot be both file and anchor: ${path} [${name}] ${overlap}`);
144
159
  result.push({
145
160
  id: `${tab}/${concept}/${entry}`,
146
161
  tab,
@@ -149,7 +164,8 @@ export async function loadContextEntries(root: string): Promise<ContextEntry[]>
149
164
  conceptDescription,
150
165
  name: entry,
151
166
  description: record.description.trim(),
152
- files: sortedUnique((record.files as string[]).map((item) => normalizeProjectPath(root, item))),
167
+ files: entryFiles,
168
+ anchors: entryAnchors,
153
169
  path,
154
170
  });
155
171
  }
@@ -53,19 +53,35 @@ export default function contextExtension(pi: ExtensionAPI): void {
53
53
  );
54
54
  if (!selected?.length) return;
55
55
  const files = [...new Set(selected.flatMap((entry) => entry.files))].sort();
56
+ const fileSet = new Set(files);
57
+ const anchors = [...new Set(selected.flatMap((entry) => entry.anchors))]
58
+ .filter((path) => !fileSet.has(path))
59
+ .sort();
56
60
  pi.sendMessage(
57
61
  createInjectedContext(
58
- "Treat 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.",
62
+ [
63
+ "Selected repository context:",
64
+ ...selected.map((entry) => `- ${entry.id}: ${entry.description}`),
65
+ "",
66
+ "Eager snapshots supplied through autoread:",
67
+ ...(files.length ? files.map((path) => `- ${path}`) : ["(none)"]),
68
+ "",
69
+ "Lazy navigation anchors whose contents have not been loaded:",
70
+ ...(anchors.length ? anchors.map((path) => `- ${path}`) : ["(none)"]),
71
+ "",
72
+ "Treat eager snapshots as authoritative current project context. Do not reread them or search for coverage around them. Inspect only the anchors needed for the request, using grep or bounded reads. Explore elsewhere only when the request or concrete evidence requires missing information.",
73
+ ].join("\n"),
59
74
  { source: "context", title: "Project context" },
60
75
  ),
61
76
  );
62
- emitTauEvent(pi, "tau:autoread.requested", {
63
- source: "context",
64
- title: "Project context",
65
- cwd: root,
66
- batchId: randomUUID(),
67
- files: files.map((path) => ({ path })),
68
- });
77
+ if (files.length)
78
+ emitTauEvent(pi, "tau:autoread.requested", {
79
+ source: "context",
80
+ title: "Project context",
81
+ cwd: root,
82
+ batchId: randomUUID(),
83
+ files: files.map((path) => ({ path })),
84
+ });
69
85
  },
70
86
  });
71
87
 
@@ -73,14 +73,15 @@ export class ContextPanel implements Component {
73
73
  const list = this.activeList();
74
74
  if (!list?.isFilterFocused()) {
75
75
  const keys = getKeybindings();
76
- const pageSize = this.filePageSize();
77
- if (this.current.files.length > pageSize && matchesKey(data, Key.alt("up"))) {
76
+ const paths = this.currentPaths();
77
+ const pageSize = this.pathPageSize();
78
+ if (paths.length > pageSize && matchesKey(data, Key.alt("up"))) {
78
79
  this.fileOffset = Math.max(0, this.fileOffset - pageSize);
79
80
  this.sync();
80
81
  return;
81
82
  }
82
- if (this.current.files.length > pageSize && matchesKey(data, Key.alt("down"))) {
83
- this.fileOffset = Math.min(this.current.files.length - pageSize, this.fileOffset + pageSize);
83
+ if (paths.length > pageSize && matchesKey(data, Key.alt("down"))) {
84
+ this.fileOffset = Math.min(paths.length - pageSize, this.fileOffset + pageSize);
84
85
  this.sync();
85
86
  return;
86
87
  }
@@ -122,35 +123,51 @@ export class ContextPanel implements Component {
122
123
  return {
123
124
  render: (width) => {
124
125
  const tabs = this.tabs.render(width);
125
- const pageSize = this.filePageSize(tabs.length);
126
- const maxOffset = Math.max(0, this.current.files.length - pageSize);
126
+ const paths = this.currentPaths();
127
+ const pageSize = this.pathPageSize(tabs.length);
128
+ const maxOffset = Math.max(0, paths.length - pageSize);
127
129
  this.fileOffset = Math.min(this.fileOffset, maxOffset);
128
- const files = this.current.files.slice(this.fileOffset, this.fileOffset + pageSize);
130
+ const visible = paths.slice(this.fileOffset, this.fileOffset + pageSize);
129
131
  const range =
130
- this.current.files.length > pageSize
132
+ paths.length > pageSize
131
133
  ? this.theme.fg(
132
134
  "dim",
133
- `${this.fileOffset + 1}-${this.fileOffset + files.length} of ${this.current.files.length} files`,
135
+ `${this.fileOffset + 1}-${this.fileOffset + visible.length} of ${paths.length} paths`,
134
136
  )
135
137
  : undefined;
136
138
  return [
137
139
  ...tabs,
138
140
  "",
139
141
  ...(range ? [truncateToWidth(range, width, "…")] : []),
140
- ...files.map((file) => truncateToWidth(this.theme.fg("muted", `• ${file}`), width, "…")),
142
+ ...visible.map(({ kind, path }) =>
143
+ truncateToWidth(
144
+ this.theme.fg(
145
+ kind === "read" ? "muted" : "dim",
146
+ `${kind === "read" ? "read" : "anchor"} • ${path}`,
147
+ ),
148
+ width,
149
+ "…",
150
+ ),
151
+ ),
141
152
  ];
142
153
  },
143
154
  invalidate: () => this.tabs.invalidate(),
144
155
  };
145
156
  }
146
- private filePageSize(tabLines = this.tabs.render(this.tui.terminal.columns).length): number {
157
+ private currentPaths(): Array<{ kind: "read" | "anchor"; path: string }> {
158
+ return [
159
+ ...this.current.files.map((path) => ({ kind: "read" as const, path })),
160
+ ...this.current.anchors.map((path) => ({ kind: "anchor" as const, path })),
161
+ ];
162
+ }
163
+ private pathPageSize(tabLines = this.tabs.render(this.tui.terminal.columns).length): number {
147
164
  const overlayHeight = Math.floor(this.tui.terminal.rows * 0.8);
148
165
  const available = Math.max(1, overlayHeight - tabLines - 7);
149
166
  const pageSize = Math.min(8, available);
150
- return this.current.files.length > pageSize ? Math.max(1, pageSize - 1) : pageSize;
167
+ return this.currentPaths().length > pageSize ? Math.max(1, pageSize - 1) : pageSize;
151
168
  }
152
169
  private secondary(): string {
153
- return `${[...this.selected.values()].reduce((sum, items) => sum + items.length, 0)} selected`;
170
+ return `${[...this.selected.values()].reduce((sum, items) => sum + items.length, 0)} selected · ${this.current.files.length} read · ${this.current.anchors.length} anchors`;
154
171
  }
155
172
  private hints() {
156
173
  const list = this.activeList();
@@ -158,7 +175,7 @@ export class ContextPanel implements Component {
158
175
  ? list.getKeyHints()
159
176
  : [
160
177
  ...this.tabs.getKeyHints(),
161
- ...(this.current.files.length > this.filePageSize() ? [rawHint("option+↑/↓", "scroll files")] : []),
178
+ ...(this.currentPaths().length > this.pathPageSize() ? [rawHint("option+↑/↓", "scroll paths")] : []),
162
179
  rawHint("ctrl+c", "clear all"),
163
180
  bindingHint("tui.select.confirm", "inject"),
164
181
  bindingHint("tui.select.cancel", "cancel"),
@@ -10,6 +10,7 @@ import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
10
10
  import { truncAt } from "../../shared/text.ts";
11
11
  import { XAI_CHAT_MODEL, XAI_PROVIDER } from "../xai/constants.ts";
12
12
  import {
13
+ contextEntryPaths,
13
14
  isContextEligiblePath,
14
15
  isSensitiveContextPath,
15
16
  loadContextEntries,
@@ -216,13 +217,14 @@ async function collectSyncEvidence(
216
217
  for (const path of dependencies) if (!isContextEligiblePath(path, ignoreGlobs)) dependencies.delete(path);
217
218
  const missingPaths = new Set<string>();
218
219
  for (const entry of entries)
219
- for (const path of entry.files)
220
+ for (const path of contextEntryPaths(entry))
220
221
  if (isContextEligiblePath(path, ignoreGlobs) && !(await isFile(join(root, path)))) missingPaths.add(path);
221
222
  const affectedIds = new Set<string>();
222
223
  for (const entry of entries) {
224
+ const paths = contextEntryPaths(entry);
223
225
  if (
224
- entry.files.some((path) => missingPaths.has(path)) ||
225
- entry.files.some(
226
+ paths.some((path) => missingPaths.has(path)) ||
227
+ paths.some(
226
228
  (path) => files.some((file) => file.path === path || file.oldPath === path) || dependencies.has(path),
227
229
  )
228
230
  )
@@ -231,7 +233,7 @@ async function collectSyncEvidence(
231
233
  const affectedConcepts = new Set([...affectedIds].map((id) => id.split("/").slice(0, 2).join("/")));
232
234
  const siblingEntries = entries.filter((entry) => affectedConcepts.has(`${entry.tab}/${entry.concept}`));
233
235
  const siblingFiles = siblingEntries
234
- .flatMap((entry) => entry.files)
236
+ .flatMap((entry) => contextEntryPaths(entry))
235
237
  .filter((path) => !missingPaths.has(path) && isContextEligiblePath(path, ignoreGlobs));
236
238
  const eligibleFiles = new Set([...dirtyExisting, ...dependencies, ...siblingFiles]);
237
239
  const structuralPreviews = new Map<string, string>();
@@ -327,9 +329,13 @@ async function collectDirtyFiles(
327
329
  sorted.slice(offset, offset + EVIDENCE_CONCURRENCY).map(async (file, inner) => ({
328
330
  ...file,
329
331
  id: offset + inner + 1,
330
- memberships: entries.filter((entry) => entry.files.includes(file.path)).map((entry) => entry.id),
332
+ memberships: entries
333
+ .filter((entry) => contextEntryPaths(entry).includes(file.path))
334
+ .map((entry) => entry.id),
331
335
  oldMemberships: file.oldPath
332
- ? entries.filter((entry) => entry.files.includes(file.oldPath ?? "")).map((entry) => entry.id)
336
+ ? entries
337
+ .filter((entry) => contextEntryPaths(entry).includes(file.oldPath ?? ""))
338
+ .map((entry) => entry.id)
333
339
  : [],
334
340
  evidence: await dirtyEvidence(git, root, file),
335
341
  })),
@@ -431,7 +437,9 @@ async function isFile(path: string): Promise<boolean> {
431
437
 
432
438
  function buildContextSyncPrompt(evidence: SyncEvidence): string {
433
439
  const stale = evidence.entries.flatMap((entry) =>
434
- entry.files.filter((path) => evidence.missingPaths.has(path)).map((path) => `${entry.id}: ${path}`),
440
+ contextEntryPaths(entry)
441
+ .filter((path) => evidence.missingPaths.has(path))
442
+ .map((path) => `${entry.id}: ${path}`),
435
443
  );
436
444
  const affected = evidence.entries.filter((entry) => evidence.affectedIds.has(entry.id));
437
445
  const previews = [...evidence.structuralPreviews].map(([path, preview]) => `${path}\n${preview}`);
@@ -441,6 +449,7 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
441
449
  "Call submit_context_sync exactly once and produce no prose response.",
442
450
  "Return no-change only when every eligible changed file has context membership and no stale catalog paths remain.",
443
451
  "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.",
452
+ "The files field in your changes is desired membership, including eager files and lazy anchors. Paths already classified anywhere in the catalog preserve their loading class; new paths become eager files. Do not change loading policy.",
444
453
  "Follow direct local dependency candidates only when needed. Do not add package dependencies, generated files, incidental imports, or recursive dependencies.",
445
454
  "Reconsider granularity only inside affected concepts. Preserve broad entries when splitting would duplicate files without improving future work.",
446
455
  "Every eligible changed file must belong to at least one entry. Remove every stale catalog path. Use only supplied candidate paths.",
@@ -457,8 +466,9 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
457
466
  conceptName: entry.conceptName,
458
467
  conceptDescription: entry.conceptDescription,
459
468
  description: entry.description,
460
- files: entry.files,
461
- missingFiles: entry.files.filter((path) => evidence.missingPaths.has(path)),
469
+ eagerFiles: entry.files,
470
+ anchors: entry.anchors,
471
+ missingFiles: contextEntryPaths(entry).filter((path) => evidence.missingPaths.has(path)),
462
472
  })),
463
473
  ),
464
474
  "Changed files with no membership:",
@@ -540,7 +550,7 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
540
550
  throw new Error(`Entry is unrelated: ${id}`);
541
551
  if (conceptName !== existing.conceptName || conceptDescription !== existing.conceptDescription)
542
552
  throw new Error(`Existing concept metadata cannot change: ${conceptId}`);
543
- if (description === existing.description && files.join("\0") === existing.files.join("\0"))
553
+ if (description === existing.description && files.join("\0") === contextEntryPaths(existing).join("\0"))
544
554
  throw new Error(`Set-entry is identical: ${id}`);
545
555
  } else {
546
556
  const existingConcept = evidence.entries.find((item) => item.tab === tab && item.concept === concept);
@@ -560,7 +570,7 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
560
570
  }
561
571
  changes.push({ action: "set-entry", tab, concept, conceptName, conceptDescription, entry, description, files });
562
572
  }
563
- const final = new Map(evidence.entries.map((entry) => [entry.id, [...entry.files]]));
573
+ const final = new Map(evidence.entries.map((entry) => [entry.id, contextEntryPaths(entry)]));
564
574
  for (const change of changes) {
565
575
  const id = `${change.tab}/${change.concept}/${change.entry}`;
566
576
  if (change.action === "delete-entry") final.delete(id);
@@ -571,7 +581,7 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
571
581
  )) {
572
582
  const old = current.get(`${change.tab}/${change.concept}/${change.entry}`);
573
583
  if (!old) continue;
574
- for (const path of old.files)
584
+ for (const path of contextEntryPaths(old))
575
585
  if (evidence.eligibleFiles.has(path) && ![...final.values()].some((files) => files.includes(path)))
576
586
  throw new Error(`Deleting entry would orphan surviving file: ${path}`);
577
587
  }
@@ -640,6 +650,8 @@ export async function applyContextSyncPlan(
640
650
  ): Promise<ContextSyncDetails> {
641
651
  for (const change of plan.changes) if (change.action === "set-entry") await requireFiles(root, change.files);
642
652
  const concepts = [...new Set(plan.changes.map((change) => `${change.tab}/${change.concept}`))].sort();
653
+ const eagerPaths = new Set(entries.flatMap((entry) => entry.files));
654
+ const anchorPaths = new Set(entries.flatMap((entry) => entry.anchors).filter((path) => !eagerPaths.has(path)));
643
655
  const outputs = new Map<string, string | undefined>();
644
656
  for (const key of concepts) {
645
657
  const [tab, concept] = key.split("/");
@@ -653,7 +665,12 @@ export async function applyContextSyncPlan(
653
665
  else {
654
666
  if (raw.name === undefined) raw.name = change.conceptName;
655
667
  if (raw.description === undefined) raw.description = change.conceptDescription;
656
- raw[change.entry] = { description: change.description, files: change.files };
668
+ const anchors = change.files.filter((path) => anchorPaths.has(path));
669
+ raw[change.entry] = {
670
+ description: change.description,
671
+ files: change.files.filter((path) => !anchors.includes(path)),
672
+ ...(anchors.length ? { anchors } : {}),
673
+ };
657
674
  }
658
675
  }
659
676
  outputs.set(
@@ -1,7 +1,7 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type { GitRunner } from "../../shared/git.ts";
4
- import { isContextEligiblePath, isSensitiveContextPath, loadContextEntries } from "./definitions.ts";
4
+ import { contextEntryPaths, isContextEligiblePath, isSensitiveContextPath, loadContextEntries } from "./definitions.ts";
5
5
 
6
6
  export interface ContextValidationResult {
7
7
  stale: Array<{ path: string; ids: string[] }>;
@@ -16,7 +16,7 @@ export async function validateContextCatalog(
16
16
  const entries = await loadContextEntries(root);
17
17
  const memberships = new Map<string, string[]>();
18
18
  for (const entry of entries)
19
- for (const file of entry.files) memberships.set(file, [...(memberships.get(file) ?? []), entry.id]);
19
+ for (const file of contextEntryPaths(entry)) memberships.set(file, [...(memberships.get(file) ?? []), entry.id]);
20
20
 
21
21
  const stale: Array<{ path: string; ids: string[] }> = [];
22
22
  for (const [path, ids] of memberships) {
@@ -28,7 +28,7 @@ Adds `/commit` for semantic commit grouping, review, and committing selected rep
28
28
 
29
29
  ## context
30
30
 
31
- Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-sync` to reconcile affected scopes from current Git changes. Context validation is disabled by default; when enabled, Tau checks changed-file membership and stale references after agent turns. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
31
+ Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-sync` to reconcile affected scopes from current Git changes. Entry `files` are autoread; entry `anchors` are unloaded navigation paths the agent can inspect when needed. Context validation is disabled by default; when enabled, Tau checks both classes for changed-file membership and stale references after agent turns. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
32
32
 
33
33
  ## explore
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Tau is a custom agentic harness built with pi extensions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@shanepadgett/tau-tui": "0.13.0",
31
+ "@shanepadgett/tau-tui": "0.14.0",
32
32
  "@toon-format/toon": "2.3.0",
33
33
  "smol-toml": "1.7.0"
34
34
  },