@tangle-network/agent-profile-materialize 0.17.0 → 0.17.3

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.
@@ -35,8 +35,8 @@ export function materializeExecutionControls(h, tools, permissions, modes, plan,
35
35
  const nativeTools = mapSupportedBooleanEntries(enforceableTools, openCodeToolName, (name) => unsupported("tools", `opencode: no verified native tool mapping for "${name}"`), "opencode tool");
36
36
  const nativePermissions = mapSupportedPermissionEntries(enforceablePermissions, openCodePermissionName, (name) => unsupported("permissions", `opencode: no verified native permission mapping for "${name}"`), "opencode permission");
37
37
  if (enforceableTools.length > 0 || permissionEntries.length > 0) {
38
- add("opencode.json", serializeWorkspacePlanJson({
39
- $schema: "https://opencode.ai/config.json",
38
+ add(OPENCODE_CONFIG_FILE, serializeWorkspacePlanJson({
39
+ $schema: OPENCODE_CONFIG_SCHEMA,
40
40
  ...(nativeTools.length > 0
41
41
  ? { tools: Object.fromEntries(nativeTools) }
42
42
  : {}),
@@ -180,7 +180,7 @@ function piExecutionControlExtensionSource(enabledTools, excludedTools) {
180
180
  }
181
181
  import { resolveNativeWebTools } from "@tangle-network/agent-interface";
182
182
  import { claudePermissionRules, claudeToolName, mapSupportedBooleanEntries, mapSupportedPermissionEntries, normalizedControlName, openCodePermissionName, openCodeToolName, PI_EXECUTION_CONTROL_EXTENSION_PATH, permissionAllowsEverything, piToolId, sealClaudePermissionRules, } from "./control-maps.js";
183
- import { CLAUDE_SETTINGS_PATH } from "./harness.js";
183
+ import { CLAUDE_SETTINGS_PATH, OPENCODE_CONFIG_FILE, OPENCODE_CONFIG_SCHEMA, } from "./harness.js";
184
184
  import { registerClaudeSettings } from "./hooks.js";
185
185
  import { serializeWorkspacePlanJson } from "./plan-json.js";
186
186
  import { compareStrings } from "./workspace-files.js";
package/dist/harness.d.ts CHANGED
@@ -4,6 +4,8 @@ export declare function isMaterializerHarness(harness: string): harness is Harne
4
4
  export declare function assertMaterializerHarness(harness: string): asserts harness is HarnessId;
5
5
  export declare const CODEX_SYSTEM_PROMPT_FILE = ".codex/system-prompt.md";
6
6
  export declare const OPENCODE_APPEND_SYSTEM_PROMPT_FILE = ".opencode/agent-system-prompt.md";
7
+ export declare const OPENCODE_CONFIG_FILE = "opencode.json";
8
+ export declare const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json";
7
9
  export declare const OPENCODE_PRIMARY_AGENT_PREFIX = "tangle-profile-";
8
10
  export declare const CLAUDE_SETTINGS_PATH = ".tangle/claude-settings.json";
9
11
  export declare function harnessAutoLoadsContextFile(harness: HarnessId): boolean;
@@ -38,30 +40,52 @@ export declare function appendSystemPromptRefusal(harness: HarnessId): string;
38
40
  /** Project context file each harness actually reads from cwd. NEVER a constant. */
39
41
  export declare const CONTEXT_FILE: Record<HarnessId, string>;
40
42
  /**
41
- * The paths whose only possible author is this package, per
42
- * {@link PlanFile.ownership}.
43
+ * The paths a profile resource may never mount.
43
44
  *
44
45
  * Codex names no system-prompt file of its own — `model_instructions_file`
45
46
  * takes whatever path it is given — and opencode names none either, since it
46
47
  * reads exactly the files its config lists. All three names below were chosen
47
48
  * here, so a file already at one of them is this materializer's own output from
48
- * an earlier application into the same workspace.
49
+ * an earlier application into the same workspace, and a mount there could not
50
+ * survive the next application.
49
51
  *
50
52
  * That is an inference from the path name, not verified provenance: nothing
51
53
  * checks the existing bytes are prior materializer output, so a workspace that
52
54
  * carried a foreign file at one of these invented names is overwritten on the
53
55
  * first application. Accepted cost of stateless multi-turn re-application,
54
56
  * bounded by the names being ones no harness or user convention uses.
57
+ */
58
+ export declare const MATERIALIZER_RESERVED_PATHS: ReadonlySet<string>;
59
+ /**
60
+ * The paths whose generated content is wholly this package's output, per
61
+ * {@link PlanFile.ownership}: the reserved names plus `opencode.json`, which
62
+ * carries only the registrations this package generates from the profile.
63
+ *
64
+ * `opencode.json` is a documented name a user repository can carry, so the
65
+ * name alone proves nothing and a profile resource may still mount it. An
66
+ * existing file there is replaced only when {@link isMaterializerOutput}
67
+ * recognizes its bytes as this package's own earlier output.
55
68
  *
56
69
  * Deliberately absent: `.gemini/system.md` (gemini's own documented name),
57
- * `GEMINI.md`, `CLAUDE.md`, `AGENTS.md`, `opencode.json`. A user's repository
58
- * can carry any of those, and clobbering one is worse than refusing the turn.
59
- * For `opencode.json` this means a profile change that alters the
60
- * `instructions[]` membership between turns of one workspace refuses loudly
61
- * (the consolidated config no longer matches byte-for-byte) rather than
62
- * replacing a possibly user-authored config.
70
+ * `GEMINI.md`, `CLAUDE.md`, `AGENTS.md`. A user's repository can carry any of
71
+ * those with content this package cannot tell from its own, and clobbering
72
+ * one is worse than refusing the turn.
63
73
  */
64
74
  export declare const MATERIALIZER_OWNED_PATHS: ReadonlySet<string>;
75
+ /** The top-level keys this package writes into `opencode.json`. */
76
+ export declare const OPENCODE_CONFIG_KEYS: ReadonlySet<string>;
77
+ /**
78
+ * Whether the file already at owned path `relPath` is this package's own
79
+ * earlier output. A reserved name proves that by itself, so `content` is read
80
+ * only for `opencode.json`; `undefined` content means no regular file is there.
81
+ *
82
+ * An `opencode.json` this package wrote holds the opencode schema id, only the
83
+ * keys this package writes, and only instruction paths this package invented.
84
+ * A user config that holds nothing else is indistinguishable from that output
85
+ * and is replaced too; a config with any other key, or with an instruction path
86
+ * of the user's own, is refused.
87
+ */
88
+ export declare function isMaterializerOutput(relPath: string, content: () => string | undefined): boolean;
65
89
  /**
66
90
  * cwd-relative native skill dir per harness, or null if the harness has no
67
91
  * cwd skill primitive (skills live in a user/global dir the workspace plan
package/dist/harness.js CHANGED
@@ -21,6 +21,8 @@ export function assertMaterializerHarness(harness) {
21
21
  }
22
22
  export const CODEX_SYSTEM_PROMPT_FILE = ".codex/system-prompt.md";
23
23
  export const OPENCODE_APPEND_SYSTEM_PROMPT_FILE = ".opencode/agent-system-prompt.md";
24
+ export const OPENCODE_CONFIG_FILE = "opencode.json";
25
+ export const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json";
24
26
  export const OPENCODE_PRIMARY_AGENT_PREFIX = "tangle-profile-";
25
27
  export const CLAUDE_SETTINGS_PATH = ".tangle/claude-settings.json";
26
28
  export function harnessAutoLoadsContextFile(harness) {
@@ -101,34 +103,95 @@ export const CONTEXT_FILE = {
101
103
  openclaw: "AGENTS.md",
102
104
  };
103
105
  /**
104
- * The paths whose only possible author is this package, per
105
- * {@link PlanFile.ownership}.
106
+ * The paths a profile resource may never mount.
106
107
  *
107
108
  * Codex names no system-prompt file of its own — `model_instructions_file`
108
109
  * takes whatever path it is given — and opencode names none either, since it
109
110
  * reads exactly the files its config lists. All three names below were chosen
110
111
  * here, so a file already at one of them is this materializer's own output from
111
- * an earlier application into the same workspace.
112
+ * an earlier application into the same workspace, and a mount there could not
113
+ * survive the next application.
112
114
  *
113
115
  * That is an inference from the path name, not verified provenance: nothing
114
116
  * checks the existing bytes are prior materializer output, so a workspace that
115
117
  * carried a foreign file at one of these invented names is overwritten on the
116
118
  * first application. Accepted cost of stateless multi-turn re-application,
117
119
  * bounded by the names being ones no harness or user convention uses.
118
- *
119
- * Deliberately absent: `.gemini/system.md` (gemini's own documented name),
120
- * `GEMINI.md`, `CLAUDE.md`, `AGENTS.md`, `opencode.json`. A user's repository
121
- * can carry any of those, and clobbering one is worse than refusing the turn.
122
- * For `opencode.json` this means a profile change that alters the
123
- * `instructions[]` membership between turns of one workspace refuses loudly
124
- * (the consolidated config no longer matches byte-for-byte) rather than
125
- * replacing a possibly user-authored config.
126
120
  */
127
- export const MATERIALIZER_OWNED_PATHS = new Set([
121
+ export const MATERIALIZER_RESERVED_PATHS = new Set([
128
122
  CODEX_SYSTEM_PROMPT_FILE,
129
123
  OPENCODE_APPEND_SYSTEM_PROMPT_FILE,
130
124
  CONTEXT_FILE.opencode,
131
125
  ]);
126
+ /**
127
+ * The paths whose generated content is wholly this package's output, per
128
+ * {@link PlanFile.ownership}: the reserved names plus `opencode.json`, which
129
+ * carries only the registrations this package generates from the profile.
130
+ *
131
+ * `opencode.json` is a documented name a user repository can carry, so the
132
+ * name alone proves nothing and a profile resource may still mount it. An
133
+ * existing file there is replaced only when {@link isMaterializerOutput}
134
+ * recognizes its bytes as this package's own earlier output.
135
+ *
136
+ * Deliberately absent: `.gemini/system.md` (gemini's own documented name),
137
+ * `GEMINI.md`, `CLAUDE.md`, `AGENTS.md`. A user's repository can carry any of
138
+ * those with content this package cannot tell from its own, and clobbering
139
+ * one is worse than refusing the turn.
140
+ */
141
+ export const MATERIALIZER_OWNED_PATHS = new Set([
142
+ ...MATERIALIZER_RESERVED_PATHS,
143
+ OPENCODE_CONFIG_FILE,
144
+ ]);
145
+ /** The top-level keys this package writes into `opencode.json`. */
146
+ export const OPENCODE_CONFIG_KEYS = new Set([
147
+ "$schema",
148
+ "instructions",
149
+ "mcp",
150
+ "tools",
151
+ "permission",
152
+ ]);
153
+ /**
154
+ * Whether the file already at owned path `relPath` is this package's own
155
+ * earlier output. A reserved name proves that by itself, so `content` is read
156
+ * only for `opencode.json`; `undefined` content means no regular file is there.
157
+ *
158
+ * An `opencode.json` this package wrote holds the opencode schema id, only the
159
+ * keys this package writes, and only instruction paths this package invented.
160
+ * A user config that holds nothing else is indistinguishable from that output
161
+ * and is replaced too; a config with any other key, or with an instruction path
162
+ * of the user's own, is refused.
163
+ */
164
+ export function isMaterializerOutput(relPath, content) {
165
+ if (MATERIALIZER_RESERVED_PATHS.has(relPath))
166
+ return true;
167
+ if (relPath !== OPENCODE_CONFIG_FILE)
168
+ return false;
169
+ const bytes = content();
170
+ if (bytes === undefined)
171
+ return false;
172
+ let parsed;
173
+ try {
174
+ parsed = JSON.parse(bytes);
175
+ }
176
+ catch {
177
+ return false;
178
+ }
179
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
180
+ return false;
181
+ }
182
+ const config = parsed;
183
+ if (config.$schema !== OPENCODE_CONFIG_SCHEMA)
184
+ return false;
185
+ if (!Object.keys(config).every((key) => OPENCODE_CONFIG_KEYS.has(key))) {
186
+ return false;
187
+ }
188
+ const instructions = config.instructions;
189
+ if (instructions === undefined)
190
+ return true;
191
+ return (Array.isArray(instructions) &&
192
+ instructions.every((entry) => entry === OPENCODE_APPEND_SYSTEM_PROMPT_FILE ||
193
+ entry === CONTEXT_FILE.opencode));
194
+ }
132
195
  /**
133
196
  * cwd-relative native skill dir per harness, or null if the harness has no
134
197
  * cwd skill primitive (skills live in a user/global dir the workspace plan
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export type { HarnessId } from "./harness.js";
6
6
  export { HARNESS_SKILL_DIRS, harnessAutoLoadsContextFile, harnessContextFile, isMaterializerHarness, MATERIALIZER_HARNESSES, skillDirForHarness, } from "./harness.js";
7
7
  export type { OpenCodeMcpConfig } from "./mcp.js";
8
8
  export { openCodeMcpConfig } from "./mcp.js";
9
- export { modelIdsMatch, qualifyModelId } from "./model-id.js";
9
+ export { modelIdsMatch, qualifyModelId, unqualifyModelId, } from "./model-id.js";
10
10
  export { buildPiModelsConfig, buildPiModelsConfigFile, PI_MODELS_CONFIG_PATH, type PiModelsConfigFile, type PiModelsConfigInput, } from "./pi-models-config.js";
11
11
  export { applyWorkspacePlan, applyWorkspacePlanWithSecretProvider, } from "./plan-application.js";
12
12
  export { agentCandidateProfilePlanMaterial, canonicalizeAgentCandidateProfilePlan, captureAgentCandidateProfilePlan, hashWorkspacePlan, } from "./plan-digest.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { applyAgentCandidateWorkspacePlan, materializeCandidateProfile, } from "
3
3
  export { OPENCODE_NATIVE_TOOL_IDS, openCodeNativeToolId, } from "./control-maps.js";
4
4
  export { HARNESS_SKILL_DIRS, harnessAutoLoadsContextFile, harnessContextFile, isMaterializerHarness, MATERIALIZER_HARNESSES, skillDirForHarness, } from "./harness.js";
5
5
  export { openCodeMcpConfig } from "./mcp.js";
6
- export { modelIdsMatch, qualifyModelId } from "./model-id.js";
6
+ export { modelIdsMatch, qualifyModelId, unqualifyModelId, } from "./model-id.js";
7
7
  export { buildPiModelsConfig, buildPiModelsConfigFile, PI_MODELS_CONFIG_PATH, } from "./pi-models-config.js";
8
8
  export { applyWorkspacePlan, applyWorkspacePlanWithSecretProvider, } from "./plan-application.js";
9
9
  export { agentCandidateProfilePlanMaterial, canonicalizeAgentCandidateProfilePlan, captureAgentCandidateProfilePlan, hashWorkspacePlan, } from "./plan-digest.js";
package/dist/mcp.js CHANGED
@@ -47,8 +47,8 @@ export function materializeMcp(h, mcp, plan, add, unsupported) {
47
47
  }
48
48
  case "opencode":
49
49
  // opencode.json "mcp" block (local|remote).
50
- add("opencode.json", serializeWorkspacePlanJson({
51
- $schema: "https://opencode.ai/config.json",
50
+ add(OPENCODE_CONFIG_FILE, serializeWorkspacePlanJson({
51
+ $schema: OPENCODE_CONFIG_SCHEMA,
52
52
  mcp: openCodeMcpConfig(mcp),
53
53
  }));
54
54
  break;
@@ -238,6 +238,7 @@ export function openCodeMcpConfig(mcp) {
238
238
  }
239
239
  return out;
240
240
  }
241
+ import { OPENCODE_CONFIG_FILE, OPENCODE_CONFIG_SCHEMA } from "./harness.js";
241
242
  import { sha256 } from "./plan-digest.js";
242
243
  import { concatWorkspacePlanArguments, joinWorkspacePlanArguments, serializeWorkspacePlanJson, tomlProfileConfigValue, tomlStr, } from "./plan-json.js";
243
244
  import { setPlanEnvironment } from "./profile-plan.js";
@@ -26,6 +26,19 @@
26
26
  * `qualifyModelId(p, qualifyModelId(p, m)) === qualifyModelId(p, m)`.
27
27
  */
28
28
  export declare function qualifyModelId<T extends string | undefined>(provider: string | undefined, modelId: T): T;
29
+ /**
30
+ * Remove one exact `provider/` qualification from `modelId`.
31
+ *
32
+ * This is the inverse operation for a model id that was qualified by this
33
+ * provider. It removes only the known outer prefix and preserves upstream
34
+ * segments such as `zai/glm-4.7`.
35
+ *
36
+ * The helper removes one outer qualification per call. It leaves missing
37
+ * providers and model ids unchanged.
38
+ */
39
+ export declare function unqualifyModelId(provider: string | undefined, modelId: string): string;
40
+ export declare function unqualifyModelId(provider: string | undefined, modelId: undefined): undefined;
41
+ export declare function unqualifyModelId(provider: string | undefined, modelId: string | undefined): string | undefined;
29
42
  /**
30
43
  * Do two model ids name the same model?
31
44
  *
package/dist/model-id.js CHANGED
@@ -32,6 +32,14 @@ export function qualifyModelId(provider, modelId) {
32
32
  return modelId;
33
33
  return `${provider}/${modelId}`;
34
34
  }
35
+ export function unqualifyModelId(provider, modelId) {
36
+ if (!provider || !modelId)
37
+ return modelId;
38
+ const providerPrefix = `${provider}/`;
39
+ return modelId.startsWith(providerPrefix)
40
+ ? modelId.slice(providerPrefix.length)
41
+ : modelId;
42
+ }
35
43
  /**
36
44
  * Do two model ids name the same model?
37
45
  *
@@ -28,16 +28,20 @@ export interface PlanFile {
28
28
  * Who owns the PATH — which decides whether an existing file there may be
29
29
  * replaced.
30
30
  *
31
- * `"materializer"` = a path this package invented. No harness convention
32
- * names it and no user authors it, so a file already there can only be this
33
- * materializer's own output from an earlier application into the same
34
- * workspace, and replacing it is how turn two of a resumed session gets the
35
- * current profile instead of the previous one.
31
+ * `"materializer"` = a path whose generated content is wholly this
32
+ * package's output: a name this package invented, or `opencode.json`, which
33
+ * carries only the registrations generated from the profile. A file already
34
+ * there is this materializer's own output from an earlier application into
35
+ * the same workspace, and replacing it is how turn two of a resumed session
36
+ * gets the current profile instead of the previous one. Application replaces
37
+ * it only when the existing bytes prove that provenance — by the name for an
38
+ * invented path, by the content for `opencode.json`.
36
39
  *
37
40
  * `"shared"` (the default, and every other file) = a path the user or the
38
- * harness also owns — `CLAUDE.md`, `AGENTS.md`, `opencode.json`,
39
- * `.gemini/system.md` are all documented, user-authorable names. An existing
40
- * file there may be the caller's own work, so it is never replaced.
41
+ * harness also owns — `CLAUDE.md`, `AGENTS.md`, `.gemini/system.md` are all
42
+ * documented, user-authorable names whose content this package cannot tell
43
+ * from its own. An existing file there may be the caller's own work, so it is
44
+ * never replaced.
41
45
  *
42
46
  * Optional because hand-constructed plans have no path provenance to report;
43
47
  * absent reads as `"shared"`, the safe answer.
@@ -277,16 +281,21 @@ export declare class WorkspacePlanCleanupError extends AggregateError {
277
281
  export interface ApplyWorkspacePlanOptions {
278
282
  /** Plan files consumed in memory by the runtime and never persisted to cwd. */
279
283
  runtimeOnlyFiles?: readonly string[];
284
+ /** Own newly written workspace files as the process that will read them. */
285
+ ownership?: {
286
+ uid: number;
287
+ gid: number;
288
+ };
280
289
  /** Existing workspace paths are either exact idempotent inputs or forbidden. */
281
290
  existingFiles?: "allow-identical" | "reject";
282
291
  /**
283
292
  * `workspacePlanDigest` of a plan a prior call already applied into this
284
- * workspace. When it equals this plan's digest, application writes no files:
285
- * the workspace holds the running agent's live state, and a file the agent
286
- * edited or removed since the prior application stays exactly as the agent
287
- * left it. Env, flags, prompt intents, and the receipt are still produced.
288
- * A different digest applies normally, so a plan that changed mid-session
289
- * still meets the existing-file refusal.
293
+ * workspace. When it equals this plan's digest, application changes no file
294
+ * content. It can repair ownership on unchanged materializer-owned files.
295
+ * A file the agent edited or removed stays exactly as the agent left it.
296
+ * Env, flags, prompt intents, and the receipt are still produced. A different
297
+ * digest applies normally, so a changed plan still meets the existing-file
298
+ * refusal.
290
299
  */
291
300
  appliedPlanDigest?: string;
292
301
  }
@@ -1,4 +1,4 @@
1
- import { MATERIALIZER_OWNED_PATHS } from "./harness.js";
1
+ import { MATERIALIZER_OWNED_PATHS, MATERIALIZER_RESERVED_PATHS, } from "./harness.js";
2
2
  import { workspacePlanConfigValue } from "./plan-json.js";
3
3
  import { mergeJsonFiles } from "./plan-normalize.js";
4
4
  import { formatUnsupported } from "./plan-support.js";
@@ -47,7 +47,7 @@ export function createProfilePlanBuilder(harness) {
47
47
  if (previousDimension) {
48
48
  throw new Error(`Duplicate profile resource path: ${normalizedPath} (${previousDimension}, ${dimension})`);
49
49
  }
50
- if (MATERIALIZER_OWNED_PATHS.has(normalizedPath)) {
50
+ if (MATERIALIZER_RESERVED_PATHS.has(normalizedPath)) {
51
51
  throw new Error(`Profile resource path is reserved by the materializer: ${normalizedPath} (${dimension}). ` +
52
52
  "This package writes that path itself and replaces it on re-application, so a mount " +
53
53
  "there cannot survive; choose another path.");
@@ -1,4 +1,4 @@
1
- import { appendSystemPromptRefusal, CODEX_SYSTEM_PROMPT_FILE, CONTEXT_FILE, OPENCODE_APPEND_SYSTEM_PROMPT_FILE, OPENCODE_PRIMARY_AGENT_PREFIX, systemPromptRefusal, } from "./harness.js";
1
+ import { appendSystemPromptRefusal, CODEX_SYSTEM_PROMPT_FILE, CONTEXT_FILE, OPENCODE_APPEND_SYSTEM_PROMPT_FILE, OPENCODE_CONFIG_FILE, OPENCODE_CONFIG_SCHEMA, OPENCODE_PRIMARY_AGENT_PREFIX, systemPromptRefusal, } from "./harness.js";
2
2
  import { sha256 } from "./plan-digest.js";
3
3
  import { serializeWorkspacePlanJson } from "./plan-json.js";
4
4
  export function lowerProfilePrompt(profile, harness, binds, builder) {
@@ -97,8 +97,8 @@ export function lowerProfilePrompt(profile, harness, binds, builder) {
97
97
  }
98
98
  }
99
99
  if (openCodeInstructionFiles.length > 0) {
100
- add("opencode.json", serializeWorkspacePlanJson({
101
- $schema: "https://opencode.ai/config.json",
100
+ add(OPENCODE_CONFIG_FILE, serializeWorkspacePlanJson({
101
+ $schema: OPENCODE_CONFIG_SCHEMA,
102
102
  instructions: openCodeInstructionFiles,
103
103
  }));
104
104
  }
@@ -0,0 +1,50 @@
1
+ declare function workspaceFileExists(pathValue: string): boolean;
2
+ declare function regularFileContent(pathValue: string): string | undefined;
3
+ declare function workspaceFileMatches(file: NormalizedPlanFile & {
4
+ absPath: string;
5
+ }): boolean;
6
+ declare function workspaceFileHasOwnership(pathValue: string, ownership: {
7
+ uid: number;
8
+ gid: number;
9
+ }): boolean;
10
+ declare function validateWorkspaceOwnership(ownership: ApplyWorkspacePlanOptions["ownership"]): {
11
+ uid: number;
12
+ gid: number;
13
+ } | undefined;
14
+ /**
15
+ * The refusal names what differs — planned versus existing content digest,
16
+ * mode, or file kind — plus the plan digest, so the operator can tell a
17
+ * mid-session profile swap from an agent-edited workspace file without
18
+ * reproducing the workspace. Reads here are best-effort diagnosis inside an
19
+ * already-failing path; an unreadable existing file is reported, not thrown
20
+ * over.
21
+ */
22
+ declare function workspaceFileConflictMessage(file: NormalizedPlanFile & {
23
+ absPath: string;
24
+ }, existingFiles: "allow-identical" | "reject", planDigest: string): string;
25
+ interface WorkspaceFileIdentity {
26
+ device: number;
27
+ inode: number;
28
+ }
29
+ interface PreparedWorkspaceFile {
30
+ file: NormalizedPlanFile & {
31
+ absPath: string;
32
+ };
33
+ temporaryPath: string;
34
+ identity: WorkspaceFileIdentity;
35
+ published: boolean;
36
+ }
37
+ declare function prepareWorkspaceFile(workspaceRoot: string, file: NormalizedPlanFile & {
38
+ absPath: string;
39
+ }, ownership: {
40
+ uid: number;
41
+ gid: number;
42
+ } | undefined): PreparedWorkspaceFile;
43
+ declare function workspacePathHasIdentity(pathValue: string, identity: WorkspaceFileIdentity): boolean;
44
+ declare function resolveWorkspaceFilePath(workspaceRoot: string, relativePath: string): string;
45
+ declare function assertNoSymlinkPathComponents(workspaceRoot: string, targetPath: string): void;
46
+ declare function isFsError(error: unknown, code: string): error is NodeJS.ErrnoException;
47
+ export type { PreparedWorkspaceFile };
48
+ export { assertNoSymlinkPathComponents, isFsError, prepareWorkspaceFile, regularFileContent, resolveWorkspaceFilePath, validateWorkspaceOwnership, workspaceFileConflictMessage, workspaceFileExists, workspaceFileHasOwnership, workspaceFileMatches, workspacePathHasIdentity, };
49
+ import type { NormalizedPlanFile } from "./plan-normalize.js";
50
+ import type { ApplyWorkspacePlanOptions } from "./plan-types.js";
@@ -0,0 +1,178 @@
1
+ function workspaceFileExists(pathValue) {
2
+ try {
3
+ lstatSync(pathValue);
4
+ return true;
5
+ }
6
+ catch (error) {
7
+ if (isFsError(error, "ENOENT"))
8
+ return false;
9
+ throw error;
10
+ }
11
+ }
12
+ function regularFileContent(pathValue) {
13
+ return lstatSync(pathValue).isFile()
14
+ ? readFileSync(pathValue, "utf8")
15
+ : undefined;
16
+ }
17
+ function workspaceFileMatches(file) {
18
+ const stats = lstatSync(file.absPath);
19
+ return (stats.isFile() &&
20
+ (stats.mode & 0o777) === file.mode &&
21
+ readFileSync(file.absPath, "utf8") === file.content);
22
+ }
23
+ function workspaceFileHasOwnership(pathValue, ownership) {
24
+ const stats = lstatSync(pathValue);
25
+ return (stats.isFile() && stats.uid === ownership.uid && stats.gid === ownership.gid);
26
+ }
27
+ function validateWorkspaceOwnership(ownership) {
28
+ if (ownership === undefined)
29
+ return undefined;
30
+ if (!Number.isInteger(ownership.uid) ||
31
+ ownership.uid < 0 ||
32
+ !Number.isInteger(ownership.gid) ||
33
+ ownership.gid < 0) {
34
+ throw new Error("Workspace file ownership must use non-negative integer uid/gid values");
35
+ }
36
+ return ownership;
37
+ }
38
+ function sha256Hex(content) {
39
+ return createHash("sha256").update(content).digest("hex");
40
+ }
41
+ /**
42
+ * The refusal names what differs — planned versus existing content digest,
43
+ * mode, or file kind — plus the plan digest, so the operator can tell a
44
+ * mid-session profile swap from an agent-edited workspace file without
45
+ * reproducing the workspace. Reads here are best-effort diagnosis inside an
46
+ * already-failing path; an unreadable existing file is reported, not thrown
47
+ * over.
48
+ */
49
+ function workspaceFileConflictMessage(file, existingFiles, planDigest) {
50
+ const prefix = `Refusing to replace existing workspace file: ${file.relPath}`;
51
+ if (existingFiles === "reject") {
52
+ return `${prefix} — the reject policy forbids any pre-existing plan path (plan ${planDigest})`;
53
+ }
54
+ const planned = `planned sha256:${sha256Hex(file.content)} mode ${file.mode.toString(8)}`;
55
+ let existing;
56
+ try {
57
+ const stats = lstatSync(file.absPath);
58
+ if (!stats.isFile()) {
59
+ existing = stats.isSymbolicLink()
60
+ ? "existing path is a symlink, not a regular file"
61
+ : "existing path is not a regular file";
62
+ }
63
+ else {
64
+ const mode = (stats.mode & 0o777).toString(8);
65
+ existing = `existing sha256:${sha256Hex(readFileSync(file.absPath))} mode ${mode}`;
66
+ }
67
+ }
68
+ catch (error) {
69
+ const message = error instanceof Error ? error.message : String(error);
70
+ existing = `existing file unreadable: ${message}`;
71
+ }
72
+ return `${prefix} — ${planned} != ${existing} (plan ${planDigest})`;
73
+ }
74
+ function prepareWorkspaceFile(workspaceRoot, file, ownership) {
75
+ assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
76
+ const temporaryPath = join(dirname(file.absPath), `.agent-profile-${randomUUID()}.tmp`);
77
+ let descriptor;
78
+ try {
79
+ descriptor = openSync(temporaryPath, SAFE_WRITE_FLAGS, 0o600);
80
+ writeFileSync(descriptor, file.content, "utf8");
81
+ if (ownership)
82
+ fchownSync(descriptor, ownership.uid, ownership.gid);
83
+ fchmodSync(descriptor, file.mode);
84
+ const stats = fstatSync(descriptor);
85
+ const descriptorToClose = descriptor;
86
+ descriptor = undefined;
87
+ closeSync(descriptorToClose);
88
+ return {
89
+ file,
90
+ temporaryPath,
91
+ identity: { device: stats.dev, inode: stats.ino },
92
+ published: false,
93
+ };
94
+ }
95
+ catch (error) {
96
+ const cleanupErrors = [];
97
+ if (descriptor !== undefined) {
98
+ try {
99
+ closeSync(descriptor);
100
+ }
101
+ catch (cleanupError) {
102
+ cleanupErrors.push(cleanupError);
103
+ }
104
+ }
105
+ try {
106
+ unlinkSync(temporaryPath);
107
+ }
108
+ catch (cleanupError) {
109
+ if (!isFsError(cleanupError, "ENOENT"))
110
+ cleanupErrors.push(cleanupError);
111
+ }
112
+ if (cleanupErrors.length > 0) {
113
+ throw new AggregateError([error, ...cleanupErrors], `Failed to prepare workspace file: ${file.relPath}`);
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+ function workspacePathHasIdentity(pathValue, identity) {
119
+ try {
120
+ const stats = lstatSync(pathValue);
121
+ return stats.dev === identity.device && stats.ino === identity.inode;
122
+ }
123
+ catch (error) {
124
+ if (isFsError(error, "ENOENT"))
125
+ return false;
126
+ throw error;
127
+ }
128
+ }
129
+ function resolveWorkspaceFilePath(workspaceRoot, relativePath) {
130
+ const targetPath = resolve(workspaceRoot, ...relativePath.split("/"));
131
+ const targetRelativePath = relative(workspaceRoot, targetPath);
132
+ if (!targetRelativePath ||
133
+ targetRelativePath === ".." ||
134
+ targetRelativePath.startsWith(`..${sep}`) ||
135
+ isAbsolute(targetRelativePath)) {
136
+ throw new Error(`Refusing to materialize profile file outside workspace: ${relativePath}`);
137
+ }
138
+ return targetPath;
139
+ }
140
+ function assertNoSymlinkPathComponents(workspaceRoot, targetPath) {
141
+ const targetRelativePath = relative(workspaceRoot, targetPath);
142
+ let currentPath = workspaceRoot;
143
+ const components = ["", ...targetRelativePath.split(sep)];
144
+ for (const [index, component] of components.entries()) {
145
+ if (component)
146
+ currentPath = join(currentPath, component);
147
+ let stats;
148
+ try {
149
+ stats = lstatSync(currentPath);
150
+ }
151
+ catch (error) {
152
+ if (isFsError(error, "ENOENT"))
153
+ return;
154
+ if (isFsError(error, "ELOOP")) {
155
+ throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
156
+ }
157
+ throw error;
158
+ }
159
+ if (stats.isSymbolicLink()) {
160
+ throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
161
+ }
162
+ const isTarget = index === components.length - 1;
163
+ if ((!isTarget && !stats.isDirectory()) || (isTarget && !stats.isFile())) {
164
+ throw new Error(`Refusing to materialize profile file through non-file path: ${targetRelativePath}`);
165
+ }
166
+ }
167
+ }
168
+ function isFsError(error, code) {
169
+ return (typeof error === "object" &&
170
+ error !== null &&
171
+ "code" in error &&
172
+ error.code === code);
173
+ }
174
+ export { assertNoSymlinkPathComponents, isFsError, prepareWorkspaceFile, regularFileContent, resolveWorkspaceFilePath, validateWorkspaceOwnership, workspaceFileConflictMessage, workspaceFileExists, workspaceFileHasOwnership, workspaceFileMatches, workspacePathHasIdentity, };
175
+ import { createHash, randomUUID } from "node:crypto";
176
+ import { closeSync, fchmodSync, fchownSync, fstatSync, lstatSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
177
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
178
+ import { SAFE_WRITE_FLAGS } from "./workspace-files.js";
@@ -1,6 +1,10 @@
1
1
  export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, options, publicPlanDigest = hashWorkspacePlan(publicPlan)) {
2
2
  const workspaceRoot = resolve(workspaceDir);
3
3
  const runtimeOnlyFiles = new Set((options.runtimeOnlyFiles ?? []).map(normalizeWorkspaceRelativePath));
4
+ const ownership = validateWorkspaceOwnership(options.ownership);
5
+ const privateFilePaths = new Set(normalizePlanFiles(publicPlan.files)
6
+ .filter((file) => file.secretSlots.length > 0)
7
+ .map((file) => file.relPath));
4
8
  const existingFiles = options.existingFiles ?? "allow-identical";
5
9
  if (existingFiles !== "allow-identical" && existingFiles !== "reject") {
6
10
  throw new Error(`Unsupported existing workspace file policy: ${existingFiles}`);
@@ -15,17 +19,31 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
15
19
  ...file,
16
20
  absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
17
21
  }));
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" &&
22
+ // A prior application of this exact plan makes the workspace live agent
23
+ // state. Preserve changed and removed files. Refresh private generated files
24
+ // because the public digest excludes secret values, and repair unchanged
25
+ // materializer files that still have the writer's ownership. `reject` does
26
+ // not use this path because it requires an empty target.
27
+ const resumeSamePlan = existingFiles === "allow-identical" &&
27
28
  options.appliedPlanDigest !== undefined &&
28
- options.appliedPlanDigest === publicPlanDigest) {
29
+ options.appliedPlanDigest === publicPlanDigest;
30
+ const filesToApply = resumeSamePlan
31
+ ? files.filter((file) => {
32
+ if (file.ownership !== "materializer")
33
+ return false;
34
+ assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
35
+ if (!workspaceFileExists(file.absPath))
36
+ return false;
37
+ const matches = workspaceFileMatches(file);
38
+ const hasCurrentOwnership = ownership === undefined ||
39
+ workspaceFileHasOwnership(file.absPath, ownership);
40
+ if (privateFilePaths.has(file.relPath)) {
41
+ return !matches || !hasCurrentOwnership;
42
+ }
43
+ return matches && !hasCurrentOwnership;
44
+ })
45
+ : files;
46
+ if (resumeSamePlan && filesToApply.length === 0) {
29
47
  return {
30
48
  receipt: safeWorkspacePlanReceipt(publicPlan, files.map((file) => file.relPath), publicPlanDigest),
31
49
  spawn: {
@@ -37,14 +55,17 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
37
55
  runtimeOnlyFiles: privateRuntimeFiles,
38
56
  };
39
57
  }
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";
58
+ // An owned path holds this package's own earlier output, so re-applying
59
+ // into the same workspace replaces it rather than failing the turn — once
60
+ // the bytes already there prove that provenance. `reject` still refuses:
61
+ // that policy is "nothing may pre-exist here", which is a statement about
62
+ // the workspace, not authorship.
63
+ const mayReplace = (file) => existingFiles === "allow-identical" &&
64
+ file.ownership === "materializer" &&
65
+ isMaterializerOutput(file.relPath, () => regularFileContent(file.absPath));
45
66
  // Validate the complete plan before the first write so a late traversal or
46
67
  // existing symlink cannot leave a partially materialized workspace.
47
- for (const file of files) {
68
+ for (const file of filesToApply) {
48
69
  assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
49
70
  if (workspaceFileExists(file.absPath) &&
50
71
  !mayReplace(file) &&
@@ -54,23 +75,26 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
54
75
  }
55
76
  // Parent directories remain on failure because they may contain concurrent
56
77
  // workspace state that this call does not own.
57
- for (const file of files) {
78
+ for (const file of filesToApply) {
58
79
  mkdirSync(dirname(file.absPath), { recursive: true });
59
80
  }
60
- for (const file of files) {
81
+ for (const file of filesToApply) {
61
82
  assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
62
83
  }
63
84
  const prepared = [];
64
85
  // Prepared temp links stay alive through publication and rollback. Their
65
86
  // inodes distinguish files created here from concurrent replacements.
66
87
  try {
67
- for (const file of files) {
88
+ for (const file of filesToApply) {
68
89
  if (existingFiles === "allow-identical" &&
69
90
  workspaceFileExists(file.absPath) &&
70
- workspaceFileMatches(file)) {
91
+ workspaceFileMatches(file) &&
92
+ (file.ownership !== "materializer" ||
93
+ ownership === undefined ||
94
+ workspaceFileHasOwnership(file.absPath, ownership))) {
71
95
  continue;
72
96
  }
73
- prepared.push(prepareWorkspaceFile(workspaceRoot, file));
97
+ prepared.push(prepareWorkspaceFile(workspaceRoot, file, ownership));
74
98
  }
75
99
  for (const entry of prepared) {
76
100
  assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
@@ -81,7 +105,10 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
81
105
  catch (error) {
82
106
  if (isFsError(error, "EEXIST") &&
83
107
  existingFiles === "allow-identical" &&
84
- workspaceFileMatches(entry.file)) {
108
+ workspaceFileMatches(entry.file) &&
109
+ (entry.file.ownership !== "materializer" ||
110
+ ownership === undefined ||
111
+ workspaceFileHasOwnership(entry.file.absPath, ownership))) {
85
112
  continue;
86
113
  }
87
114
  if (isFsError(error, "EEXIST") && mayReplace(entry.file)) {
@@ -159,157 +186,6 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
159
186
  runtimeOnlyFiles: privateRuntimeFiles,
160
187
  };
161
188
  }
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
189
  export function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
314
190
  return {
315
191
  workspacePlanDigest,
@@ -324,11 +200,12 @@ export function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = ha
324
200
  written: [...written],
325
201
  };
326
202
  }
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";
203
+ import { linkSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
204
+ import { dirname, resolve } from "node:path";
205
+ import { isMaterializerOutput } from "./harness.js";
330
206
  import { hashWorkspacePlan } from "./plan-digest.js";
331
- import { normalizeWorkspacePlanArgument, } from "./plan-normalize.js";
207
+ import { normalizePlanFiles, normalizeWorkspacePlanArgument, } from "./plan-normalize.js";
332
208
  import { promptIntentFields, samePromptIntentValue, systemPromptBindingField, } from "./plan-prompt.js";
333
209
  import { WorkspacePlanCleanupError } from "./plan-types.js";
334
- import { normalizeWorkspaceRelativePath, SAFE_WRITE_FLAGS, } from "./workspace-files.js";
210
+ import { assertNoSymlinkPathComponents, isFsError, prepareWorkspaceFile, regularFileContent, resolveWorkspaceFilePath, validateWorkspaceOwnership, workspaceFileConflictMessage, workspaceFileExists, workspaceFileHasOwnership, workspaceFileMatches, workspacePathHasIdentity, } from "./workspace-apply-files.js";
211
+ import { normalizeWorkspaceRelativePath } from "./workspace-files.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-profile-materialize",
3
- "version": "0.17.0",
3
+ "version": "0.17.3",
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",