@shanepadgett/tau-agent 0.8.0 → 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/context/README.md +16 -1
- package/extensions/context/definitions.ts +3 -1
- package/extensions/context/index.ts +52 -27
- package/extensions/context/settings.ts +33 -0
- package/extensions/context/sync.ts +37 -23
- package/extensions/context/validation.ts +73 -0
- 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 -48
- package/extensions/soul/README.md +2 -2
- package/extensions/soul/index.ts +4 -88
- package/extensions/soul/prompt.ts +3 -261
- package/extensions/subagent/index.ts +9 -14
- package/extensions/tau-help/help.md +6 -2
- package/extensions/tau-help/index.ts +12 -0
- package/extensions/turn-budget/index.ts +4 -13
- package/package.json +2 -2
- package/schemas/tau.schema.json +25 -0
- package/shared/glob.ts +34 -0
- package/shared/system-prompt-contributions.ts +0 -40
|
@@ -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;
|
|
@@ -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 {
|
|
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
|
|
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:
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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: 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,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
|
-
|
|
150
|
-
const evidence = await collectSyncEvidence(git, status.root);
|
|
151
|
-
if (evidence.files.length === 0
|
|
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(
|
|
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) ||
|
|
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
|
|
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
|
-
"
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
@@ -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
|
+
}
|