@shanepadgett/tau-agent 0.16.0 → 0.18.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.
- package/docs/subagents.md +25 -6
- package/extensions/attention/README.md +1 -0
- package/extensions/attention/index.ts +38 -0
- package/extensions/context/README.md +13 -3
- package/extensions/context/definitions.ts +3 -15
- package/extensions/context/evidence.ts +516 -0
- package/extensions/context/index.ts +114 -116
- package/extensions/context/panel.ts +60 -0
- package/extensions/context/settings.ts +30 -1
- package/extensions/context/sync.ts +171 -686
- package/extensions/context/validation.ts +4 -1
- package/extensions/context/write-scope.ts +109 -0
- package/extensions/context-pruning/README.md +22 -0
- package/extensions/context-pruning/file-evidence.ts +265 -0
- package/extensions/context-pruning/index.ts +379 -0
- package/extensions/context-pruning/projection.ts +108 -0
- package/extensions/context-pruning/prune.ts +346 -0
- package/extensions/context-pruning/render.ts +179 -0
- package/extensions/context-pruning/settings.ts +41 -0
- package/extensions/explore/README.md +3 -1
- package/extensions/explore/autoread.ts +85 -22
- package/extensions/explore/full-file-knowledge.ts +234 -0
- package/extensions/explore/index.ts +2 -3
- package/extensions/explore/read-cache.ts +150 -86
- package/extensions/explore/read-snapshots.ts +17 -4
- package/extensions/explore/read.ts +57 -38
- package/extensions/footer/index.ts +62 -60
- package/extensions/run-summary/index.ts +5 -5
- package/extensions/silent-command-runner/README.md +1 -1
- package/extensions/silent-command-runner/index.ts +45 -28
- package/extensions/soul/prompt.ts +3 -1
- package/extensions/subagent/README.md +21 -5
- package/extensions/subagent/agents/context-sync.md +97 -0
- package/extensions/subagent/agents/{generalist.md → dormant/generalist.md} +6 -0
- package/extensions/subagent/agents/{scout.md → dormant/scout.md} +6 -0
- package/extensions/subagent/agents/review.md +48 -0
- package/extensions/subagent/agents/web-research.md +6 -0
- package/extensions/subagent/agents.ts +15 -2
- package/extensions/subagent/cmux-dashboard.ts +454 -0
- package/extensions/subagent/index.ts +181 -234
- package/extensions/subagent/render.ts +1 -1
- package/extensions/subagent/resume.ts +78 -0
- package/extensions/subagent/run.ts +213 -118
- package/extensions/subagent/runtime.ts +856 -0
- package/extensions/subagent/session-resource.ts +169 -0
- package/extensions/tau-help/help.md +6 -2
- package/extensions/turn-budget/index.ts +8 -36
- package/package.json +2 -2
- package/schemas/tau.schema.json +48 -1
- package/shared/context-pruning-state.ts +364 -0
- package/shared/events.ts +18 -0
- package/shared/model-fallback/index.ts +21 -10
- package/shared/model-fallback/types.ts +5 -3
- package/shared/settings/load.ts +78 -1
- package/shared/tool-row-state.ts +21 -1
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { createGitRunner, loadRepoStatus, type GitRunner } from "../../shared/git.ts";
|
|
7
|
+
import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
8
|
+
import { truncAt } from "../../shared/text.ts";
|
|
9
|
+
import {
|
|
10
|
+
contextEntryPaths,
|
|
11
|
+
isContextEligiblePath,
|
|
12
|
+
isSensitiveContextPath,
|
|
13
|
+
loadContextEntries,
|
|
14
|
+
type ContextEntry,
|
|
15
|
+
} from "./definitions.ts";
|
|
16
|
+
import contextSettings from "./settings.ts";
|
|
17
|
+
import { formatContextValidationFailure, validateContextCatalog } from "./validation.ts";
|
|
18
|
+
|
|
19
|
+
export const CONTEXT_SYNC_EVIDENCE_TOOL = "context_sync_evidence";
|
|
20
|
+
|
|
21
|
+
const MAX_DIRTY_EVIDENCE = 4_000;
|
|
22
|
+
const MAX_STRUCTURAL_PREVIEW = 1_500;
|
|
23
|
+
const MAX_SECTION = 48_000;
|
|
24
|
+
const MAX_UNTRACKED_BYTES = 12_000;
|
|
25
|
+
const EVIDENCE_CONCURRENCY = 4;
|
|
26
|
+
|
|
27
|
+
const evidenceParams = Type.Object(
|
|
28
|
+
{
|
|
29
|
+
section: Type.Union(
|
|
30
|
+
[
|
|
31
|
+
Type.Literal("overview"),
|
|
32
|
+
Type.Literal("catalog"),
|
|
33
|
+
Type.Literal("dirty"),
|
|
34
|
+
Type.Literal("file"),
|
|
35
|
+
Type.Literal("dependencies"),
|
|
36
|
+
Type.Literal("previews"),
|
|
37
|
+
Type.Literal("invariants"),
|
|
38
|
+
],
|
|
39
|
+
{
|
|
40
|
+
description:
|
|
41
|
+
"Evidence slice to load. Use overview first, then file/previews/invariants as needed. Call invariants after catalog edits.",
|
|
42
|
+
},
|
|
43
|
+
),
|
|
44
|
+
path: Type.Optional(
|
|
45
|
+
Type.String({
|
|
46
|
+
description: "Project-relative path required when section is file.",
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
},
|
|
50
|
+
{ additionalProperties: false },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
export interface SyncDirtyFile {
|
|
54
|
+
id: number;
|
|
55
|
+
path: string;
|
|
56
|
+
status: string;
|
|
57
|
+
kind: "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked";
|
|
58
|
+
untracked: boolean;
|
|
59
|
+
oldPath?: string;
|
|
60
|
+
memberships: string[];
|
|
61
|
+
oldMemberships: string[];
|
|
62
|
+
evidence: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SyncEvidence {
|
|
66
|
+
root: string;
|
|
67
|
+
ignoreGlobs: readonly string[];
|
|
68
|
+
files: SyncDirtyFile[];
|
|
69
|
+
entries: ContextEntry[];
|
|
70
|
+
dirtyExisting: Set<string>;
|
|
71
|
+
dependencies: Set<string>;
|
|
72
|
+
affectedIds: Set<string>;
|
|
73
|
+
affectedConcepts: Set<string>;
|
|
74
|
+
eligibleFiles: Set<string>;
|
|
75
|
+
missingPaths: Set<string>;
|
|
76
|
+
structuralPreviews: Map<string, string>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function registerContextSyncEvidenceTool(pi: ExtensionAPI): void {
|
|
80
|
+
pi.registerTool(
|
|
81
|
+
defineTool<typeof evidenceParams>({
|
|
82
|
+
name: CONTEXT_SYNC_EVIDENCE_TOOL,
|
|
83
|
+
label: "context_sync_evidence",
|
|
84
|
+
description:
|
|
85
|
+
"Load repository and context-catalog evidence for context-sync. Prefer overview, then narrower sections.",
|
|
86
|
+
parameters: evidenceParams,
|
|
87
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
88
|
+
const text = await loadEvidenceSection(pi, ctx, params.section, params.path);
|
|
89
|
+
return { content: [{ type: "text" as const, text }], details: undefined };
|
|
90
|
+
},
|
|
91
|
+
renderCall(args, theme, context) {
|
|
92
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
93
|
+
const suffix = args.path ? ` ${args.path}` : "";
|
|
94
|
+
text.setText(theme.fg("toolTitle", `${CONTEXT_SYNC_EVIDENCE_TOOL} ${args.section}${suffix}`));
|
|
95
|
+
return text;
|
|
96
|
+
},
|
|
97
|
+
renderResult(result, options, theme, context) {
|
|
98
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
99
|
+
const output = result.content.map((part) => (part.type === "text" ? part.text : "")).join("");
|
|
100
|
+
text.setText(options.isPartial ? theme.fg("dim", output) : truncAt(output, 400));
|
|
101
|
+
return text;
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function hideContextSyncEvidenceTool(pi: ExtensionAPI): void {
|
|
108
|
+
const active = pi.getActiveTools();
|
|
109
|
+
if (!active.includes(CONTEXT_SYNC_EVIDENCE_TOOL)) return;
|
|
110
|
+
// Keep active in the context-sync child toolset (may include bash for read-only inspection).
|
|
111
|
+
const parentMarkers = ["subagent", "edit", "write", "load_tools"];
|
|
112
|
+
if (!parentMarkers.some((name) => active.includes(name))) return;
|
|
113
|
+
pi.setActiveTools(active.filter((name) => name !== CONTEXT_SYNC_EVIDENCE_TOOL));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function collectSyncEvidence(
|
|
117
|
+
git: GitRunner,
|
|
118
|
+
root: string,
|
|
119
|
+
ignoreGlobs: readonly string[],
|
|
120
|
+
): Promise<SyncEvidence> {
|
|
121
|
+
const entries = await loadContextEntries(root);
|
|
122
|
+
const files = await collectDirtyFiles(git, root, entries, ignoreGlobs);
|
|
123
|
+
const dirtyExisting = new Set<string>();
|
|
124
|
+
for (const file of files)
|
|
125
|
+
if (file.kind !== "deleted" && (await isFile(join(root, file.path)))) dirtyExisting.add(file.path);
|
|
126
|
+
const dependencies = await discoverDirectDependencies(
|
|
127
|
+
root,
|
|
128
|
+
files.filter((file) => dirtyExisting.has(file.path)),
|
|
129
|
+
);
|
|
130
|
+
for (const path of dependencies) if (!isContextEligiblePath(path, ignoreGlobs)) dependencies.delete(path);
|
|
131
|
+
const missingPaths = new Set<string>();
|
|
132
|
+
for (const entry of entries)
|
|
133
|
+
for (const path of contextEntryPaths(entry))
|
|
134
|
+
if (isContextEligiblePath(path, ignoreGlobs) && !(await isFile(join(root, path)))) missingPaths.add(path);
|
|
135
|
+
const affectedIds = new Set<string>();
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
const paths = contextEntryPaths(entry);
|
|
138
|
+
if (
|
|
139
|
+
paths.some((path) => missingPaths.has(path)) ||
|
|
140
|
+
paths.some(
|
|
141
|
+
(path) => files.some((file) => file.path === path || file.oldPath === path) || dependencies.has(path),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
affectedIds.add(entry.id);
|
|
145
|
+
}
|
|
146
|
+
const affectedConcepts = new Set([...affectedIds].map((id) => id.split("/").slice(0, 2).join("/")));
|
|
147
|
+
const siblingEntries = entries.filter((entry) => affectedConcepts.has(`${entry.tab}/${entry.concept}`));
|
|
148
|
+
const siblingFiles = siblingEntries
|
|
149
|
+
.flatMap((entry) => contextEntryPaths(entry))
|
|
150
|
+
.filter((path) => !missingPaths.has(path) && isContextEligiblePath(path, ignoreGlobs));
|
|
151
|
+
const eligibleFiles = new Set([...dirtyExisting, ...dependencies, ...siblingFiles]);
|
|
152
|
+
const structuralPreviews = new Map<string, string>();
|
|
153
|
+
for (const path of [...new Set(siblingFiles)].sort()) {
|
|
154
|
+
try {
|
|
155
|
+
const content = await readFile(join(root, path), "utf8");
|
|
156
|
+
const lines = content
|
|
157
|
+
.split("\n")
|
|
158
|
+
.filter((line) => line.trim())
|
|
159
|
+
.slice(0, 30)
|
|
160
|
+
.join("\n");
|
|
161
|
+
structuralPreviews.set(path, truncAt(lines, MAX_STRUCTURAL_PREVIEW));
|
|
162
|
+
} catch {
|
|
163
|
+
structuralPreviews.set(path, "preview unavailable");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
root,
|
|
168
|
+
ignoreGlobs,
|
|
169
|
+
files,
|
|
170
|
+
entries,
|
|
171
|
+
dirtyExisting,
|
|
172
|
+
dependencies,
|
|
173
|
+
affectedIds,
|
|
174
|
+
affectedConcepts,
|
|
175
|
+
eligibleFiles,
|
|
176
|
+
missingPaths,
|
|
177
|
+
structuralPreviews,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function discoverDirectDependencies(
|
|
182
|
+
root: string,
|
|
183
|
+
files: readonly Pick<SyncDirtyFile, "path" | "evidence">[],
|
|
184
|
+
): Promise<Set<string>> {
|
|
185
|
+
const result = new Set<string>();
|
|
186
|
+
const knownExtensions = [
|
|
187
|
+
".ts",
|
|
188
|
+
".tsx",
|
|
189
|
+
".js",
|
|
190
|
+
".jsx",
|
|
191
|
+
".mjs",
|
|
192
|
+
".cjs",
|
|
193
|
+
".json",
|
|
194
|
+
".py",
|
|
195
|
+
".rs",
|
|
196
|
+
".go",
|
|
197
|
+
".c",
|
|
198
|
+
".cc",
|
|
199
|
+
".cpp",
|
|
200
|
+
".h",
|
|
201
|
+
".hpp",
|
|
202
|
+
];
|
|
203
|
+
for (const file of files) {
|
|
204
|
+
let content = file.evidence;
|
|
205
|
+
try {
|
|
206
|
+
content += `\n${await readFile(join(root, file.path), "utf8")}`;
|
|
207
|
+
} catch {
|
|
208
|
+
/* evidence remains */
|
|
209
|
+
}
|
|
210
|
+
const statement = /(?:import|export|require|include|use|mod(?:ule)?)\b[^\n]*?["'`]([^"'`]+)["'`]/g;
|
|
211
|
+
for (const match of content.matchAll(statement)) {
|
|
212
|
+
const specifier = match[1];
|
|
213
|
+
if (!specifier?.startsWith("./") && !specifier?.startsWith("../")) continue;
|
|
214
|
+
const absolute = resolve(dirname(join(root, file.path)), specifier);
|
|
215
|
+
const relativePath = relative(root, absolute).split(sep).join("/");
|
|
216
|
+
if (!relativePath || relativePath === ".." || relativePath.startsWith("../")) continue;
|
|
217
|
+
const candidates = extname(absolute)
|
|
218
|
+
? [absolute]
|
|
219
|
+
: [
|
|
220
|
+
...knownExtensions.map((extension) => `${absolute}${extension}`),
|
|
221
|
+
...knownExtensions.map((extension) => join(absolute, `index${extension}`)),
|
|
222
|
+
];
|
|
223
|
+
const matches: string[] = [];
|
|
224
|
+
if (await isFile(absolute)) matches.push(absolute);
|
|
225
|
+
else for (const candidate of candidates) if (await isFile(candidate)) matches.push(candidate);
|
|
226
|
+
if (matches.length === 1)
|
|
227
|
+
result.add(
|
|
228
|
+
relative(root, matches[0] ?? absolute)
|
|
229
|
+
.split(sep)
|
|
230
|
+
.join("/"),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function formatEvidenceSection(evidence: SyncEvidence, section: string, path?: string): string {
|
|
238
|
+
switch (section) {
|
|
239
|
+
case "overview":
|
|
240
|
+
return truncAt(formatOverview(evidence), MAX_SECTION);
|
|
241
|
+
case "catalog":
|
|
242
|
+
return truncAt(formatCatalog(evidence), MAX_SECTION);
|
|
243
|
+
case "dirty":
|
|
244
|
+
return truncAt(formatDirty(evidence), MAX_SECTION);
|
|
245
|
+
case "file": {
|
|
246
|
+
const target = path?.trim();
|
|
247
|
+
if (!target) throw new Error("path is required when section is file");
|
|
248
|
+
const file = evidence.files.find((item) => item.path === target || item.oldPath === target);
|
|
249
|
+
if (!file) throw new Error(`No dirty evidence for path: ${target}`);
|
|
250
|
+
return truncAt(formatDirtyFile(file), MAX_SECTION);
|
|
251
|
+
}
|
|
252
|
+
case "dependencies":
|
|
253
|
+
return truncAt(
|
|
254
|
+
["Resolved direct dependency candidates:", ...[...evidence.dependencies].sort()].join("\n") ||
|
|
255
|
+
"Resolved direct dependency candidates:\n(none)",
|
|
256
|
+
MAX_SECTION,
|
|
257
|
+
);
|
|
258
|
+
case "previews":
|
|
259
|
+
return truncAt(formatPreviews(evidence), MAX_SECTION);
|
|
260
|
+
case "invariants":
|
|
261
|
+
return truncAt(formatInvariants(evidence), MAX_SECTION);
|
|
262
|
+
default:
|
|
263
|
+
throw new Error(`Unknown evidence section: ${section}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function loadEvidenceSection(
|
|
268
|
+
pi: ExtensionAPI,
|
|
269
|
+
ctx: ExtensionContext,
|
|
270
|
+
section: string,
|
|
271
|
+
path: string | undefined,
|
|
272
|
+
): Promise<string> {
|
|
273
|
+
if (!ctx.isProjectTrusted()) throw new Error("Context sync evidence requires a trusted project");
|
|
274
|
+
const git = createGitRunner(pi, ctx);
|
|
275
|
+
const status = await loadRepoStatus(git);
|
|
276
|
+
if (!status) throw new Error("No Git repository found");
|
|
277
|
+
const settings = await loadTauExtensionSettings(ctx, contextSettings);
|
|
278
|
+
if (section === "invariants") {
|
|
279
|
+
const failure = formatContextValidationFailure(
|
|
280
|
+
await validateContextCatalog(git, status.root, settings.validation.ignoreGlobs),
|
|
281
|
+
);
|
|
282
|
+
return failure ?? "Context catalog invariants hold. No uncovered eligible dirty files. No stale catalog paths.";
|
|
283
|
+
}
|
|
284
|
+
const evidence = await collectSyncEvidence(git, status.root, settings.validation.ignoreGlobs);
|
|
285
|
+
return formatEvidenceSection(evidence, section, path);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function formatOverview(evidence: SyncEvidence): string {
|
|
289
|
+
const uncovered = evidence.files
|
|
290
|
+
.filter((file) => file.kind !== "deleted" && file.memberships.length === 0)
|
|
291
|
+
.map((file) => file.path);
|
|
292
|
+
const stale = [...evidence.missingPaths].sort();
|
|
293
|
+
return [
|
|
294
|
+
`Project root: ${evidence.root}`,
|
|
295
|
+
`Ignore globs: ${evidence.ignoreGlobs.join(", ") || "(none)"}`,
|
|
296
|
+
"",
|
|
297
|
+
"Ladder (answer in order before editing):",
|
|
298
|
+
"1. Domain (tab/folder): reuse, new, or split?",
|
|
299
|
+
"2. Concept (toml): reuse, new, split, or merge?",
|
|
300
|
+
"3. Entry (section): update, new, split, delete, or move?",
|
|
301
|
+
"4. Bloat: any touched scope now a junk drawer?",
|
|
302
|
+
"5. Membership: files/anchors under winners only. Preserve existing read vs anchor class.",
|
|
303
|
+
"",
|
|
304
|
+
"Edit only files under .pi/contexts/. Use patch. Do not use bash.",
|
|
305
|
+
"Every eligible changed non-deleted file must belong to at least one entry. Remove every stale catalog path.",
|
|
306
|
+
"Prefer honest typology over stuffing paths into the nearest existing feature bucket.",
|
|
307
|
+
"",
|
|
308
|
+
`Dirty eligible files: ${evidence.files.length}`,
|
|
309
|
+
`Uncovered changed files: ${uncovered.length}`,
|
|
310
|
+
`Stale catalog paths: ${stale.length}`,
|
|
311
|
+
`Affected entries: ${evidence.affectedIds.size}`,
|
|
312
|
+
`Affected concepts: ${evidence.affectedConcepts.size}`,
|
|
313
|
+
`Direct dependencies: ${evidence.dependencies.size}`,
|
|
314
|
+
"",
|
|
315
|
+
"Changed files with no membership:",
|
|
316
|
+
...(uncovered.length ? uncovered.map((path) => `- ${path}`) : ["(none)"]),
|
|
317
|
+
"",
|
|
318
|
+
"Stale catalog paths:",
|
|
319
|
+
...(stale.length ? stale.map((path) => `- ${path}`) : ["(none)"]),
|
|
320
|
+
"",
|
|
321
|
+
"Affected entries:",
|
|
322
|
+
...(evidence.affectedIds.size ? [...evidence.affectedIds].sort().map((id) => `- ${id}`) : ["(none)"]),
|
|
323
|
+
"",
|
|
324
|
+
"Affected concepts:",
|
|
325
|
+
...(evidence.affectedConcepts.size ? [...evidence.affectedConcepts].sort().map((id) => `- ${id}`) : ["(none)"]),
|
|
326
|
+
"",
|
|
327
|
+
"Dirty file index:",
|
|
328
|
+
...evidence.files.map(
|
|
329
|
+
(file) =>
|
|
330
|
+
`[${file.id}] ${file.status} ${file.path}${file.oldPath ? ` <- ${file.oldPath}` : ""} kind=${file.kind} memberships=${file.memberships.join(",") || "none"} oldMemberships=${file.oldMemberships.join(",") || "none"}`,
|
|
331
|
+
),
|
|
332
|
+
"",
|
|
333
|
+
"Next: context_sync_evidence section=catalog, then dirty or file as needed. After edits, section=invariants.",
|
|
334
|
+
].join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function formatCatalog(evidence: SyncEvidence): string {
|
|
338
|
+
return [
|
|
339
|
+
"Complete context catalog skeleton:",
|
|
340
|
+
JSON.stringify(
|
|
341
|
+
evidence.entries.map((entry) => ({
|
|
342
|
+
id: entry.id,
|
|
343
|
+
tab: entry.tab,
|
|
344
|
+
concept: entry.concept,
|
|
345
|
+
conceptName: entry.conceptName,
|
|
346
|
+
conceptDescription: entry.conceptDescription,
|
|
347
|
+
entry: entry.name,
|
|
348
|
+
description: entry.description,
|
|
349
|
+
files: entry.files,
|
|
350
|
+
anchors: entry.anchors,
|
|
351
|
+
missing: contextEntryPaths(entry).filter((path) => evidence.missingPaths.has(path)),
|
|
352
|
+
path: entry.path,
|
|
353
|
+
})),
|
|
354
|
+
null,
|
|
355
|
+
2,
|
|
356
|
+
),
|
|
357
|
+
].join("\n");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function formatDirty(evidence: SyncEvidence): string {
|
|
361
|
+
return ["Dirty files:", ...evidence.files.map((file) => formatDirtyFile(file))].join("\n\n");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function formatDirtyFile(file: SyncDirtyFile, includeEvidence = true): string {
|
|
365
|
+
const header = [
|
|
366
|
+
`[${file.id}] ${file.status} ${file.path}${file.oldPath ? ` <- ${file.oldPath}` : ""}`,
|
|
367
|
+
`kind=${file.kind}`,
|
|
368
|
+
`memberships=${file.memberships.join(",") || "none"}`,
|
|
369
|
+
`oldMemberships=${file.oldMemberships.join(",") || "none"}`,
|
|
370
|
+
].join("\n");
|
|
371
|
+
return includeEvidence ? `${header}\n${file.evidence}` : header;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function formatPreviews(evidence: SyncEvidence): string {
|
|
375
|
+
const lines = ["Bounded affected-entry structural previews:"];
|
|
376
|
+
if (evidence.structuralPreviews.size === 0) lines.push("(none)");
|
|
377
|
+
else
|
|
378
|
+
for (const [path, preview] of [...evidence.structuralPreviews].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
379
|
+
lines.push("", path, preview);
|
|
380
|
+
}
|
|
381
|
+
return lines.join("\n");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function formatInvariants(evidence: SyncEvidence): string {
|
|
385
|
+
const uncovered = evidence.files
|
|
386
|
+
.filter((file) => file.kind !== "deleted" && file.memberships.length === 0)
|
|
387
|
+
.map((file) => file.path)
|
|
388
|
+
.sort();
|
|
389
|
+
const stale = [...evidence.missingPaths].sort();
|
|
390
|
+
if (uncovered.length === 0 && stale.length === 0)
|
|
391
|
+
return "Context catalog invariants hold. No uncovered eligible dirty files. No stale catalog paths.";
|
|
392
|
+
return [
|
|
393
|
+
"Context catalog invariants failed.",
|
|
394
|
+
"",
|
|
395
|
+
"Uncovered changed files:",
|
|
396
|
+
...(uncovered.length ? uncovered.map((path) => `- ${path}`) : ["(none)"]),
|
|
397
|
+
"",
|
|
398
|
+
"Stale catalog paths:",
|
|
399
|
+
...(stale.length ? stale.map((path) => `- ${path}`) : ["(none)"]),
|
|
400
|
+
"",
|
|
401
|
+
"Continue editing .pi/contexts until invariants hold, then recheck.",
|
|
402
|
+
].join("\n");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function collectDirtyFiles(
|
|
406
|
+
git: GitRunner,
|
|
407
|
+
root: string,
|
|
408
|
+
entries: readonly ContextEntry[],
|
|
409
|
+
ignoreGlobs: readonly string[],
|
|
410
|
+
): Promise<SyncDirtyFile[]> {
|
|
411
|
+
const raw = await git.run(["status", "--porcelain=v2", "-z", "--untracked-files=all"], { cwd: root });
|
|
412
|
+
const parts = raw.split("\0").filter(Boolean);
|
|
413
|
+
const parsed: Omit<SyncDirtyFile, "id" | "memberships" | "oldMemberships" | "evidence">[] = [];
|
|
414
|
+
for (let index = 0; index < parts.length; index++) {
|
|
415
|
+
const record = parts[index];
|
|
416
|
+
if (!record) continue;
|
|
417
|
+
if (record[0] === "u")
|
|
418
|
+
throw new Error("Unmerged conflict entries detected; resolve conflicts before context sync.");
|
|
419
|
+
if (record[0] === "?") {
|
|
420
|
+
parsed.push({ path: record.slice(2), status: "??", kind: "untracked", untracked: true });
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const fields = record.split(" ");
|
|
424
|
+
if (record[0] === "1") {
|
|
425
|
+
const status = (fields[1] ?? "..").replace(/\./g, " ");
|
|
426
|
+
parsed.push({
|
|
427
|
+
path: fields.slice(8).join(" "),
|
|
428
|
+
status,
|
|
429
|
+
kind: status.includes("D") ? "deleted" : status.includes("A") ? "added" : "modified",
|
|
430
|
+
untracked: false,
|
|
431
|
+
});
|
|
432
|
+
} else if (record[0] === "2") {
|
|
433
|
+
const oldPath = parts[index + 1];
|
|
434
|
+
const status = (fields[1] ?? "..").replace(/\./g, " ");
|
|
435
|
+
parsed.push({
|
|
436
|
+
path: fields.slice(9).join(" "),
|
|
437
|
+
status,
|
|
438
|
+
kind: fields[8]?.startsWith("C") ? "copied" : "renamed",
|
|
439
|
+
untracked: false,
|
|
440
|
+
...(oldPath ? { oldPath } : {}),
|
|
441
|
+
});
|
|
442
|
+
index++;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const sensitive = parsed.filter(
|
|
446
|
+
(file) =>
|
|
447
|
+
file.kind !== "deleted" &&
|
|
448
|
+
(isSensitiveContextPath(file.path) || (file.oldPath !== undefined && isSensitiveContextPath(file.oldPath))),
|
|
449
|
+
);
|
|
450
|
+
if (sensitive.length > 0)
|
|
451
|
+
throw new Error(
|
|
452
|
+
`Sensitive files cannot be inspected by context sync:\n${sensitive.map((file) => `- ${file.path}`).join("\n")}`,
|
|
453
|
+
);
|
|
454
|
+
const sorted = parsed
|
|
455
|
+
.filter(
|
|
456
|
+
(file) =>
|
|
457
|
+
isContextEligiblePath(file.path, ignoreGlobs) ||
|
|
458
|
+
(file.oldPath !== undefined && isContextEligiblePath(file.oldPath, ignoreGlobs)),
|
|
459
|
+
)
|
|
460
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
461
|
+
const result: SyncDirtyFile[] = [];
|
|
462
|
+
for (let offset = 0; offset < sorted.length; offset += EVIDENCE_CONCURRENCY) {
|
|
463
|
+
result.push(
|
|
464
|
+
...(await Promise.all(
|
|
465
|
+
sorted.slice(offset, offset + EVIDENCE_CONCURRENCY).map(async (file, inner) => ({
|
|
466
|
+
...file,
|
|
467
|
+
id: offset + inner + 1,
|
|
468
|
+
memberships: entries
|
|
469
|
+
.filter((entry) => contextEntryPaths(entry).includes(file.path))
|
|
470
|
+
.map((entry) => entry.id),
|
|
471
|
+
oldMemberships: file.oldPath
|
|
472
|
+
? entries
|
|
473
|
+
.filter((entry) => contextEntryPaths(entry).includes(file.oldPath ?? ""))
|
|
474
|
+
.map((entry) => entry.id)
|
|
475
|
+
: [],
|
|
476
|
+
evidence: await dirtyEvidence(git, root, file),
|
|
477
|
+
})),
|
|
478
|
+
)),
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
return result;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function dirtyEvidence(
|
|
485
|
+
git: GitRunner,
|
|
486
|
+
root: string,
|
|
487
|
+
file: { path: string; oldPath?: string; kind: string; untracked: boolean },
|
|
488
|
+
): Promise<string> {
|
|
489
|
+
if (file.kind === "deleted") return "deleted file; contents omitted";
|
|
490
|
+
if (file.untracked) {
|
|
491
|
+
try {
|
|
492
|
+
const info = await stat(join(root, file.path));
|
|
493
|
+
if (!info.isFile()) return "untracked non-file; contents omitted";
|
|
494
|
+
if (info.size > MAX_UNTRACKED_BYTES) return `untracked file, ${info.size} bytes; contents omitted`;
|
|
495
|
+
const bytes = await readFile(join(root, file.path));
|
|
496
|
+
if (bytes.includes(0)) return `untracked binary file, ${info.size} bytes`;
|
|
497
|
+
return truncAt(`untracked file preview:\n${bytes.toString("utf8")}`, MAX_DIRTY_EVIDENCE);
|
|
498
|
+
} catch {
|
|
499
|
+
return "untracked preview unavailable";
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const paths = file.oldPath ? [file.oldPath, file.path] : [file.path];
|
|
503
|
+
const diff = await git.run(["diff", "--stat", "--patch", "--no-color", "--no-ext-diff", "HEAD", "--", ...paths], {
|
|
504
|
+
cwd: root,
|
|
505
|
+
optional: true,
|
|
506
|
+
});
|
|
507
|
+
return truncAt(diff || "metadata-only change", MAX_DIRTY_EVIDENCE);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function isFile(path: string): Promise<boolean> {
|
|
511
|
+
try {
|
|
512
|
+
return (await stat(path)).isFile();
|
|
513
|
+
} catch {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
}
|