@tangle-network/agent-profile-materialize 0.15.1 → 0.16.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.
Files changed (55) hide show
  1. package/dist/candidate-types.d.ts +32 -0
  2. package/dist/candidate-types.js +1 -0
  3. package/dist/candidate.d.ts +9 -0
  4. package/dist/candidate.js +279 -0
  5. package/dist/commands.d.ts +2 -0
  6. package/dist/commands.js +32 -0
  7. package/dist/control-maps.d.ts +34 -0
  8. package/dist/control-maps.js +185 -0
  9. package/dist/execution-controls.d.ts +5 -0
  10. package/dist/execution-controls.js +186 -0
  11. package/dist/harness.d.ts +81 -0
  12. package/dist/harness.js +167 -0
  13. package/dist/hooks.d.ts +6 -0
  14. package/dist/hooks.js +261 -0
  15. package/dist/index.d.ts +23 -623
  16. package/dist/index.js +14 -3733
  17. package/dist/mcp.d.ts +22 -0
  18. package/dist/mcp.js +244 -0
  19. package/dist/plan-application.d.ts +10 -0
  20. package/dist/plan-application.js +152 -0
  21. package/dist/plan-digest.d.ts +18 -0
  22. package/dist/plan-digest.js +90 -0
  23. package/dist/plan-json.d.ts +18 -0
  24. package/dist/plan-json.js +91 -0
  25. package/dist/plan-normalize.d.ts +13 -0
  26. package/dist/plan-normalize.js +156 -0
  27. package/dist/plan-prompt.d.ts +38 -0
  28. package/dist/plan-prompt.js +49 -0
  29. package/dist/plan-support.d.ts +9 -0
  30. package/dist/plan-support.js +22 -0
  31. package/dist/plan-types.d.ts +294 -0
  32. package/dist/plan-types.js +106 -0
  33. package/dist/prime-agent-dir.d.ts +47 -0
  34. package/dist/prime-agent-dir.js +164 -0
  35. package/dist/prime-profile.d.ts +88 -0
  36. package/dist/prime-profile.js +284 -0
  37. package/dist/profile-materializer.d.ts +12 -0
  38. package/dist/profile-materializer.js +59 -0
  39. package/dist/profile-plan.d.ts +14 -0
  40. package/dist/profile-plan.js +86 -0
  41. package/dist/profile-prompt.d.ts +4 -0
  42. package/dist/profile-prompt.js +105 -0
  43. package/dist/profile-support.d.ts +8 -0
  44. package/dist/profile-support.js +6 -0
  45. package/dist/resource-lowering.d.ts +7 -0
  46. package/dist/resource-lowering.js +103 -0
  47. package/dist/resource-resolution.d.ts +27 -0
  48. package/dist/resource-resolution.js +241 -0
  49. package/dist/subagents.d.ts +6 -0
  50. package/dist/subagents.js +133 -0
  51. package/dist/workspace-apply.d.ts +12 -0
  52. package/dist/workspace-apply.js +334 -0
  53. package/dist/workspace-files.d.ts +8 -0
  54. package/dist/workspace-files.js +46 -0
  55. package/package.json +3 -3
@@ -0,0 +1,334 @@
1
+ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, options, publicPlanDigest = hashWorkspacePlan(publicPlan)) {
2
+ const workspaceRoot = resolve(workspaceDir);
3
+ const runtimeOnlyFiles = new Set((options.runtimeOnlyFiles ?? []).map(normalizeWorkspaceRelativePath));
4
+ const existingFiles = options.existingFiles ?? "allow-identical";
5
+ if (existingFiles !== "allow-identical" && existingFiles !== "reject") {
6
+ throw new Error(`Unsupported existing workspace file policy: ${existingFiles}`);
7
+ }
8
+ const resolvedFiles = resolvedPlan.files;
9
+ const privateRuntimeFiles = Object.fromEntries(resolvedFiles
10
+ .filter((file) => runtimeOnlyFiles.has(file.relPath))
11
+ .map((file) => [file.relPath, file.content]));
12
+ const files = resolvedFiles
13
+ .filter((file) => !runtimeOnlyFiles.has(file.relPath))
14
+ .map((file) => ({
15
+ ...file,
16
+ absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
17
+ }));
18
+ // A prior application of THIS EXACT plan makes the workspace the running
19
+ // agent's live state: a file the agent edited or removed since then is the
20
+ // session's own memory, not a conflict, so a byte comparison against the
21
+ // plan would refuse a resume the caller proved is unchanged. The digest the
22
+ // caller passes is its own record of what it already applied here; only the
23
+ // caller can know that, which is why this is an option and not an inference
24
+ // from disk. `reject` is not short-circuited: that policy asserts an empty
25
+ // target and must keep failing on any pre-existing path.
26
+ if (existingFiles === "allow-identical" &&
27
+ options.appliedPlanDigest !== undefined &&
28
+ options.appliedPlanDigest === publicPlanDigest) {
29
+ return {
30
+ receipt: safeWorkspacePlanReceipt(publicPlan, files.map((file) => file.relPath), publicPlanDigest),
31
+ spawn: {
32
+ env: { ...resolvedPlan.env },
33
+ flags: [...resolvedPlan.flags],
34
+ ...promptIntentFields(resolvedPlan, samePromptIntentValue),
35
+ ...systemPromptBindingField(resolvedPlan),
36
+ },
37
+ runtimeOnlyFiles: privateRuntimeFiles,
38
+ };
39
+ }
40
+ // A path this package invented can only hold this package's own earlier
41
+ // output, so re-applying into the same workspace replaces it rather than
42
+ // failing the turn. `reject` still refuses: that policy is "nothing may
43
+ // pre-exist here", which is a statement about the workspace, not authorship.
44
+ const mayReplace = (file) => existingFiles === "allow-identical" && file.ownership === "materializer";
45
+ // Validate the complete plan before the first write so a late traversal or
46
+ // existing symlink cannot leave a partially materialized workspace.
47
+ for (const file of files) {
48
+ assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
49
+ if (workspaceFileExists(file.absPath) &&
50
+ !mayReplace(file) &&
51
+ (existingFiles === "reject" || !workspaceFileMatches(file))) {
52
+ throw new Error(workspaceFileConflictMessage(file, existingFiles, publicPlanDigest));
53
+ }
54
+ }
55
+ // Parent directories remain on failure because they may contain concurrent
56
+ // workspace state that this call does not own.
57
+ for (const file of files) {
58
+ mkdirSync(dirname(file.absPath), { recursive: true });
59
+ }
60
+ for (const file of files) {
61
+ assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
62
+ }
63
+ const prepared = [];
64
+ // Prepared temp links stay alive through publication and rollback. Their
65
+ // inodes distinguish files created here from concurrent replacements.
66
+ try {
67
+ for (const file of files) {
68
+ if (existingFiles === "allow-identical" &&
69
+ workspaceFileExists(file.absPath) &&
70
+ workspaceFileMatches(file)) {
71
+ continue;
72
+ }
73
+ prepared.push(prepareWorkspaceFile(workspaceRoot, file));
74
+ }
75
+ for (const entry of prepared) {
76
+ assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
77
+ try {
78
+ linkSync(entry.temporaryPath, entry.file.absPath);
79
+ entry.published = true;
80
+ }
81
+ catch (error) {
82
+ if (isFsError(error, "EEXIST") &&
83
+ existingFiles === "allow-identical" &&
84
+ workspaceFileMatches(entry.file)) {
85
+ continue;
86
+ }
87
+ if (isFsError(error, "EEXIST") && mayReplace(entry.file)) {
88
+ // rename() replaces in one step, so no window exists in which the
89
+ // path is missing and a concurrent spawn reads nothing. The staged
90
+ // inode moves onto the path, which is what rollback identifies the
91
+ // file by, so an abort still removes exactly what this call
92
+ // published — leaving the path ABSENT rather than restored to the
93
+ // superseded bytes. That is the fail-closed direction: the harness
94
+ // whose flag names a missing file refuses to start, whereas silently
95
+ // restored previous-turn bytes would run the wrong prompt.
96
+ assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
97
+ renameSync(entry.temporaryPath, entry.file.absPath);
98
+ entry.published = true;
99
+ continue;
100
+ }
101
+ if (isFsError(error, "EEXIST")) {
102
+ throw new Error(workspaceFileConflictMessage(entry.file, existingFiles, publicPlanDigest), { cause: error });
103
+ }
104
+ throw error;
105
+ }
106
+ }
107
+ }
108
+ catch (error) {
109
+ const cleanupErrors = [];
110
+ for (const entry of [...prepared].reverse()) {
111
+ try {
112
+ if (entry.published &&
113
+ workspacePathHasIdentity(entry.file.absPath, entry.identity)) {
114
+ unlinkSync(entry.file.absPath);
115
+ }
116
+ }
117
+ catch (cleanupError) {
118
+ cleanupErrors.push(cleanupError);
119
+ }
120
+ try {
121
+ if (workspacePathHasIdentity(entry.temporaryPath, entry.identity)) {
122
+ unlinkSync(entry.temporaryPath);
123
+ }
124
+ }
125
+ catch (cleanupError) {
126
+ cleanupErrors.push(cleanupError);
127
+ }
128
+ }
129
+ if (cleanupErrors.length > 0) {
130
+ throw new AggregateError([error, ...cleanupErrors], "Failed to apply workspace plan and roll back published files");
131
+ }
132
+ throw error;
133
+ }
134
+ const receipt = safeWorkspacePlanReceipt(publicPlan, files.map((file) => file.relPath), publicPlanDigest);
135
+ const cleanupErrors = [];
136
+ const temporaryPaths = [];
137
+ for (const entry of prepared) {
138
+ try {
139
+ unlinkSync(entry.temporaryPath);
140
+ }
141
+ catch (error) {
142
+ if (!isFsError(error, "ENOENT")) {
143
+ cleanupErrors.push(error);
144
+ temporaryPaths.push(entry.temporaryPath);
145
+ }
146
+ }
147
+ }
148
+ if (cleanupErrors.length > 0) {
149
+ throw new WorkspacePlanCleanupError(cleanupErrors, receipt, temporaryPaths);
150
+ }
151
+ return {
152
+ receipt,
153
+ spawn: {
154
+ env: { ...resolvedPlan.env },
155
+ flags: [...resolvedPlan.flags],
156
+ ...promptIntentFields(resolvedPlan, samePromptIntentValue),
157
+ ...systemPromptBindingField(resolvedPlan),
158
+ },
159
+ runtimeOnlyFiles: privateRuntimeFiles,
160
+ };
161
+ }
162
+ function workspaceFileExists(pathValue) {
163
+ try {
164
+ lstatSync(pathValue);
165
+ return true;
166
+ }
167
+ catch (error) {
168
+ if (isFsError(error, "ENOENT"))
169
+ return false;
170
+ throw error;
171
+ }
172
+ }
173
+ function workspaceFileMatches(file) {
174
+ const stats = lstatSync(file.absPath);
175
+ return (stats.isFile() &&
176
+ (stats.mode & 0o777) === file.mode &&
177
+ readFileSync(file.absPath, "utf8") === file.content);
178
+ }
179
+ function sha256Hex(content) {
180
+ return createHash("sha256").update(content).digest("hex");
181
+ }
182
+ /**
183
+ * The refusal names what differs — planned versus existing content digest,
184
+ * mode, or file kind — plus the plan digest, so the operator can tell a
185
+ * mid-session profile swap from an agent-edited workspace file without
186
+ * reproducing the workspace. Reads here are best-effort diagnosis inside an
187
+ * already-failing path; an unreadable existing file is reported, not thrown
188
+ * over.
189
+ */
190
+ function workspaceFileConflictMessage(file, existingFiles, planDigest) {
191
+ const prefix = `Refusing to replace existing workspace file: ${file.relPath}`;
192
+ if (existingFiles === "reject") {
193
+ return `${prefix} — the reject policy forbids any pre-existing plan path (plan ${planDigest})`;
194
+ }
195
+ const planned = `planned sha256:${sha256Hex(file.content)} mode ${file.mode.toString(8)}`;
196
+ let existing;
197
+ try {
198
+ const stats = lstatSync(file.absPath);
199
+ if (!stats.isFile()) {
200
+ existing = stats.isSymbolicLink()
201
+ ? "existing path is a symlink, not a regular file"
202
+ : "existing path is not a regular file";
203
+ }
204
+ else {
205
+ const mode = (stats.mode & 0o777).toString(8);
206
+ existing = `existing sha256:${sha256Hex(readFileSync(file.absPath))} mode ${mode}`;
207
+ }
208
+ }
209
+ catch (error) {
210
+ const message = error instanceof Error ? error.message : String(error);
211
+ existing = `existing file unreadable: ${message}`;
212
+ }
213
+ return `${prefix} — ${planned} != ${existing} (plan ${planDigest})`;
214
+ }
215
+ function prepareWorkspaceFile(workspaceRoot, file) {
216
+ assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
217
+ const temporaryPath = join(dirname(file.absPath), `.agent-profile-${randomUUID()}.tmp`);
218
+ let descriptor;
219
+ try {
220
+ descriptor = openSync(temporaryPath, SAFE_WRITE_FLAGS, 0o600);
221
+ writeFileSync(descriptor, file.content, "utf8");
222
+ fchmodSync(descriptor, file.mode);
223
+ const stats = fstatSync(descriptor);
224
+ const descriptorToClose = descriptor;
225
+ descriptor = undefined;
226
+ closeSync(descriptorToClose);
227
+ return {
228
+ file,
229
+ temporaryPath,
230
+ identity: { device: stats.dev, inode: stats.ino },
231
+ published: false,
232
+ };
233
+ }
234
+ catch (error) {
235
+ const cleanupErrors = [];
236
+ if (descriptor !== undefined) {
237
+ try {
238
+ closeSync(descriptor);
239
+ }
240
+ catch (cleanupError) {
241
+ cleanupErrors.push(cleanupError);
242
+ }
243
+ }
244
+ try {
245
+ unlinkSync(temporaryPath);
246
+ }
247
+ catch (cleanupError) {
248
+ if (!isFsError(cleanupError, "ENOENT"))
249
+ cleanupErrors.push(cleanupError);
250
+ }
251
+ if (cleanupErrors.length > 0) {
252
+ throw new AggregateError([error, ...cleanupErrors], `Failed to prepare workspace file: ${file.relPath}`);
253
+ }
254
+ throw error;
255
+ }
256
+ }
257
+ function workspacePathHasIdentity(pathValue, identity) {
258
+ try {
259
+ const stats = lstatSync(pathValue);
260
+ return stats.dev === identity.device && stats.ino === identity.inode;
261
+ }
262
+ catch (error) {
263
+ if (isFsError(error, "ENOENT"))
264
+ return false;
265
+ throw error;
266
+ }
267
+ }
268
+ function resolveWorkspaceFilePath(workspaceRoot, relativePath) {
269
+ const targetPath = resolve(workspaceRoot, ...relativePath.split("/"));
270
+ const targetRelativePath = relative(workspaceRoot, targetPath);
271
+ if (!targetRelativePath ||
272
+ targetRelativePath === ".." ||
273
+ targetRelativePath.startsWith(`..${sep}`) ||
274
+ isAbsolute(targetRelativePath)) {
275
+ throw new Error(`Refusing to materialize profile file outside workspace: ${relativePath}`);
276
+ }
277
+ return targetPath;
278
+ }
279
+ function assertNoSymlinkPathComponents(workspaceRoot, targetPath) {
280
+ const targetRelativePath = relative(workspaceRoot, targetPath);
281
+ let currentPath = workspaceRoot;
282
+ const components = ["", ...targetRelativePath.split(sep)];
283
+ for (const [index, component] of components.entries()) {
284
+ if (component)
285
+ currentPath = join(currentPath, component);
286
+ let stats;
287
+ try {
288
+ stats = lstatSync(currentPath);
289
+ }
290
+ catch (error) {
291
+ if (isFsError(error, "ENOENT"))
292
+ return;
293
+ if (isFsError(error, "ELOOP")) {
294
+ throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
295
+ }
296
+ throw error;
297
+ }
298
+ if (stats.isSymbolicLink()) {
299
+ throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
300
+ }
301
+ const isTarget = index === components.length - 1;
302
+ if ((!isTarget && !stats.isDirectory()) || (isTarget && !stats.isFile())) {
303
+ throw new Error(`Refusing to materialize profile file through non-file path: ${targetRelativePath}`);
304
+ }
305
+ }
306
+ }
307
+ function isFsError(error, code) {
308
+ return (typeof error === "object" &&
309
+ error !== null &&
310
+ "code" in error &&
311
+ error.code === code);
312
+ }
313
+ export function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
314
+ return {
315
+ workspacePlanDigest,
316
+ env: Object.fromEntries(Object.entries(plan.env).map(([name, value]) => [
317
+ name,
318
+ typeof value === "string" ? value : { ...value },
319
+ ])),
320
+ flags: plan.flags.map(normalizeWorkspacePlanArgument),
321
+ ...promptIntentFields(plan, samePromptIntentValue),
322
+ ...systemPromptBindingField(plan),
323
+ unsupported: plan.unsupported.map((entry) => ({ ...entry })),
324
+ written: [...written],
325
+ };
326
+ }
327
+ import { createHash, randomUUID } from "node:crypto";
328
+ import { closeSync, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
329
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
330
+ import { hashWorkspacePlan } from "./plan-digest.js";
331
+ import { normalizeWorkspacePlanArgument, } from "./plan-normalize.js";
332
+ import { promptIntentFields, samePromptIntentValue, systemPromptBindingField, } from "./plan-prompt.js";
333
+ import { WorkspacePlanCleanupError } from "./plan-types.js";
334
+ import { normalizeWorkspaceRelativePath, SAFE_WRITE_FLAGS, } from "./workspace-files.js";
@@ -0,0 +1,8 @@
1
+ export declare const compareStrings: (a: string, b: string) => 0 | 1 | -1;
2
+ export declare const DEFAULT_FILE_MODE = 420;
3
+ export declare const PRIVATE_FILE_MODE = 384;
4
+ export declare const EXECUTABLE_FILE_MODE = 493;
5
+ export declare const SAFE_WRITE_FLAGS: number;
6
+ export declare function normalizeWorkspaceRelativePath(pathValue: string): string;
7
+ export declare function hasControlCharacter(value: string): boolean;
8
+ export declare function validateFileMode(mode: number | undefined): number;
@@ -0,0 +1,46 @@
1
+ export const compareStrings = (a, b) => a < b ? -1 : a > b ? 1 : 0;
2
+ export const DEFAULT_FILE_MODE = 0o644;
3
+ export const PRIVATE_FILE_MODE = 0o600;
4
+ export const EXECUTABLE_FILE_MODE = 0o755;
5
+ export const SAFE_WRITE_FLAGS = fsConstants.O_WRONLY |
6
+ fsConstants.O_CREAT |
7
+ fsConstants.O_EXCL |
8
+ (typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0);
9
+ export function normalizeWorkspaceRelativePath(pathValue) {
10
+ if (typeof pathValue !== "string" ||
11
+ !pathValue ||
12
+ hasControlCharacter(pathValue)) {
13
+ throw new Error("Workspace file path must be non-empty and contain no control characters");
14
+ }
15
+ if (isAbsolute(pathValue) ||
16
+ win32.isAbsolute(pathValue) ||
17
+ /^[A-Za-z]:/.test(pathValue)) {
18
+ throw new Error(`Workspace file path must be relative: ${pathValue}`);
19
+ }
20
+ const segments = pathValue.replace(/\\/g, "/").split("/");
21
+ if (segments.some((segment) => !segment ||
22
+ segment === "." ||
23
+ segment === ".." ||
24
+ segment === ".git" ||
25
+ segment === ".sidecar")) {
26
+ throw new Error(`Workspace file path must be canonical: ${pathValue}`);
27
+ }
28
+ return segments.join("/");
29
+ }
30
+ export function hasControlCharacter(value) {
31
+ for (let index = 0; index < value.length; index++) {
32
+ const code = value.charCodeAt(index);
33
+ if (code < 0x20 || code === 0x7f)
34
+ return true;
35
+ }
36
+ return false;
37
+ }
38
+ export function validateFileMode(mode) {
39
+ const normalized = mode ?? DEFAULT_FILE_MODE;
40
+ if (!Number.isInteger(normalized) || normalized < 0 || normalized > 0o777) {
41
+ throw new Error(`Workspace file mode must be between 0o000 and 0o777: ${mode}`);
42
+ }
43
+ return normalized;
44
+ }
45
+ import { constants as fsConstants } from "node:fs";
46
+ import { isAbsolute, win32 } from "node:path";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-profile-materialize",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Materialize an AgentProfile into the native per-harness workspace files (the one shared materializer for cli-bridge, agent-dev-container, and VB).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,13 +20,13 @@
20
20
  "dist"
21
21
  ],
22
22
  "devDependencies": {
23
- "@tangle-network/agent-interface": "0.53.0",
23
+ "@tangle-network/agent-interface": "1.0.0",
24
24
  "@types/node": "25.6.0",
25
25
  "typescript": "^6.0.3",
26
26
  "vitest": "^4.1.10"
27
27
  },
28
28
  "peerDependencies": {
29
- "@tangle-network/agent-interface": ">=0.47.0 <0.54.0"
29
+ "@tangle-network/agent-interface": "^1.0.0"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsc -p tsconfig.json",