@shanepadgett/tau-agent 0.8.0 → 0.10.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,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 disabled by default. Enable 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;
@@ -36,10 +37,11 @@ const CONTEXT_IGNORED_FILENAMES = new Set([
36
37
  "yarn.lock",
37
38
  ]);
38
39
 
39
- export function isContextEligiblePath(path: string): boolean {
40
+ export function isContextEligiblePath(path: string, ignoreGlobs: readonly string[] = []): boolean {
40
41
  return (
41
42
  path !== "LICENSE" &&
42
43
  !CONTEXT_IGNORED_FILENAMES.has(basename(path)) &&
44
+ !ignoreGlobs.some((glob) => matchGlob(glob, path)) &&
43
45
  path !== ".pi/tau/ideas.jsonl" &&
44
46
  path !== ".working" &&
45
47
  !path.startsWith(".working/") &&
@@ -4,11 +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";
8
- import { registerTauSystemPromptContribution } from "../../shared/system-prompt-contributions.ts";
9
+ import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
9
10
  import { ContextPanel } from "./panel.ts";
10
11
  import { findProjectRoot, loadContextEntries, type ContextEntry } from "./definitions.ts";
12
+ import contextSettings from "./settings.ts";
11
13
  import { runContextSync, type ContextSyncDetails } from "./sync.ts";
14
+ import { formatContextValidationFailure, validateContextCatalog } from "./validation.ts";
12
15
 
13
16
  const contextSyncParams = Type.Object({}, { additionalProperties: false });
14
17
 
@@ -16,22 +19,16 @@ function compactResult(details: ContextSyncDetails) {
16
19
  const value = {
17
20
  outcome: details.outcome,
18
21
  summary: details.summary,
22
+ reason: details.reason,
19
23
  changedContextFiles: details.changedContextFiles,
20
24
  };
21
25
  return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details };
22
26
  }
23
27
 
24
28
  export default function contextExtension(pi: ExtensionAPI): void {
25
- let active: ContextEntry[] = [];
29
+ let validationSettings = contextSettings.defaults.validation;
30
+ let lastValidationFailure: string | undefined;
26
31
  const activeToolExecutions = new Map<string, { toolName: string; done: Promise<void>; complete: () => void }>();
27
- const unregisterPrompt = registerTauSystemPromptContribution({
28
- id: "context.selected-authority",
29
- order: 100,
30
- render: () =>
31
- active.length
32
- ? "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."
33
- : undefined,
34
- });
35
32
 
36
33
  pi.registerCommand("context", {
37
34
  description: "Select repository context entries and inject their files",
@@ -55,8 +52,6 @@ export default function contextExtension(pi: ExtensionAPI): void {
55
52
  },
56
53
  );
57
54
  if (!selected?.length) return;
58
- active = selected;
59
- pi.appendEntry("tau.context-selection", { ids: selected.map((entry) => entry.id) });
60
55
  const files = [...new Set(selected.flatMap((entry) => entry.files))].sort();
61
56
  pi.sendMessage(
62
57
  createInjectedContext(
@@ -102,7 +97,8 @@ export default function contextExtension(pi: ExtensionAPI): void {
102
97
  defineTool<typeof contextSyncParams, ContextSyncDetails | undefined>({
103
98
  name: "context_sync",
104
99
  label: "context_sync",
105
- description: "Synchronize repository context from current Git changes.",
100
+ description:
101
+ "Synchronize repository context membership after context validation reports changed, stale, or unassigned files.",
106
102
  parameters: contextSyncParams,
107
103
  async execute(id, _params, _signal, onUpdate, ctx) {
108
104
  await Promise.all(
@@ -161,22 +157,51 @@ export default function contextExtension(pi: ExtensionAPI): void {
161
157
  });
162
158
 
163
159
  pi.on("session_start", async (_event, ctx) => {
164
- const root = await findProjectRoot(ctx.cwd);
165
- const entries = await loadContextEntries(root);
166
- let selectionData: unknown;
167
- for (const entry of ctx.sessionManager.getBranch())
168
- if (entry.type === "custom" && entry.customType === "tau.context-selection") selectionData = entry.data;
169
- const ids =
170
- typeof selectionData === "object" &&
171
- selectionData !== null &&
172
- "ids" in selectionData &&
173
- Array.isArray(selectionData.ids)
174
- ? selectionData.ids.filter((id: unknown): id is string => typeof id === "string")
175
- : [];
176
- 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
+ }
177
203
  });
178
204
  pi.on("session_shutdown", () => {
179
- unregisterPrompt();
180
205
  for (const execution of activeToolExecutions.values()) execution.complete();
181
206
  activeToolExecutions.clear();
182
207
  });
@@ -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: false 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: false, 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,6 +6,7 @@ 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 {
@@ -18,6 +19,7 @@ import {
18
19
  validSlug,
19
20
  type ContextEntry,
20
21
  } from "./definitions.ts";
22
+ import contextSettings from "./settings.ts";
21
23
 
22
24
  const MAX_DIRTY_EVIDENCE = 4_000;
23
25
  const MAX_STRUCTURAL_PREVIEW = 1_500;
@@ -146,9 +148,10 @@ async function runContextSyncLocked(
146
148
  const git = createGitRunner(pi, ctx);
147
149
  const status = await loadRepoStatus(git);
148
150
  if (!status) throw new Error("No Git repository found");
149
- if (status.fileCount === 0) return noChange("Existing context mappings already fit the changed scope.");
150
- const evidence = await collectSyncEvidence(git, status.root);
151
- if (evidence.files.length === 0) return noChange("Changed files are outside context catalog scope.");
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.");
152
155
  const prompt = buildContextSyncPrompt(evidence);
153
156
  const plan = await generateToolValidated(
154
157
  ctx,
@@ -163,7 +166,7 @@ async function runContextSyncLocked(
163
166
  await onStatus?.("Applying context catalog changes");
164
167
  return applyContextSyncPlan(evidence.root, plan, evidence.entries, async () => {
165
168
  const currentEntries = await loadContextEntries(evidence.root);
166
- const currentFiles = await collectDirtyFiles(git, evidence.root, currentEntries);
169
+ const currentFiles = await collectDirtyFiles(git, evidence.root, currentEntries, settings.validation.ignoreGlobs);
167
170
  if ((await computeWorktreeSignature(git, evidence.root, currentFiles)) !== evidence.worktreeSignature)
168
171
  throw new Error("Repository changed during context sync. Rerun context sync.");
169
172
  if ((await computeCatalogSignature(evidence.root)) !== evidence.catalogSignature)
@@ -196,9 +199,13 @@ async function withSyncLock<T>(task: () => Promise<T>): Promise<T> {
196
199
  }
197
200
  }
198
201
 
199
- 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> {
200
207
  const entries = await loadContextEntries(root);
201
- const files = await collectDirtyFiles(git, root, entries);
208
+ const files = await collectDirtyFiles(git, root, entries, ignoreGlobs);
202
209
  const dirtyExisting = new Set<string>();
203
210
  for (const file of files)
204
211
  if (file.kind !== "deleted" && (await isFile(join(root, file.path)))) dirtyExisting.add(file.path);
@@ -206,10 +213,15 @@ async function collectSyncEvidence(git: GitRunner, root: string): Promise<SyncEv
206
213
  root,
207
214
  files.filter((file) => dirtyExisting.has(file.path)),
208
215
  );
209
- for (const path of dependencies) if (!isContextEligiblePath(path)) dependencies.delete(path);
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);
210
221
  const affectedIds = new Set<string>();
211
222
  for (const entry of entries) {
212
223
  if (
224
+ entry.files.some((path) => missingPaths.has(path)) ||
213
225
  entry.files.some(
214
226
  (path) => files.some((file) => file.path === path || file.oldPath === path) || dependencies.has(path),
215
227
  )
@@ -218,12 +230,9 @@ async function collectSyncEvidence(git: GitRunner, root: string): Promise<SyncEv
218
230
  }
219
231
  const affectedConcepts = new Set([...affectedIds].map((id) => id.split("/").slice(0, 2).join("/")));
220
232
  const siblingEntries = entries.filter((entry) => affectedConcepts.has(`${entry.tab}/${entry.concept}`));
221
- const missingPaths = new Set<string>();
222
- for (const entry of entries)
223
- for (const path of entry.files) if (!(await isFile(join(root, path)))) missingPaths.add(path);
224
233
  const siblingFiles = siblingEntries
225
234
  .flatMap((entry) => entry.files)
226
- .filter((path) => !missingPaths.has(path) && isContextEligiblePath(path));
235
+ .filter((path) => !missingPaths.has(path) && isContextEligiblePath(path, ignoreGlobs));
227
236
  const eligibleFiles = new Set([...dirtyExisting, ...dependencies, ...siblingFiles]);
228
237
  const structuralPreviews = new Map<string, string>();
229
238
  for (const path of [...new Set(siblingFiles)].sort()) {
@@ -259,6 +268,7 @@ async function collectDirtyFiles(
259
268
  git: GitRunner,
260
269
  root: string,
261
270
  entries: readonly ContextEntry[],
271
+ ignoreGlobs: readonly string[],
262
272
  ): Promise<SyncDirtyFile[]> {
263
273
  const raw = await git.run(["status", "--porcelain=v2", "-z", "--untracked-files=all"], { cwd: root });
264
274
  const parts = raw.split("\0").filter(Boolean);
@@ -306,7 +316,8 @@ async function collectDirtyFiles(
306
316
  const sorted = parsed
307
317
  .filter(
308
318
  (file) =>
309
- isContextEligiblePath(file.path) || (file.oldPath !== undefined && isContextEligiblePath(file.oldPath)),
319
+ isContextEligiblePath(file.path, ignoreGlobs) ||
320
+ (file.oldPath !== undefined && isContextEligiblePath(file.oldPath, ignoreGlobs)),
310
321
  )
311
322
  .sort((a, b) => a.path.localeCompare(b.path));
312
323
  const result: SyncDirtyFile[] = [];
@@ -420,13 +431,7 @@ async function isFile(path: string): Promise<boolean> {
420
431
 
421
432
  function buildContextSyncPrompt(evidence: SyncEvidence): string {
422
433
  const stale = evidence.entries.flatMap((entry) =>
423
- entry.files
424
- .filter(
425
- (path) =>
426
- evidence.missingPaths.has(path) &&
427
- !evidence.files.some((file) => file.path === path || file.oldPath === path),
428
- )
429
- .map((path) => `${entry.id}: ${path}`),
434
+ entry.files.filter((path) => evidence.missingPaths.has(path)).map((path) => `${entry.id}: ${path}`),
430
435
  );
431
436
  const affected = evidence.entries.filter((entry) => evidence.affectedIds.has(entry.id));
432
437
  const previews = [...evidence.structuralPreviews].map(([path, preview]) => `${path}\n${preview}`);
@@ -434,11 +439,11 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
434
439
  [
435
440
  "Synchronize reusable repository context scopes from the supplied Git and catalog evidence.",
436
441
  "Call submit_context_sync exactly once and produce no prose response.",
437
- "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.",
438
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.",
439
444
  "Follow direct local dependency candidates only when needed. Do not add package dependencies, generated files, incidental imports, or recursive dependencies.",
440
445
  "Reconsider granularity only inside affected concepts. Preserve broad entries when splitting would duplicate files without improving future work.",
441
- "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.",
442
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.",
443
448
  "Changed-file catalog and current memberships:",
444
449
  ...evidence.files.map(
@@ -458,7 +463,7 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
458
463
  ),
459
464
  "Changed files with no membership:",
460
465
  evidence.files
461
- .filter((file) => file.memberships.length === 0 && file.oldMemberships.length === 0)
466
+ .filter((file) => file.kind !== "deleted" && file.memberships.length === 0)
462
467
  .map((file) => file.path)
463
468
  .join("\n") || "(none)",
464
469
  "Affected entries:",
@@ -467,7 +472,7 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
467
472
  [...evidence.affectedConcepts].sort().join("\n") || "(none)",
468
473
  "Resolved direct dependency candidates:",
469
474
  [...evidence.dependencies].sort().join("\n") || "(none)",
470
- "Unrelated stale catalog paths (evidence only; do not modify):",
475
+ "Stale catalog paths that must be removed:",
471
476
  stale.join("\n") || "(none)",
472
477
  "Dirty files:",
473
478
  ...evidence.files.map(
@@ -488,6 +493,9 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
488
493
  if (!reason) throw new Error("Context sync reason is required.");
489
494
  if (input.outcome === "no-change") {
490
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.");
491
499
  return { outcome: "no-change", reason };
492
500
  }
493
501
  if (!Array.isArray(input.changes) || input.changes.length === 0)
@@ -567,6 +575,12 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
567
575
  if (evidence.eligibleFiles.has(path) && ![...final.values()].some((files) => files.includes(path)))
568
576
  throw new Error(`Deleting entry would orphan surviving file: ${path}`);
569
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}`);
570
584
  return { outcome: "apply", reason, changes };
571
585
  }
572
586
 
@@ -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
+ }
@@ -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,11 +55,12 @@ 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);
61
62
  const tag = await latestReleaseTag(git, repo.root);
62
- const bump = await recommendBump(git, repo.root, tag);
63
+ const bump = await recommendBump(git, repo.root, tag, currentVersion);
63
64
  const version = tag ? incrementVersion(currentVersion, bump) : currentVersion;
64
65
  const selectedBump = await ctx.ui.select(`Release ${version}`, [
65
66
  `Use recommended ${bump} bump`,
@@ -152,10 +153,15 @@ async function latestReleaseTag(git: GitRunner, root: string): Promise<string |
152
153
  return tags.split("\n").find((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
153
154
  }
154
155
 
155
- async function recommendBump(git: GitRunner, root: string, tag: string | undefined): Promise<Bump> {
156
+ async function recommendBump(
157
+ git: GitRunner,
158
+ root: string,
159
+ tag: string | undefined,
160
+ currentVersion: string,
161
+ ): Promise<Bump> {
156
162
  const range = tag ? `${tag}..HEAD` : "HEAD";
157
163
  const commits = await git.run(["log", "--format=%B%x00", range], { cwd: root });
158
- if (/BREAKING CHANGE:|^[^\n]*!:/m.test(commits)) return "major";
164
+ if (/BREAKING CHANGE:|^[^\n]*!:/m.test(commits)) return currentVersion.startsWith("0.") ? "minor" : "major";
159
165
  if (/^feat(?:\([^\n]+\))?:/m.test(commits)) return "minor";
160
166
  return "patch";
161
167
  }
@@ -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.
@@ -0,0 +1,138 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { type Dirent, readdirSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+
6
+ const ROOT_SNAPSHOT_MAX_PATHS = 300;
7
+
8
+ interface SnapshotEntry {
9
+ dirent: Dirent;
10
+ path: string;
11
+ }
12
+
13
+ export interface RuntimeContext {
14
+ cwd: string;
15
+ rootSnapshot: readonly string[];
16
+ }
17
+
18
+ export function freezeRuntimeContext(cwd: string): RuntimeContext {
19
+ return { cwd: cwd.replace(/\\/g, "/"), rootSnapshot: listRootSnapshot(cwd) };
20
+ }
21
+
22
+ export function formatLocalDateKey(date: Date): string {
23
+ const year = date.getFullYear();
24
+ const month = String(date.getMonth() + 1).padStart(2, "0");
25
+ const day = String(date.getDate()).padStart(2, "0");
26
+ return `${year}-${month}-${day}`;
27
+ }
28
+
29
+ export function formatLocalDisplayDate(date: Date): string {
30
+ const months = [
31
+ "January",
32
+ "February",
33
+ "March",
34
+ "April",
35
+ "May",
36
+ "June",
37
+ "July",
38
+ "August",
39
+ "September",
40
+ "October",
41
+ "November",
42
+ "December",
43
+ ];
44
+ return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`;
45
+ }
46
+
47
+ export function fingerprintRuntimeSnapshot(context: RuntimeContext): string {
48
+ return createHash("sha256")
49
+ .update(JSON.stringify({ version: 1, cwd: context.cwd, paths: context.rootSnapshot }))
50
+ .digest("hex");
51
+ }
52
+
53
+ export function formatRuntimeContextMessage(displayDate: string, rootSnapshot: readonly string[] | undefined): string {
54
+ const blocks = [`Current local date: ${displayDate}`];
55
+ if (rootSnapshot?.length) {
56
+ blocks.push(`Root directory snapshot (depth 2):\n${rootSnapshot.map((path) => `- ${path}`).join("\n")}`);
57
+ }
58
+ return blocks.join("\n");
59
+ }
60
+
61
+ function listRootSnapshot(cwd: string): string[] {
62
+ const root = resolve(cwd);
63
+ const rootEntries = listSnapshotEntries(root, ".");
64
+ const ignoredRootPaths = gitIgnoredPaths(
65
+ root,
66
+ rootEntries.map((entry) => entry.path),
67
+ );
68
+ const visibleRootEntries = rootEntries.filter(
69
+ (entry) => !isAlwaysHiddenFromSnapshot(entry.dirent.name) && !ignoredRootPaths.has(entry.path),
70
+ );
71
+ const childEntriesByParent = new Map<string, SnapshotEntry[]>();
72
+ const childPaths: string[] = [];
73
+
74
+ for (const entry of visibleRootEntries) {
75
+ if (!entry.dirent.isDirectory()) continue;
76
+ const childEntries = listSnapshotEntries(root, entry.path).filter(
77
+ (childEntry) => !isAlwaysHiddenFromSnapshot(childEntry.dirent.name),
78
+ );
79
+ childEntriesByParent.set(entry.path, childEntries);
80
+ childPaths.push(...childEntries.map((childEntry) => childEntry.path));
81
+ }
82
+
83
+ const ignoredChildPaths = gitIgnoredPaths(root, childPaths);
84
+ const paths: string[] = [];
85
+
86
+ for (const entry of visibleRootEntries) {
87
+ pushSnapshotPath(paths, entry);
88
+ if (paths.length >= ROOT_SNAPSHOT_MAX_PATHS) break;
89
+
90
+ for (const childEntry of childEntriesByParent.get(entry.path) ?? []) {
91
+ if (ignoredChildPaths.has(childEntry.path)) continue;
92
+ pushSnapshotPath(paths, childEntry);
93
+ if (paths.length >= ROOT_SNAPSHOT_MAX_PATHS) break;
94
+ }
95
+ }
96
+
97
+ return paths.length === ROOT_SNAPSHOT_MAX_PATHS ? [...paths, "..."] : paths;
98
+ }
99
+
100
+ function listSnapshotEntries(root: string, relativePath: string): SnapshotEntry[] {
101
+ let entries: Dirent[];
102
+ try {
103
+ entries = readdirSync(join(root, relativePath), { withFileTypes: true });
104
+ } catch {
105
+ return [];
106
+ }
107
+
108
+ return entries
109
+ .sort((left, right) => left.name.localeCompare(right.name))
110
+ .map((dirent) => ({
111
+ dirent,
112
+ path: relativePath === "." ? dirent.name : `${relativePath}/${dirent.name}`,
113
+ }));
114
+ }
115
+
116
+ function gitIgnoredPaths(root: string, paths: readonly string[]): Set<string> {
117
+ if (paths.length === 0) return new Set();
118
+
119
+ const result = spawnSync("git", ["check-ignore", "-z", "--stdin"], {
120
+ cwd: root,
121
+ encoding: "utf8",
122
+ input: `${paths.join("\0")}\0`,
123
+ });
124
+
125
+ if (result.status === 0 || result.status === 1) {
126
+ return new Set(result.stdout.split("\0").filter(Boolean));
127
+ }
128
+ return new Set(paths.filter((path) => path.split("/").includes("node_modules")));
129
+ }
130
+
131
+ function isAlwaysHiddenFromSnapshot(name: string): boolean {
132
+ return name === ".git";
133
+ }
134
+
135
+ function pushSnapshotPath(paths: string[], entry: SnapshotEntry): void {
136
+ if (paths.length >= ROOT_SNAPSHOT_MAX_PATHS) return;
137
+ paths.push(entry.dirent.isDirectory() ? `${entry.path}/` : entry.path);
138
+ }