@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
|
@@ -1,131 +1,31 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { parse, stringify } from "smol-toml";
|
|
7
|
-
import { createGitRunner, loadRepoStatus, type GitRunner } from "../../shared/git.ts";
|
|
8
|
-
import { generateToolValidated, resolveCandidates } from "../../shared/model-fallback/index.ts";
|
|
2
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
3
|
+
import { extname, join, relative, sep } from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { createGitRunner, loadRepoStatus } from "../../shared/git.ts";
|
|
9
6
|
import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
10
|
-
import {
|
|
7
|
+
import { discoverAgents } from "../subagent/agents.ts";
|
|
11
8
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
validSlug,
|
|
20
|
-
type ContextEntry,
|
|
21
|
-
} from "./definitions.ts";
|
|
9
|
+
createSubagentThread,
|
|
10
|
+
disposeSubagentThread,
|
|
11
|
+
extensionPathsForTools,
|
|
12
|
+
runSubagentTurn,
|
|
13
|
+
} from "../subagent/run.ts";
|
|
14
|
+
import { collectSyncEvidence, CONTEXT_SYNC_EVIDENCE_TOOL } from "./evidence.ts";
|
|
15
|
+
import { pathExists } from "./definitions.ts";
|
|
22
16
|
import contextSettings from "./settings.ts";
|
|
17
|
+
import { formatContextValidationFailure, validateContextCatalog } from "./validation.ts";
|
|
18
|
+
import { restoreOutsideContextMutations, snapshotOutsideContext } from "./write-scope.ts";
|
|
23
19
|
|
|
24
|
-
const
|
|
25
|
-
const MAX_STRUCTURAL_PREVIEW = 1_500;
|
|
26
|
-
const MAX_TOTAL_EVIDENCE = 64_000;
|
|
27
|
-
const MAX_UNTRACKED_BYTES = 12_000;
|
|
28
|
-
const EVIDENCE_CONCURRENCY = 4;
|
|
29
|
-
|
|
30
|
-
const CONTEXT_SYNC_MODELS: ReadonlyArray<{ provider: string; model: string; reasoning: ThinkingLevel }> = [
|
|
31
|
-
{ provider: "openai-codex", model: "gpt-5.6-terra", reasoning: "medium" },
|
|
32
|
-
{ provider: "openai-codex", model: "gpt-5.6-sol", reasoning: "low" },
|
|
33
|
-
{ provider: "anthropic", model: "claude-sonnet-5", reasoning: "low" },
|
|
34
|
-
{ provider: "xai", model: "grok-4.5", reasoning: "high" },
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
const SUBMIT_TOOL = {
|
|
38
|
-
name: "submit_context_sync",
|
|
39
|
-
description: "Submit the desired context catalog changes.",
|
|
40
|
-
parameters: Type.Object(
|
|
41
|
-
{
|
|
42
|
-
outcome: Type.Union([Type.Literal("no-change"), Type.Literal("apply")]),
|
|
43
|
-
reason: Type.String({ minLength: 1 }),
|
|
44
|
-
changes: Type.Optional(
|
|
45
|
-
Type.Array(
|
|
46
|
-
Type.Union([
|
|
47
|
-
Type.Object(
|
|
48
|
-
{
|
|
49
|
-
action: Type.Literal("set-entry"),
|
|
50
|
-
tab: Type.String(),
|
|
51
|
-
concept: Type.String(),
|
|
52
|
-
conceptName: Type.String(),
|
|
53
|
-
conceptDescription: Type.String(),
|
|
54
|
-
entry: Type.String(),
|
|
55
|
-
description: Type.String(),
|
|
56
|
-
files: Type.Array(Type.String(), { minItems: 1 }),
|
|
57
|
-
},
|
|
58
|
-
{ additionalProperties: false },
|
|
59
|
-
),
|
|
60
|
-
Type.Object(
|
|
61
|
-
{
|
|
62
|
-
action: Type.Literal("delete-entry"),
|
|
63
|
-
tab: Type.String(),
|
|
64
|
-
concept: Type.String(),
|
|
65
|
-
entry: Type.String(),
|
|
66
|
-
},
|
|
67
|
-
{ additionalProperties: false },
|
|
68
|
-
),
|
|
69
|
-
]),
|
|
70
|
-
{ minItems: 1 },
|
|
71
|
-
),
|
|
72
|
-
),
|
|
73
|
-
},
|
|
74
|
-
{ additionalProperties: false },
|
|
75
|
-
),
|
|
76
|
-
} satisfies Tool;
|
|
77
|
-
|
|
78
|
-
export interface SyncDirtyFile {
|
|
79
|
-
id: number;
|
|
80
|
-
path: string;
|
|
81
|
-
status: string;
|
|
82
|
-
kind: "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked";
|
|
83
|
-
untracked: boolean;
|
|
84
|
-
oldPath?: string;
|
|
85
|
-
memberships: string[];
|
|
86
|
-
oldMemberships: string[];
|
|
87
|
-
evidence: string;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export type ContextSyncChange =
|
|
91
|
-
| {
|
|
92
|
-
action: "set-entry";
|
|
93
|
-
tab: string;
|
|
94
|
-
concept: string;
|
|
95
|
-
conceptName: string;
|
|
96
|
-
conceptDescription: string;
|
|
97
|
-
entry: string;
|
|
98
|
-
description: string;
|
|
99
|
-
files: string[];
|
|
100
|
-
}
|
|
101
|
-
| { action: "delete-entry"; tab: string; concept: string; entry: string };
|
|
102
|
-
|
|
103
|
-
export type ContextSyncPlan =
|
|
104
|
-
| { outcome: "no-change"; reason: string }
|
|
105
|
-
| { outcome: "apply"; reason: string; changes: ContextSyncChange[] };
|
|
20
|
+
const CONTEXT_SYNC_AGENT = "context-sync";
|
|
106
21
|
|
|
107
22
|
export interface ContextSyncDetails {
|
|
108
|
-
outcome: "applied" | "no-change";
|
|
23
|
+
outcome: "applied" | "no-change" | "failed";
|
|
109
24
|
summary: string;
|
|
110
|
-
changedContextFiles: string[];
|
|
111
25
|
reason: string;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
export interface SyncEvidence {
|
|
117
|
-
root: string;
|
|
118
|
-
files: SyncDirtyFile[];
|
|
119
|
-
entries: ContextEntry[];
|
|
120
|
-
dirtyExisting: Set<string>;
|
|
121
|
-
dependencies: Set<string>;
|
|
122
|
-
affectedIds: Set<string>;
|
|
123
|
-
affectedConcepts: Set<string>;
|
|
124
|
-
eligibleFiles: Set<string>;
|
|
125
|
-
missingPaths: Set<string>;
|
|
126
|
-
structuralPreviews: Map<string, string>;
|
|
127
|
-
worktreeSignature: string;
|
|
128
|
-
catalogSignature: string;
|
|
26
|
+
changedContextFiles: string[];
|
|
27
|
+
agentResponse?: string;
|
|
28
|
+
validationFailure?: string;
|
|
129
29
|
}
|
|
130
30
|
|
|
131
31
|
let syncQueue = Promise.resolve();
|
|
@@ -133,58 +33,153 @@ let syncQueue = Promise.resolve();
|
|
|
133
33
|
export async function runContextSync(
|
|
134
34
|
pi: ExtensionAPI,
|
|
135
35
|
ctx: ExtensionContext,
|
|
136
|
-
onStatus?: (status: string) => void | Promise<void
|
|
36
|
+
options: { nudge?: string; signal?: AbortSignal; onStatus?: (status: string) => void | Promise<void> } = {},
|
|
137
37
|
): Promise<ContextSyncDetails> {
|
|
138
|
-
return withSyncLock(() => runContextSyncLocked(pi, ctx,
|
|
38
|
+
return withSyncLock(() => runContextSyncLocked(pi, ctx, options));
|
|
139
39
|
}
|
|
140
40
|
|
|
141
41
|
async function runContextSyncLocked(
|
|
142
42
|
pi: ExtensionAPI,
|
|
143
43
|
ctx: ExtensionContext,
|
|
144
|
-
onStatus?: (status: string) => void | Promise<void
|
|
44
|
+
options: { nudge?: string; signal?: AbortSignal; onStatus?: (status: string) => void | Promise<void> },
|
|
145
45
|
): Promise<ContextSyncDetails> {
|
|
146
46
|
if (!ctx.isProjectTrusted()) throw new Error("Context sync requires a trusted project");
|
|
147
|
-
await onStatus?.("Inspecting repository context");
|
|
47
|
+
await options.onStatus?.("Inspecting repository context");
|
|
148
48
|
const git = createGitRunner(pi, ctx);
|
|
149
49
|
const status = await loadRepoStatus(git);
|
|
150
50
|
if (!status) throw new Error("No Git repository found");
|
|
151
51
|
const settings = await loadTauExtensionSettings(ctx, contextSettings);
|
|
152
52
|
const evidence = await collectSyncEvidence(git, status.root, settings.validation.ignoreGlobs);
|
|
153
53
|
if (evidence.files.length === 0 && evidence.missingPaths.size === 0)
|
|
154
|
-
return
|
|
155
|
-
|
|
156
|
-
|
|
54
|
+
return {
|
|
55
|
+
outcome: "no-change",
|
|
56
|
+
summary: "Existing context mappings already fit the changed scope.",
|
|
57
|
+
reason: "Changed files are outside context catalog scope.",
|
|
58
|
+
changedContextFiles: [],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const beforeCatalog = await catalogFileSnapshot(status.root);
|
|
62
|
+
const outsideBefore = await snapshotOutsideContext(git, status.root);
|
|
63
|
+
const discovery = await discoverAgents(ctx.cwd, ctx.isProjectTrusted());
|
|
64
|
+
const definition = discovery.agents.get(CONTEXT_SYNC_AGENT);
|
|
65
|
+
if (!definition) {
|
|
66
|
+
const reason =
|
|
67
|
+
discovery.invalid
|
|
68
|
+
.get(CONTEXT_SYNC_AGENT)
|
|
69
|
+
?.map((item) => item.reason)
|
|
70
|
+
.join("; ") ?? "unknown agent";
|
|
71
|
+
throw new Error(`Context sync agent unavailable: ${reason}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const task = buildContextSyncTask(status.root, options.nudge);
|
|
75
|
+
const signal = options.signal ?? ctx.signal ?? new AbortController().signal;
|
|
76
|
+
|
|
77
|
+
await options.onStatus?.("Running context-sync subagent");
|
|
78
|
+
const thread = await createSubagentThread({
|
|
79
|
+
id: `context-sync-${Date.now()}`,
|
|
80
|
+
displayName: definition.names[0] ?? definition.name,
|
|
81
|
+
definition,
|
|
82
|
+
extensionPaths: extensionPathsForTools(pi, definition.tools),
|
|
83
|
+
initialTask: task,
|
|
157
84
|
ctx,
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
{ statusKey: "context-sync", onStatus },
|
|
164
|
-
);
|
|
165
|
-
if (plan.outcome === "no-change") return noChange(plan.reason);
|
|
166
|
-
await onStatus?.("Applying context catalog changes");
|
|
167
|
-
return applyContextSyncPlan(evidence.root, plan, evidence.entries, async () => {
|
|
168
|
-
const currentEntries = await loadContextEntries(evidence.root);
|
|
169
|
-
const currentFiles = await collectDirtyFiles(git, evidence.root, currentEntries, settings.validation.ignoreGlobs);
|
|
170
|
-
if ((await computeWorktreeSignature(git, evidence.root, currentFiles)) !== evidence.worktreeSignature)
|
|
171
|
-
throw new Error("Repository changed during context sync. Rerun context sync.");
|
|
172
|
-
if ((await computeCatalogSignature(evidence.root)) !== evidence.catalogSignature)
|
|
173
|
-
throw new Error("Context catalog changed during context sync. Rerun context sync.");
|
|
85
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
86
|
+
signal,
|
|
87
|
+
onWarning: (warning) => {
|
|
88
|
+
ctx.ui.notify(`Context sync agent: ${warning}`, "warning");
|
|
89
|
+
},
|
|
174
90
|
});
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
91
|
+
let agentResponse = "";
|
|
92
|
+
try {
|
|
93
|
+
const result = await runSubagentTurn({
|
|
94
|
+
thread,
|
|
95
|
+
task,
|
|
96
|
+
initial: true,
|
|
97
|
+
signal,
|
|
98
|
+
onUpdate: async (details) => {
|
|
99
|
+
const activity = details.currentActivity ?? details.response;
|
|
100
|
+
if (activity) await options.onStatus?.(activity.slice(0, 160));
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
agentResponse = result.content;
|
|
104
|
+
if (result.details.status !== "completed") {
|
|
105
|
+
const writeScopeViolations = await restoreOutsideContextMutations(git, status.root, outsideBefore);
|
|
106
|
+
return {
|
|
107
|
+
outcome: "failed",
|
|
108
|
+
summary: "Context sync subagent failed.",
|
|
109
|
+
reason: [
|
|
110
|
+
result.details.error ?? result.content,
|
|
111
|
+
writeScopeViolations.length
|
|
112
|
+
? `Restored out-of-scope writes:\n${writeScopeViolations.map((path) => `- ${path}`).join("\n")}`
|
|
113
|
+
: undefined,
|
|
114
|
+
]
|
|
115
|
+
.filter((line): line is string => Boolean(line))
|
|
116
|
+
.join("\n\n"),
|
|
117
|
+
changedContextFiles: changedCatalogPaths(beforeCatalog, await catalogFileSnapshot(status.root)),
|
|
118
|
+
agentResponse,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
await disposeSubagentThread(thread);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await options.onStatus?.("Verifying write scope");
|
|
126
|
+
const writeScopeViolations = await restoreOutsideContextMutations(git, status.root, outsideBefore);
|
|
127
|
+
const afterCatalog = await catalogFileSnapshot(status.root);
|
|
128
|
+
const changedContextFiles = changedCatalogPaths(beforeCatalog, afterCatalog);
|
|
129
|
+
if (writeScopeViolations.length > 0) {
|
|
130
|
+
return {
|
|
131
|
+
outcome: "failed",
|
|
132
|
+
summary: "Context sync wrote outside .pi/contexts; out-of-scope paths were restored.",
|
|
133
|
+
reason: `Out-of-scope writes restored:\n${writeScopeViolations.map((path) => `- ${path}`).join("\n")}`,
|
|
134
|
+
changedContextFiles,
|
|
135
|
+
agentResponse,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await options.onStatus?.("Verifying context catalog");
|
|
140
|
+
const validationFailure = formatContextValidationFailure(
|
|
141
|
+
await validateContextCatalog(git, status.root, settings.validation.ignoreGlobs),
|
|
142
|
+
);
|
|
143
|
+
if (validationFailure) {
|
|
144
|
+
return {
|
|
145
|
+
outcome: "failed",
|
|
146
|
+
summary: "Context sync finished but catalog invariants still fail.",
|
|
147
|
+
reason: validationFailure,
|
|
148
|
+
changedContextFiles,
|
|
149
|
+
agentResponse,
|
|
150
|
+
validationFailure,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (changedContextFiles.length === 0) {
|
|
154
|
+
return {
|
|
155
|
+
outcome: "no-change",
|
|
156
|
+
summary: "Existing context mappings already fit the changed scope.",
|
|
157
|
+
reason: agentResponse.trim() || "Context-sync subagent made no catalog edits.",
|
|
158
|
+
changedContextFiles: [],
|
|
159
|
+
agentResponse,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
178
162
|
return {
|
|
179
|
-
outcome: "
|
|
180
|
-
summary:
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
counts: { created: 0, updated: 0, deleted: 0, unchanged: 0 },
|
|
163
|
+
outcome: "applied",
|
|
164
|
+
summary: `Updated context catalog (${changedContextFiles.length} file${changedContextFiles.length === 1 ? "" : "s"}).`,
|
|
165
|
+
reason: agentResponse.trim() || "Context-sync subagent updated the catalog.",
|
|
166
|
+
changedContextFiles,
|
|
167
|
+
agentResponse,
|
|
185
168
|
};
|
|
186
169
|
}
|
|
187
170
|
|
|
171
|
+
export function buildContextSyncTask(root: string, nudge?: string): string {
|
|
172
|
+
const trimmed = nudge?.trim();
|
|
173
|
+
return [
|
|
174
|
+
`Synchronize the repository context catalog at ${root}.`,
|
|
175
|
+
"Use context_sync_evidence for git/catalog facts. Edit only .pi/contexts with patch.",
|
|
176
|
+
"Walk the typology ladder out loud before path placement. Recheck invariants before finishing.",
|
|
177
|
+
trimmed ? `Human nudge (soft steer, does not skip evidence or ladder):\n${trimmed}` : undefined,
|
|
178
|
+
]
|
|
179
|
+
.filter((line): line is string => Boolean(line))
|
|
180
|
+
.join("\n\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
188
183
|
async function withSyncLock<T>(task: () => Promise<T>): Promise<T> {
|
|
189
184
|
const previous = syncQueue;
|
|
190
185
|
let release = () => {};
|
|
@@ -199,551 +194,41 @@ async function withSyncLock<T>(task: () => Promise<T>): Promise<T> {
|
|
|
199
194
|
}
|
|
200
195
|
}
|
|
201
196
|
|
|
202
|
-
async function
|
|
203
|
-
|
|
204
|
-
root: string,
|
|
205
|
-
ignoreGlobs: readonly string[],
|
|
206
|
-
): Promise<SyncEvidence> {
|
|
207
|
-
const entries = await loadContextEntries(root);
|
|
208
|
-
const files = await collectDirtyFiles(git, root, entries, ignoreGlobs);
|
|
209
|
-
const dirtyExisting = new Set<string>();
|
|
210
|
-
for (const file of files)
|
|
211
|
-
if (file.kind !== "deleted" && (await isFile(join(root, file.path)))) dirtyExisting.add(file.path);
|
|
212
|
-
const dependencies = await discoverDirectDependencies(
|
|
213
|
-
root,
|
|
214
|
-
files.filter((file) => dirtyExisting.has(file.path)),
|
|
215
|
-
);
|
|
216
|
-
for (const path of dependencies) if (!isContextEligiblePath(path, ignoreGlobs)) dependencies.delete(path);
|
|
217
|
-
const missingPaths = new Set<string>();
|
|
218
|
-
for (const entry of entries)
|
|
219
|
-
for (const path of contextEntryPaths(entry))
|
|
220
|
-
if (isContextEligiblePath(path, ignoreGlobs) && !(await isFile(join(root, path)))) missingPaths.add(path);
|
|
221
|
-
const affectedIds = new Set<string>();
|
|
222
|
-
for (const entry of entries) {
|
|
223
|
-
const paths = contextEntryPaths(entry);
|
|
224
|
-
if (
|
|
225
|
-
paths.some((path) => missingPaths.has(path)) ||
|
|
226
|
-
paths.some(
|
|
227
|
-
(path) => files.some((file) => file.path === path || file.oldPath === path) || dependencies.has(path),
|
|
228
|
-
)
|
|
229
|
-
)
|
|
230
|
-
affectedIds.add(entry.id);
|
|
231
|
-
}
|
|
232
|
-
const affectedConcepts = new Set([...affectedIds].map((id) => id.split("/").slice(0, 2).join("/")));
|
|
233
|
-
const siblingEntries = entries.filter((entry) => affectedConcepts.has(`${entry.tab}/${entry.concept}`));
|
|
234
|
-
const siblingFiles = siblingEntries
|
|
235
|
-
.flatMap((entry) => contextEntryPaths(entry))
|
|
236
|
-
.filter((path) => !missingPaths.has(path) && isContextEligiblePath(path, ignoreGlobs));
|
|
237
|
-
const eligibleFiles = new Set([...dirtyExisting, ...dependencies, ...siblingFiles]);
|
|
238
|
-
const structuralPreviews = new Map<string, string>();
|
|
239
|
-
for (const path of [...new Set(siblingFiles)].sort()) {
|
|
240
|
-
try {
|
|
241
|
-
const content = await readFile(join(root, path), "utf8");
|
|
242
|
-
const lines = content
|
|
243
|
-
.split("\n")
|
|
244
|
-
.filter((line) => line.trim())
|
|
245
|
-
.slice(0, 30)
|
|
246
|
-
.join("\n");
|
|
247
|
-
structuralPreviews.set(path, truncAt(lines, MAX_STRUCTURAL_PREVIEW));
|
|
248
|
-
} catch {
|
|
249
|
-
structuralPreviews.set(path, "preview unavailable");
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
return {
|
|
253
|
-
root,
|
|
254
|
-
files,
|
|
255
|
-
entries,
|
|
256
|
-
dirtyExisting,
|
|
257
|
-
dependencies,
|
|
258
|
-
affectedIds,
|
|
259
|
-
affectedConcepts,
|
|
260
|
-
eligibleFiles,
|
|
261
|
-
missingPaths,
|
|
262
|
-
structuralPreviews,
|
|
263
|
-
worktreeSignature: await computeWorktreeSignature(git, root, files),
|
|
264
|
-
catalogSignature: await computeCatalogSignature(root),
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
async function collectDirtyFiles(
|
|
269
|
-
git: GitRunner,
|
|
270
|
-
root: string,
|
|
271
|
-
entries: readonly ContextEntry[],
|
|
272
|
-
ignoreGlobs: readonly string[],
|
|
273
|
-
): Promise<SyncDirtyFile[]> {
|
|
274
|
-
const raw = await git.run(["status", "--porcelain=v2", "-z", "--untracked-files=all"], { cwd: root });
|
|
275
|
-
const parts = raw.split("\0").filter(Boolean);
|
|
276
|
-
const parsed: Omit<SyncDirtyFile, "id" | "memberships" | "oldMemberships" | "evidence">[] = [];
|
|
277
|
-
for (let index = 0; index < parts.length; index++) {
|
|
278
|
-
const record = parts[index];
|
|
279
|
-
if (!record) continue;
|
|
280
|
-
if (record[0] === "u")
|
|
281
|
-
throw new Error("Unmerged conflict entries detected; resolve conflicts before context sync.");
|
|
282
|
-
if (record[0] === "?") {
|
|
283
|
-
parsed.push({ path: record.slice(2), status: "??", kind: "untracked", untracked: true });
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
const fields = record.split(" ");
|
|
287
|
-
if (record[0] === "1") {
|
|
288
|
-
const status = (fields[1] ?? "..").replace(/\./g, " ");
|
|
289
|
-
parsed.push({
|
|
290
|
-
path: fields.slice(8).join(" "),
|
|
291
|
-
status,
|
|
292
|
-
kind: status.includes("D") ? "deleted" : status.includes("A") ? "added" : "modified",
|
|
293
|
-
untracked: false,
|
|
294
|
-
});
|
|
295
|
-
} else if (record[0] === "2") {
|
|
296
|
-
const oldPath = parts[index + 1];
|
|
297
|
-
const status = (fields[1] ?? "..").replace(/\./g, " ");
|
|
298
|
-
parsed.push({
|
|
299
|
-
path: fields.slice(9).join(" "),
|
|
300
|
-
status,
|
|
301
|
-
kind: fields[8]?.startsWith("C") ? "copied" : "renamed",
|
|
302
|
-
untracked: false,
|
|
303
|
-
...(oldPath ? { oldPath } : {}),
|
|
304
|
-
});
|
|
305
|
-
index++;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
const sensitive = parsed.filter(
|
|
309
|
-
(file) =>
|
|
310
|
-
file.kind !== "deleted" &&
|
|
311
|
-
(isSensitiveContextPath(file.path) || (file.oldPath !== undefined && isSensitiveContextPath(file.oldPath))),
|
|
312
|
-
);
|
|
313
|
-
if (sensitive.length > 0)
|
|
314
|
-
throw new Error(
|
|
315
|
-
`Sensitive files cannot be inspected by context sync:\n${sensitive.map((file) => `- ${file.path}`).join("\n")}`,
|
|
316
|
-
);
|
|
317
|
-
const sorted = parsed
|
|
318
|
-
.filter(
|
|
319
|
-
(file) =>
|
|
320
|
-
isContextEligiblePath(file.path, ignoreGlobs) ||
|
|
321
|
-
(file.oldPath !== undefined && isContextEligiblePath(file.oldPath, ignoreGlobs)),
|
|
322
|
-
)
|
|
323
|
-
.sort((a, b) => a.path.localeCompare(b.path));
|
|
324
|
-
const result: SyncDirtyFile[] = [];
|
|
325
|
-
for (let offset = 0; offset < sorted.length; offset += EVIDENCE_CONCURRENCY) {
|
|
326
|
-
result.push(
|
|
327
|
-
...(await Promise.all(
|
|
328
|
-
sorted.slice(offset, offset + EVIDENCE_CONCURRENCY).map(async (file, inner) => ({
|
|
329
|
-
...file,
|
|
330
|
-
id: offset + inner + 1,
|
|
331
|
-
memberships: entries
|
|
332
|
-
.filter((entry) => contextEntryPaths(entry).includes(file.path))
|
|
333
|
-
.map((entry) => entry.id),
|
|
334
|
-
oldMemberships: file.oldPath
|
|
335
|
-
? entries
|
|
336
|
-
.filter((entry) => contextEntryPaths(entry).includes(file.oldPath ?? ""))
|
|
337
|
-
.map((entry) => entry.id)
|
|
338
|
-
: [],
|
|
339
|
-
evidence: await dirtyEvidence(git, root, file),
|
|
340
|
-
})),
|
|
341
|
-
)),
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
return result;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
async function dirtyEvidence(
|
|
348
|
-
git: GitRunner,
|
|
349
|
-
root: string,
|
|
350
|
-
file: { path: string; oldPath?: string; kind: string; untracked: boolean },
|
|
351
|
-
): Promise<string> {
|
|
352
|
-
if (file.kind === "deleted") return "deleted file; contents omitted";
|
|
353
|
-
if (file.untracked) {
|
|
354
|
-
try {
|
|
355
|
-
const info = await stat(join(root, file.path));
|
|
356
|
-
if (!info.isFile()) return "untracked non-file; contents omitted";
|
|
357
|
-
if (info.size > MAX_UNTRACKED_BYTES) return `untracked file, ${info.size} bytes; contents omitted`;
|
|
358
|
-
const bytes = await readFile(join(root, file.path));
|
|
359
|
-
if (bytes.includes(0)) return `untracked binary file, ${info.size} bytes`;
|
|
360
|
-
return truncAt(`untracked file preview:\n${bytes.toString("utf8")}`, MAX_DIRTY_EVIDENCE);
|
|
361
|
-
} catch {
|
|
362
|
-
return "untracked preview unavailable";
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
const paths = file.oldPath ? [file.oldPath, file.path] : [file.path];
|
|
366
|
-
const diff = await git.run(["diff", "--stat", "--patch", "--no-color", "--no-ext-diff", "HEAD", "--", ...paths], {
|
|
367
|
-
cwd: root,
|
|
368
|
-
optional: true,
|
|
369
|
-
});
|
|
370
|
-
return truncAt(diff || "metadata-only change", MAX_DIRTY_EVIDENCE);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
export async function discoverDirectDependencies(
|
|
374
|
-
root: string,
|
|
375
|
-
files: readonly Pick<SyncDirtyFile, "path" | "evidence">[],
|
|
376
|
-
): Promise<Set<string>> {
|
|
377
|
-
const result = new Set<string>();
|
|
378
|
-
const knownExtensions = [
|
|
379
|
-
".ts",
|
|
380
|
-
".tsx",
|
|
381
|
-
".js",
|
|
382
|
-
".jsx",
|
|
383
|
-
".mjs",
|
|
384
|
-
".cjs",
|
|
385
|
-
".json",
|
|
386
|
-
".py",
|
|
387
|
-
".rs",
|
|
388
|
-
".go",
|
|
389
|
-
".c",
|
|
390
|
-
".cc",
|
|
391
|
-
".cpp",
|
|
392
|
-
".h",
|
|
393
|
-
".hpp",
|
|
394
|
-
];
|
|
395
|
-
for (const file of files) {
|
|
396
|
-
let content = file.evidence;
|
|
397
|
-
try {
|
|
398
|
-
content += `\n${await readFile(join(root, file.path), "utf8")}`;
|
|
399
|
-
} catch {
|
|
400
|
-
/* evidence remains */
|
|
401
|
-
}
|
|
402
|
-
const statement = /(?:import|export|require|include|use|mod(?:ule)?)\b[^\n]*?["'`]([^"'`]+)["'`]/g;
|
|
403
|
-
for (const match of content.matchAll(statement)) {
|
|
404
|
-
const specifier = match[1];
|
|
405
|
-
if (!specifier?.startsWith("./") && !specifier?.startsWith("../")) continue;
|
|
406
|
-
const absolute = resolve(dirname(join(root, file.path)), specifier);
|
|
407
|
-
const relativePath = relative(root, absolute).split(sep).join("/");
|
|
408
|
-
if (!relativePath || relativePath === ".." || relativePath.startsWith("../")) continue;
|
|
409
|
-
const candidates = extname(absolute)
|
|
410
|
-
? [absolute]
|
|
411
|
-
: [
|
|
412
|
-
...knownExtensions.map((extension) => `${absolute}${extension}`),
|
|
413
|
-
...knownExtensions.map((extension) => join(absolute, `index${extension}`)),
|
|
414
|
-
];
|
|
415
|
-
const matches: string[] = [];
|
|
416
|
-
if (await isFile(absolute)) matches.push(absolute);
|
|
417
|
-
else for (const candidate of candidates) if (await isFile(candidate)) matches.push(candidate);
|
|
418
|
-
if (matches.length === 1)
|
|
419
|
-
result.add(
|
|
420
|
-
relative(root, matches[0] ?? absolute)
|
|
421
|
-
.split(sep)
|
|
422
|
-
.join("/"),
|
|
423
|
-
);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
return result;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
async function isFile(path: string): Promise<boolean> {
|
|
430
|
-
try {
|
|
431
|
-
return (await stat(path)).isFile();
|
|
432
|
-
} catch {
|
|
433
|
-
return false;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function buildContextSyncPrompt(evidence: SyncEvidence): string {
|
|
438
|
-
const stale = evidence.entries.flatMap((entry) =>
|
|
439
|
-
contextEntryPaths(entry)
|
|
440
|
-
.filter((path) => evidence.missingPaths.has(path))
|
|
441
|
-
.map((path) => `${entry.id}: ${path}`),
|
|
442
|
-
);
|
|
443
|
-
const affected = evidence.entries.filter((entry) => evidence.affectedIds.has(entry.id));
|
|
444
|
-
const previews = [...evidence.structuralPreviews].map(([path, preview]) => `${path}\n${preview}`);
|
|
445
|
-
return truncAt(
|
|
446
|
-
[
|
|
447
|
-
"Synchronize reusable repository context scopes from the supplied Git and catalog evidence.",
|
|
448
|
-
"Call submit_context_sync exactly once and produce no prose response.",
|
|
449
|
-
"Return no-change only when every eligible changed file has context membership and no stale catalog paths remain.",
|
|
450
|
-
"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.",
|
|
451
|
-
"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.",
|
|
452
|
-
"Follow direct local dependency candidates only when needed. Do not add package dependencies, generated files, incidental imports, or recursive dependencies.",
|
|
453
|
-
"Reconsider granularity only inside affected concepts. Preserve broad entries when splitting would duplicate files without improving future work.",
|
|
454
|
-
"Every eligible changed file must belong to at least one entry. Remove every stale catalog path. Use only supplied candidate paths.",
|
|
455
|
-
"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.",
|
|
456
|
-
"Changed-file catalog and current memberships:",
|
|
457
|
-
...evidence.files.map(
|
|
458
|
-
(file) =>
|
|
459
|
-
`[${file.id}] ${file.status} ${file.path}${file.oldPath ? ` <- ${file.oldPath}` : ""}; kind=${file.kind}; memberships=${file.memberships.join(",") || "none"}; oldMemberships=${file.oldMemberships.join(",") || "none"}`,
|
|
460
|
-
),
|
|
461
|
-
"Complete context catalog:",
|
|
462
|
-
JSON.stringify(
|
|
463
|
-
evidence.entries.map((entry) => ({
|
|
464
|
-
id: entry.id,
|
|
465
|
-
conceptName: entry.conceptName,
|
|
466
|
-
conceptDescription: entry.conceptDescription,
|
|
467
|
-
description: entry.description,
|
|
468
|
-
eagerFiles: entry.files,
|
|
469
|
-
anchors: entry.anchors,
|
|
470
|
-
missingFiles: contextEntryPaths(entry).filter((path) => evidence.missingPaths.has(path)),
|
|
471
|
-
})),
|
|
472
|
-
),
|
|
473
|
-
"Changed files with no membership:",
|
|
474
|
-
evidence.files
|
|
475
|
-
.filter((file) => file.kind !== "deleted" && file.memberships.length === 0)
|
|
476
|
-
.map((file) => file.path)
|
|
477
|
-
.join("\n") || "(none)",
|
|
478
|
-
"Affected entries:",
|
|
479
|
-
affected.map((entry) => entry.id).join("\n") || "(none)",
|
|
480
|
-
"Affected concepts:",
|
|
481
|
-
[...evidence.affectedConcepts].sort().join("\n") || "(none)",
|
|
482
|
-
"Resolved direct dependency candidates:",
|
|
483
|
-
[...evidence.dependencies].sort().join("\n") || "(none)",
|
|
484
|
-
"Stale catalog paths that must be removed:",
|
|
485
|
-
stale.join("\n") || "(none)",
|
|
486
|
-
"Dirty files:",
|
|
487
|
-
...evidence.files.map(
|
|
488
|
-
(file) =>
|
|
489
|
-
`[${file.id}] ${file.status} ${file.path}${file.oldPath ? ` <- ${file.oldPath}` : ""}\nkind=${file.kind}\nmemberships=${file.memberships.join(",") || "none"}\noldMemberships=${file.oldMemberships.join(",") || "none"}\n${file.evidence}`,
|
|
490
|
-
),
|
|
491
|
-
"Bounded affected-entry structural previews:",
|
|
492
|
-
...previews,
|
|
493
|
-
].join("\n\n"),
|
|
494
|
-
MAX_TOTAL_EVIDENCE,
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence): ContextSyncPlan {
|
|
499
|
-
if (!isRecord(input) || (input.outcome !== "no-change" && input.outcome !== "apply"))
|
|
500
|
-
throw new Error("Context sync submission is malformed.");
|
|
501
|
-
const reason = typeof input.reason === "string" ? input.reason.trim() : "";
|
|
502
|
-
if (!reason) throw new Error("Context sync reason is required.");
|
|
503
|
-
if (input.outcome === "no-change") {
|
|
504
|
-
if ("changes" in input) throw new Error("no-change must not include changes.");
|
|
505
|
-
const uncovered = evidence.files.filter((file) => file.kind !== "deleted" && file.memberships.length === 0);
|
|
506
|
-
if (uncovered.length > 0 || evidence.missingPaths.size > 0)
|
|
507
|
-
throw new Error("no-change cannot leave uncovered changed files or stale catalog paths.");
|
|
508
|
-
return { outcome: "no-change", reason };
|
|
509
|
-
}
|
|
510
|
-
if (!Array.isArray(input.changes) || input.changes.length === 0)
|
|
511
|
-
throw new Error("apply requires at least one change.");
|
|
512
|
-
const current = new Map(evidence.entries.map((entry) => [entry.id, entry]));
|
|
513
|
-
const changes: ContextSyncChange[] = [];
|
|
514
|
-
const seen = new Set<string>();
|
|
515
|
-
for (const raw of input.changes) {
|
|
516
|
-
if (!isRecord(raw) || (raw.action !== "set-entry" && raw.action !== "delete-entry"))
|
|
517
|
-
throw new Error("Invalid context sync change.");
|
|
518
|
-
const tab = validSlug(String(raw.tab ?? ""), "Context tab");
|
|
519
|
-
const concept = validSlug(String(raw.concept ?? ""), "Context concept");
|
|
520
|
-
const entry = validSlug(String(raw.entry ?? ""), "Context entry");
|
|
521
|
-
const id = `${tab}/${concept}/${entry}`;
|
|
522
|
-
if (seen.has(id)) throw new Error(`Duplicate context change: ${id}`);
|
|
523
|
-
seen.add(id);
|
|
524
|
-
const existing = current.get(id);
|
|
525
|
-
if (raw.action === "delete-entry") {
|
|
526
|
-
if (!existing || !evidence.affectedIds.has(id)) throw new Error(`Entry is not eligible for deletion: ${id}`);
|
|
527
|
-
changes.push({ action: "delete-entry", tab, concept, entry });
|
|
528
|
-
continue;
|
|
529
|
-
}
|
|
530
|
-
const conceptName = typeof raw.conceptName === "string" ? raw.conceptName.trim() : "";
|
|
531
|
-
const conceptDescription = typeof raw.conceptDescription === "string" ? raw.conceptDescription.trim() : "";
|
|
532
|
-
const description = typeof raw.description === "string" ? raw.description.trim() : "";
|
|
533
|
-
if (
|
|
534
|
-
!conceptName ||
|
|
535
|
-
!description ||
|
|
536
|
-
!Array.isArray(raw.files) ||
|
|
537
|
-
raw.files.length === 0 ||
|
|
538
|
-
raw.files.some((path) => typeof path !== "string")
|
|
539
|
-
)
|
|
540
|
-
throw new Error(`Invalid set-entry: ${id}`);
|
|
541
|
-
const files = [
|
|
542
|
-
...new Set((raw.files as string[]).map((path) => normalizeProjectPath(evidence.root, path))),
|
|
543
|
-
].sort();
|
|
544
|
-
if (files.some((path) => !evidence.eligibleFiles.has(path)))
|
|
545
|
-
throw new Error(`Entry uses a path outside the eligible scope: ${id}`);
|
|
546
|
-
if (existing) {
|
|
547
|
-
const conceptId = `${tab}/${concept}`;
|
|
548
|
-
if (!evidence.affectedIds.has(id) && !evidence.affectedConcepts.has(conceptId))
|
|
549
|
-
throw new Error(`Entry is unrelated: ${id}`);
|
|
550
|
-
if (conceptName !== existing.conceptName || conceptDescription !== existing.conceptDescription)
|
|
551
|
-
throw new Error(`Existing concept metadata cannot change: ${conceptId}`);
|
|
552
|
-
if (description === existing.description && files.join("\0") === contextEntryPaths(existing).join("\0"))
|
|
553
|
-
throw new Error(`Set-entry is identical: ${id}`);
|
|
554
|
-
} else {
|
|
555
|
-
const existingConcept = evidence.entries.find((item) => item.tab === tab && item.concept === concept);
|
|
556
|
-
if (
|
|
557
|
-
existingConcept &&
|
|
558
|
-
(conceptName !== existingConcept.conceptName || conceptDescription !== existingConcept.conceptDescription)
|
|
559
|
-
)
|
|
560
|
-
throw new Error(`Existing concept metadata cannot change: ${tab}/${concept}`);
|
|
561
|
-
if (!files.some((path) => evidence.dirtyExisting.has(path) || evidence.dependencies.has(path)))
|
|
562
|
-
throw new Error(`New entry lacks a dirty file or dependency: ${id}`);
|
|
563
|
-
if (
|
|
564
|
-
!existingConcept &&
|
|
565
|
-
(!files.some((path) => evidence.dirtyExisting.has(path)) ||
|
|
566
|
-
files.some((path) => !evidence.eligibleFiles.has(path)))
|
|
567
|
-
)
|
|
568
|
-
throw new Error(`New concept is outside the affected scope: ${tab}/${concept}`);
|
|
569
|
-
}
|
|
570
|
-
changes.push({ action: "set-entry", tab, concept, conceptName, conceptDescription, entry, description, files });
|
|
571
|
-
}
|
|
572
|
-
const final = new Map(evidence.entries.map((entry) => [entry.id, contextEntryPaths(entry)]));
|
|
573
|
-
for (const change of changes) {
|
|
574
|
-
const id = `${change.tab}/${change.concept}/${change.entry}`;
|
|
575
|
-
if (change.action === "delete-entry") final.delete(id);
|
|
576
|
-
else final.set(id, change.files);
|
|
577
|
-
}
|
|
578
|
-
for (const change of changes.filter(
|
|
579
|
-
(item): item is Extract<ContextSyncChange, { action: "delete-entry" }> => item.action === "delete-entry",
|
|
580
|
-
)) {
|
|
581
|
-
const old = current.get(`${change.tab}/${change.concept}/${change.entry}`);
|
|
582
|
-
if (!old) continue;
|
|
583
|
-
for (const path of contextEntryPaths(old))
|
|
584
|
-
if (evidence.eligibleFiles.has(path) && ![...final.values()].some((files) => files.includes(path)))
|
|
585
|
-
throw new Error(`Deleting entry would orphan surviving file: ${path}`);
|
|
586
|
-
}
|
|
587
|
-
for (const path of evidence.dirtyExisting)
|
|
588
|
-
if (![...final.values()].some((files) => files.includes(path)))
|
|
589
|
-
throw new Error(`Context sync would leave changed file uncovered: ${path}`);
|
|
590
|
-
for (const files of final.values())
|
|
591
|
-
for (const path of files)
|
|
592
|
-
if (evidence.missingPaths.has(path)) throw new Error(`Context sync would leave stale path: ${path}`);
|
|
593
|
-
return { outcome: "apply", reason, changes };
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
async function computeWorktreeSignature(
|
|
597
|
-
git: GitRunner,
|
|
598
|
-
root: string,
|
|
599
|
-
files: readonly Pick<SyncDirtyFile, "path" | "untracked">[],
|
|
600
|
-
): Promise<string> {
|
|
601
|
-
const hash = createHash("sha256");
|
|
602
|
-
const status = await git.run(["status", "--porcelain=v2", "-z", "--untracked-files=all"], { cwd: root });
|
|
603
|
-
const [staged, unstaged] = await Promise.all([
|
|
604
|
-
git.run(["diff", "--cached", "--no-color", "--no-ext-diff"], { cwd: root, optional: true }),
|
|
605
|
-
git.run(["diff", "--no-color", "--no-ext-diff"], { cwd: root, optional: true }),
|
|
606
|
-
]);
|
|
607
|
-
hash.update(status);
|
|
608
|
-
hash.update("\0staged\0");
|
|
609
|
-
hash.update(staged);
|
|
610
|
-
hash.update("\0unstaged\0");
|
|
611
|
-
hash.update(unstaged);
|
|
612
|
-
for (const file of files.filter((item) => item.untracked).sort((a, b) => a.path.localeCompare(b.path))) {
|
|
613
|
-
try {
|
|
614
|
-
const info = await stat(join(root, file.path));
|
|
615
|
-
hash.update(`\0${file.path}:${info.size}:${info.mtimeMs}`);
|
|
616
|
-
if (info.isFile() && info.size <= MAX_UNTRACKED_BYTES) hash.update(await readFile(join(root, file.path)));
|
|
617
|
-
} catch {
|
|
618
|
-
hash.update(`\0${file.path}:missing`);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
return hash.digest("hex");
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
async function computeCatalogSignature(root: string): Promise<string> {
|
|
625
|
-
const hash = createHash("sha256");
|
|
197
|
+
async function catalogFileSnapshot(root: string): Promise<Map<string, string>> {
|
|
198
|
+
const snapshot = new Map<string, string>();
|
|
626
199
|
const base = join(root, ".pi", "contexts");
|
|
627
|
-
if (!(await pathExists(base))) return
|
|
200
|
+
if (!(await pathExists(base))) return snapshot;
|
|
628
201
|
for (const tab of (await readdir(base, { withFileTypes: true }))
|
|
629
202
|
.filter((item) => item.isDirectory())
|
|
630
203
|
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
631
204
|
for (const file of (await readdir(join(base, tab.name), { withFileTypes: true }))
|
|
632
205
|
.filter((item) => item.isFile() && extname(item.name) === ".toml")
|
|
633
206
|
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
hash
|
|
637
|
-
|
|
638
|
-
|
|
207
|
+
const absolute = join(base, tab.name, file.name);
|
|
208
|
+
const path = relative(root, absolute).split(sep).join("/");
|
|
209
|
+
const hash = createHash("sha256")
|
|
210
|
+
.update(await readFile(absolute))
|
|
211
|
+
.digest("hex");
|
|
212
|
+
snapshot.set(path, hash);
|
|
639
213
|
}
|
|
640
214
|
}
|
|
641
|
-
return
|
|
215
|
+
return snapshot;
|
|
642
216
|
}
|
|
643
217
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
): Promise<ContextSyncDetails> {
|
|
650
|
-
for (const change of plan.changes) if (change.action === "set-entry") await requireFiles(root, change.files);
|
|
651
|
-
const concepts = [...new Set(plan.changes.map((change) => `${change.tab}/${change.concept}`))].sort();
|
|
652
|
-
const eagerPaths = new Set(entries.flatMap((entry) => entry.files));
|
|
653
|
-
const anchorPaths = new Set(entries.flatMap((entry) => entry.anchors).filter((path) => !eagerPaths.has(path)));
|
|
654
|
-
const outputs = new Map<string, string | undefined>();
|
|
655
|
-
for (const key of concepts) {
|
|
656
|
-
const [tab, concept] = key.split("/");
|
|
657
|
-
if (!tab || !concept) continue;
|
|
658
|
-
const path = join(root, ".pi", "contexts", tab, `${concept}.toml`);
|
|
659
|
-
const raw: Record<string, unknown> = (await pathExists(path))
|
|
660
|
-
? (parse(await readFile(path, "utf8")) as Record<string, unknown>)
|
|
661
|
-
: {};
|
|
662
|
-
for (const change of plan.changes.filter((item) => item.tab === tab && item.concept === concept)) {
|
|
663
|
-
if (change.action === "delete-entry") delete raw[change.entry];
|
|
664
|
-
else {
|
|
665
|
-
if (raw.name === undefined) raw.name = change.conceptName;
|
|
666
|
-
if (raw.description === undefined) raw.description = change.conceptDescription;
|
|
667
|
-
const anchors = change.files.filter((path) => anchorPaths.has(path));
|
|
668
|
-
raw[change.entry] = {
|
|
669
|
-
description: change.description,
|
|
670
|
-
files: change.files.filter((path) => !anchors.includes(path)),
|
|
671
|
-
...(anchors.length ? { anchors } : {}),
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
outputs.set(
|
|
676
|
-
path,
|
|
677
|
-
Object.keys(raw).some((key) => key !== "name" && key !== "description") ? stringify(raw) : undefined,
|
|
678
|
-
);
|
|
679
|
-
}
|
|
680
|
-
const paths = [...outputs.keys()].sort();
|
|
681
|
-
const lockPaths = [...new Set([...paths, ...entries.map((entry) => entry.path)])].sort();
|
|
682
|
-
const originals = new Map<string, Buffer | undefined>();
|
|
683
|
-
for (const path of paths) originals.set(path, (await pathExists(path)) ? await readFile(path) : undefined);
|
|
684
|
-
const temporaryFiles = new Map<string, string>();
|
|
685
|
-
try {
|
|
686
|
-
await acquireMutationQueues(lockPaths, async () => {
|
|
687
|
-
await verifyFreshness?.();
|
|
688
|
-
for (const path of paths) {
|
|
689
|
-
const output = outputs.get(path);
|
|
690
|
-
if (output === undefined) continue;
|
|
691
|
-
await mkdir(dirname(path), { recursive: true });
|
|
692
|
-
const temporary = `${path}.${process.pid}.${Date.now()}.${temporaryFiles.size}.tmp`;
|
|
693
|
-
await writeFile(temporary, output, "utf8");
|
|
694
|
-
temporaryFiles.set(path, temporary);
|
|
695
|
-
}
|
|
696
|
-
const changed: string[] = [];
|
|
697
|
-
try {
|
|
698
|
-
for (const path of paths) {
|
|
699
|
-
const temporary = temporaryFiles.get(path);
|
|
700
|
-
if (!temporary) await rm(path, { force: true });
|
|
701
|
-
else await rename(temporary, path);
|
|
702
|
-
changed.push(path);
|
|
703
|
-
}
|
|
704
|
-
} catch (error) {
|
|
705
|
-
for (const path of changed.reverse()) {
|
|
706
|
-
const original = originals.get(path);
|
|
707
|
-
if (original === undefined) await rm(path, { force: true });
|
|
708
|
-
else {
|
|
709
|
-
await mkdir(dirname(path), { recursive: true });
|
|
710
|
-
await writeFile(path, original);
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
throw error;
|
|
714
|
-
}
|
|
715
|
-
});
|
|
716
|
-
} finally {
|
|
717
|
-
await Promise.all([...temporaryFiles.values()].map((path) => rm(path, { force: true })));
|
|
718
|
-
}
|
|
719
|
-
for (const path of paths.filter((path) => outputs.get(path) === undefined))
|
|
720
|
-
await rm(dirname(path), { recursive: false }).catch(() => undefined);
|
|
721
|
-
const currentIds = new Set(entries.map((entry) => entry.id));
|
|
722
|
-
const created = plan.changes.filter(
|
|
723
|
-
(change) => change.action === "set-entry" && !currentIds.has(`${change.tab}/${change.concept}/${change.entry}`),
|
|
724
|
-
).length;
|
|
725
|
-
const updated = plan.changes.filter(
|
|
726
|
-
(change) => change.action === "set-entry" && currentIds.has(`${change.tab}/${change.concept}/${change.entry}`),
|
|
727
|
-
).length;
|
|
728
|
-
const deleted = plan.changes.filter((change) => change.action === "delete-entry").length;
|
|
729
|
-
const changedContextFiles = paths.map((path) => relative(root, path).split(sep).join("/"));
|
|
730
|
-
const summary = `Updated ${updated} context entries; created ${created}; removed ${deleted}.`;
|
|
731
|
-
return {
|
|
732
|
-
outcome: "applied",
|
|
733
|
-
summary,
|
|
734
|
-
changedContextFiles,
|
|
735
|
-
reason: plan.reason,
|
|
736
|
-
changes: plan.changes,
|
|
737
|
-
counts: { created, updated, deleted, unchanged: entries.length - updated - deleted },
|
|
738
|
-
};
|
|
218
|
+
function changedCatalogPaths(before: Map<string, string>, after: Map<string, string>): string[] {
|
|
219
|
+
const paths = new Set([...before.keys(), ...after.keys()]);
|
|
220
|
+
return [...paths]
|
|
221
|
+
.filter((path) => before.get(path) !== after.get(path))
|
|
222
|
+
.sort((left, right) => left.localeCompare(right));
|
|
739
223
|
}
|
|
740
224
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
225
|
+
// Ensure evidence tool name stays aligned with agent definition tooling.
|
|
226
|
+
export const CONTEXT_SYNC_REQUIRED_TOOLS = [
|
|
227
|
+
"read",
|
|
228
|
+
"ls",
|
|
229
|
+
"find",
|
|
230
|
+
"grep",
|
|
231
|
+
"bash",
|
|
232
|
+
"patch",
|
|
233
|
+
CONTEXT_SYNC_EVIDENCE_TOOL,
|
|
234
|
+
] as const;
|