@tangle-network/agent-profile-materialize 0.18.1 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,10 @@ export interface AgentCandidateWorkspacePlanReceipt {
17
17
  appendSystemPrompt?: AgentCandidateConfigValue;
18
18
  unsupported: Unsupported[];
19
19
  }
20
+ export interface ApplyAgentCandidateWorkspacePlanOptions {
21
+ /** Absolute private agent directory required by agent-root plan files. */
22
+ agentDir?: string;
23
+ }
20
24
  export interface MaterializeCandidateProfileOptions {
21
25
  /** Exact text fetched from pinned resource refs, keyed by declared SHA-256. */
22
26
  resolvedResources?: ReadonlyMap<Sha256Digest, string>;
@@ -1,9 +1,9 @@
1
1
  import type { AgentCandidateProfile, AgentCandidateProfileApplication } from "@tangle-network/agent-interface";
2
- import type { AgentCandidateWorkspacePlan, AgentCandidateWorkspacePlanReceipt, MaterializeCandidateProfileOptions } from "./candidate-types.js";
2
+ import type { AgentCandidateWorkspacePlan, AgentCandidateWorkspacePlanReceipt, ApplyAgentCandidateWorkspacePlanOptions, MaterializeCandidateProfileOptions } from "./candidate-types.js";
3
3
  import type { HarnessId } from "./harness.js";
4
4
  export declare function materializeCandidateProfile(profile: AgentCandidateProfile, harness: HarnessId, opts?: MaterializeCandidateProfileOptions): AgentCandidateWorkspacePlan;
5
5
  /**
6
6
  * Apply a candidate plan and return its spawn values together with captured
7
7
  * canonical evidence. No caller needs to re-serialize or reinterpret the plan.
8
8
  */
9
- export declare function applyAgentCandidateWorkspacePlan(plan: AgentCandidateWorkspacePlan, workspaceDir: string, targetWorkspace: AgentCandidateProfileApplication["targetWorkspace"]): AgentCandidateWorkspacePlanReceipt;
9
+ export declare function applyAgentCandidateWorkspacePlan(plan: AgentCandidateWorkspacePlan, workspaceDir: string, targetWorkspace: AgentCandidateProfileApplication["targetWorkspace"], options?: ApplyAgentCandidateWorkspacePlanOptions): AgentCandidateWorkspacePlanReceipt;
package/dist/candidate.js CHANGED
@@ -29,6 +29,7 @@ export function materializeCandidateProfile(profile, harness, opts = {}) {
29
29
  };
30
30
  const classicPlan = materializeProfile(classicProfile, harness, {
31
31
  binds: opts.binds,
32
+ ...(harness === "pi" ? { instructionsRoot: "agent" } : {}),
32
33
  });
33
34
  // Canonical candidate evidence has no field for a launcher-addressed binding.
34
35
  // Refuse that shape instead of returning a candidate plan whose receipt would
@@ -58,7 +59,7 @@ export function materializeCandidateProfile(profile, harness, opts = {}) {
58
59
  * Apply a candidate plan and return its spawn values together with captured
59
60
  * canonical evidence. No caller needs to re-serialize or reinterpret the plan.
60
61
  */
61
- export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorkspace) {
62
+ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorkspace, options = {}) {
62
63
  assertWorkspacePlanSupported(plan);
63
64
  const profilePlan = captureAgentCandidateProfilePlan(plan);
64
65
  const activationFiles = normalizePlanFiles(plan.files).map((file) => ({
@@ -85,12 +86,14 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
85
86
  flags: plan.flags.map(candidatePublicString),
86
87
  ...promptIntentFields(plan, candidatePublicString),
87
88
  unsupported: plan.unsupported,
88
- }, workspaceDir);
89
+ }, workspaceDir, { agentDir: options.agentDir });
89
90
  return {
90
91
  profileActivation,
91
92
  application: {
92
93
  planDigest: profilePlan.digest,
93
94
  targetWorkspace,
95
+ // The candidate execution-plan contract lists workspace mounts here.
96
+ // Agent-root placement remains explicit in profile-plan material.files.
94
97
  mountPaths: applied.written,
95
98
  },
96
99
  env: { ...plan.env },
package/dist/harness.d.ts CHANGED
@@ -9,6 +9,8 @@ export declare const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json";
9
9
  export declare const OPENCODE_PRIMARY_AGENT_PREFIX = "tangle-profile-";
10
10
  export declare const CLAUDE_SETTINGS_PATH = ".tangle/claude-settings.json";
11
11
  export declare function harnessAutoLoadsContextFile(harness: HarnessId): boolean;
12
+ /** Whether the shared materializer may place its context file in the harness agent directory. */
13
+ export declare function harnessAutoLoadsAgentContextFile(harness: HarnessId): boolean;
12
14
  /**
13
15
  * The project context file this harness reads from cwd — its stable, documented
14
16
  * ALWAYS-LOADED surface. Exported so a caller lowering additive context (e.g. a
@@ -56,28 +58,74 @@ export declare const CONTEXT_FILE: Record<HarnessId, string>;
56
58
  * bounded by the names being ones no harness or user convention uses.
57
59
  */
58
60
  export declare const MATERIALIZER_RESERVED_PATHS: ReadonlySet<string>;
61
+ /**
62
+ * The context files this package owns by MARKER rather than by name:
63
+ * `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `HERMES.md`.
64
+ *
65
+ * Each is a documented name a repository can already carry, so the name proves
66
+ * nothing. A generated one carries the line {@link markMaterializerContextFile}
67
+ * writes, and only that line lets the next materialization replace it.
68
+ *
69
+ * opencode's context file is absent because it is a RESERVED name this package
70
+ * invented, already owned by the name alone.
71
+ */
72
+ export declare const MATERIALIZER_CONTEXT_PATHS: ReadonlySet<string>;
59
73
  /**
60
74
  * 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.
75
+ * {@link PlanFile.ownership}: the reserved names, the context files, and
76
+ * `opencode.json`, which carries only the registrations this package generates
77
+ * from the profile.
78
+ *
79
+ * `opencode.json` and every context file is a documented name a user repository
80
+ * can carry, so the name alone proves nothing and a profile resource may still
81
+ * mount one. An existing file there is replaced only when
82
+ * {@link isMaterializerOutput} recognizes its bytes as this package's own
83
+ * earlier output.
63
84
  *
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.
85
+ * Deliberately absent: `.gemini/system.md`, the file `prompt.systemPrompt`
86
+ * lowers to. The line a marker would have to add is the reason. A REPLACEMENT
87
+ * is the caller's exact bytes and this package may add none of its own, so that
88
+ * path keeps refusing. A context file carries `prompt.instructions`, which this
89
+ * package already composes — it joins the blocks and appends the final newline
90
+ * — so one more composed line is inside the same authority.
68
91
  *
69
- * Deliberately absent: `.gemini/system.md` (gemini's own documented name),
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.
92
+ * That line, not "which channel the harness reads it on", is what separates the
93
+ * two. `GEMINI.md` is owned even though gemini lowers its context file into the
94
+ * system-prompt position as an ADDITION ({@link appendSystemPromptRefusal}),
95
+ * because the intent it carries is still instructions this package composed.
73
96
  */
74
97
  export declare const MATERIALIZER_OWNED_PATHS: ReadonlySet<string>;
75
98
  /** Mark one generated native agent file without changing its harness meaning. */
76
99
  export declare function markMaterializerSubagent(relPath: string, content: string): string;
100
+ /**
101
+ * Mark one generated context file so the NEXT materialization can prove this
102
+ * package wrote the bytes it is about to replace.
103
+ *
104
+ * The marker is an HTML comment on line 1. CommonMark closes an HTML block at
105
+ * the line holding `-->`, so every instruction below it parses exactly as it
106
+ * did without the line, and the line itself states no instruction. It is one
107
+ * more line this package composes into a file it already composes: it goes on
108
+ * a context file, which carries `prompt.instructions`, and NEVER on a
109
+ * system-prompt REPLACEMENT, whose bytes are the caller's exact text
110
+ * (`.gemini/system.md`; `.codex/system-prompt.md` is a reserved name this
111
+ * package invented and needs no marker to be owned).
112
+ *
113
+ * It carries the digest of everything BELOW it, which the subagent marker does
114
+ * not need. `CLAUDE.md` is claude-code's own memory file and the agent appends
115
+ * to it mid-session, so a bare marker would still sit on line 1 of a file the
116
+ * agent has since rewritten. Ownership has to mean "these are still our bytes",
117
+ * not "this package wrote here once".
118
+ *
119
+ * A reserved context path — opencode's invented instruction file — is returned
120
+ * unchanged: its NAME already proves the provenance, and nothing else may mount
121
+ * there.
122
+ */
123
+ export declare function markMaterializerContextFile(relPath: string, content: string): string;
77
124
  /**
78
125
  * Whether a generated file occupies a path that the profile materializer owns.
79
126
  *
80
- * Generated native agent files come from the active profile.
127
+ * Generated native agent files and generated context files come from the active
128
+ * profile.
81
129
  * The active profile must replace the prior profile version at the same path.
82
130
  * Otherwise, a profile update stops every resumed session before launch.
83
131
  * Explicit resources at these paths stay shared.
@@ -90,9 +138,11 @@ export declare const OPENCODE_CONFIG_KEYS: ReadonlySet<string>;
90
138
  /**
91
139
  * Whether the file already at owned path `relPath` is this package's own
92
140
  * earlier output. A reserved name proves that by itself. Native agent files
93
- * require the generated marker. An exact unmarked copy of the planned file is
94
- * adopted once for upgrades from materializer versions without that marker.
95
- * `undefined` content means no regular file is there.
141
+ * require the generated marker; context files require the marker AND the body
142
+ * digest it commits to, so an agent that appended to its memory file is no
143
+ * longer holding this package's bytes. An exact unmarked copy of the planned
144
+ * file is adopted once for upgrades from materializer versions without that
145
+ * marker. `undefined` content means no regular file is there.
96
146
  *
97
147
  * An `opencode.json` this package wrote holds the opencode schema id, only the
98
148
  * keys this package writes, and only instruction paths this package invented.
package/dist/harness.js CHANGED
@@ -29,6 +29,13 @@ export function harnessAutoLoadsContextFile(harness) {
29
29
  assertMaterializerHarness(harness);
30
30
  return harness !== "opencode";
31
31
  }
32
+ /** Whether the shared materializer may place its context file in the harness agent directory. */
33
+ export function harnessAutoLoadsAgentContextFile(harness) {
34
+ assertMaterializerHarness(harness);
35
+ // Prime uses the fork-specific materializePrimeProfileControls path. It
36
+ // does not consume the shared agent-root plan produced by this predicate.
37
+ return harness === "pi";
38
+ }
32
39
  /**
33
40
  * The project context file this harness reads from cwd — its stable, documented
34
41
  * ALWAYS-LOADED surface. Exported so a caller lowering additive context (e.g. a
@@ -123,27 +130,54 @@ export const MATERIALIZER_RESERVED_PATHS = new Set([
123
130
  OPENCODE_APPEND_SYSTEM_PROMPT_FILE,
124
131
  CONTEXT_FILE.opencode,
125
132
  ]);
133
+ /** Every path any harness reads as its project context file. */
134
+ const CONTEXT_FILE_PATHS = new Set(Object.values(CONTEXT_FILE));
135
+ /**
136
+ * The context files this package owns by MARKER rather than by name:
137
+ * `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `HERMES.md`.
138
+ *
139
+ * Each is a documented name a repository can already carry, so the name proves
140
+ * nothing. A generated one carries the line {@link markMaterializerContextFile}
141
+ * writes, and only that line lets the next materialization replace it.
142
+ *
143
+ * opencode's context file is absent because it is a RESERVED name this package
144
+ * invented, already owned by the name alone.
145
+ */
146
+ export const MATERIALIZER_CONTEXT_PATHS = new Set([...CONTEXT_FILE_PATHS].filter((path) => !MATERIALIZER_RESERVED_PATHS.has(path)));
126
147
  /**
127
148
  * 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.
149
+ * {@link PlanFile.ownership}: the reserved names, the context files, and
150
+ * `opencode.json`, which carries only the registrations this package generates
151
+ * from the profile.
152
+ *
153
+ * `opencode.json` and every context file is a documented name a user repository
154
+ * can carry, so the name alone proves nothing and a profile resource may still
155
+ * mount one. An existing file there is replaced only when
156
+ * {@link isMaterializerOutput} recognizes its bytes as this package's own
157
+ * earlier output.
130
158
  *
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.
159
+ * Deliberately absent: `.gemini/system.md`, the file `prompt.systemPrompt`
160
+ * lowers to. The line a marker would have to add is the reason. A REPLACEMENT
161
+ * is the caller's exact bytes and this package may add none of its own, so that
162
+ * path keeps refusing. A context file carries `prompt.instructions`, which this
163
+ * package already composes — it joins the blocks and appends the final newline
164
+ * — so one more composed line is inside the same authority.
135
165
  *
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.
166
+ * That line, not "which channel the harness reads it on", is what separates the
167
+ * two. `GEMINI.md` is owned even though gemini lowers its context file into the
168
+ * system-prompt position as an ADDITION ({@link appendSystemPromptRefusal}),
169
+ * because the intent it carries is still instructions this package composed.
140
170
  */
141
171
  export const MATERIALIZER_OWNED_PATHS = new Set([
142
172
  ...MATERIALIZER_RESERVED_PATHS,
173
+ ...MATERIALIZER_CONTEXT_PATHS,
143
174
  OPENCODE_CONFIG_FILE,
144
175
  ]);
145
176
  const MATERIALIZER_SUBAGENT_PATH = /^(?:(?:\.claude|\.opencode|\.gemini)\/agents\/[a-z0-9][a-z0-9_-]*\.md|\.codex\/agents\/[a-z0-9][a-z0-9_-]*\.toml)$/u;
146
- const MATERIALIZER_SUBAGENT_MARKER = "# tangle-agent-profile-materialize: generated";
177
+ /** The one provenance token every generated-file marker carries. */
178
+ const MATERIALIZER_MARKER = "tangle-agent-profile-materialize: generated";
179
+ const MATERIALIZER_SUBAGENT_MARKER = `# ${MATERIALIZER_MARKER}`;
180
+ const MATERIALIZER_CONTEXT_MARKER_NOTE = "do not edit; the next profile materialization replaces this file";
147
181
  function materializerSubagentMarkerPrefix(relPath) {
148
182
  return relPath.endsWith(".toml")
149
183
  ? `${MATERIALIZER_SUBAGENT_MARKER}\n`
@@ -162,10 +196,58 @@ export function markMaterializerSubagent(relPath, content) {
162
196
  }
163
197
  return `${materializerSubagentMarkerPrefix(relPath)}${content.slice(4)}`;
164
198
  }
199
+ /** The marker line a generated context file whose body is `body` carries. */
200
+ function materializerContextMarker(body) {
201
+ const digest = createHash("sha256").update(body, "utf8").digest("hex");
202
+ return `<!-- ${MATERIALIZER_MARKER} sha256:${digest}; ${MATERIALIZER_CONTEXT_MARKER_NOTE} -->`;
203
+ }
204
+ /**
205
+ * Mark one generated context file so the NEXT materialization can prove this
206
+ * package wrote the bytes it is about to replace.
207
+ *
208
+ * The marker is an HTML comment on line 1. CommonMark closes an HTML block at
209
+ * the line holding `-->`, so every instruction below it parses exactly as it
210
+ * did without the line, and the line itself states no instruction. It is one
211
+ * more line this package composes into a file it already composes: it goes on
212
+ * a context file, which carries `prompt.instructions`, and NEVER on a
213
+ * system-prompt REPLACEMENT, whose bytes are the caller's exact text
214
+ * (`.gemini/system.md`; `.codex/system-prompt.md` is a reserved name this
215
+ * package invented and needs no marker to be owned).
216
+ *
217
+ * It carries the digest of everything BELOW it, which the subagent marker does
218
+ * not need. `CLAUDE.md` is claude-code's own memory file and the agent appends
219
+ * to it mid-session, so a bare marker would still sit on line 1 of a file the
220
+ * agent has since rewritten. Ownership has to mean "these are still our bytes",
221
+ * not "this package wrote here once".
222
+ *
223
+ * A reserved context path — opencode's invented instruction file — is returned
224
+ * unchanged: its NAME already proves the provenance, and nothing else may mount
225
+ * there.
226
+ */
227
+ export function markMaterializerContextFile(relPath, content) {
228
+ if (!CONTEXT_FILE_PATHS.has(relPath)) {
229
+ throw new Error(`Not a harness context file: ${relPath}`);
230
+ }
231
+ if (!MATERIALIZER_CONTEXT_PATHS.has(relPath))
232
+ return content;
233
+ return `${materializerContextMarker(content)}\n${content}`;
234
+ }
235
+ /**
236
+ * Whether `existing` is a context file this package wrote and nothing edited
237
+ * afterwards: line 1 is exactly the marker computed over every byte below it.
238
+ */
239
+ function isMarkedContextOutput(existing) {
240
+ const lineEnd = existing.indexOf("\n");
241
+ if (lineEnd === -1)
242
+ return false;
243
+ return (existing.slice(0, lineEnd) ===
244
+ materializerContextMarker(existing.slice(lineEnd + 1)));
245
+ }
165
246
  /**
166
247
  * Whether a generated file occupies a path that the profile materializer owns.
167
248
  *
168
- * Generated native agent files come from the active profile.
249
+ * Generated native agent files and generated context files come from the active
250
+ * profile.
169
251
  * The active profile must replace the prior profile version at the same path.
170
252
  * Otherwise, a profile update stops every resumed session before launch.
171
253
  * Explicit resources at these paths stay shared.
@@ -187,9 +269,11 @@ export const OPENCODE_CONFIG_KEYS = new Set([
187
269
  /**
188
270
  * Whether the file already at owned path `relPath` is this package's own
189
271
  * earlier output. A reserved name proves that by itself. Native agent files
190
- * require the generated marker. An exact unmarked copy of the planned file is
191
- * adopted once for upgrades from materializer versions without that marker.
192
- * `undefined` content means no regular file is there.
272
+ * require the generated marker; context files require the marker AND the body
273
+ * digest it commits to, so an agent that appended to its memory file is no
274
+ * longer holding this package's bytes. An exact unmarked copy of the planned
275
+ * file is adopted once for upgrades from materializer versions without that
276
+ * marker. `undefined` content means no regular file is there.
193
277
  *
194
278
  * An `opencode.json` this package wrote holds the opencode schema id, only the
195
279
  * keys this package writes, and only instruction paths this package invented.
@@ -216,6 +300,23 @@ export function isMaterializerOutput(relPath, content, plannedContent) {
216
300
  : `---\n${plannedContent.slice(markerPrefix.length)}`;
217
301
  return existing === legacyContent;
218
302
  }
303
+ if (MATERIALIZER_CONTEXT_PATHS.has(relPath)) {
304
+ const existing = content();
305
+ if (existing === undefined)
306
+ return false;
307
+ if (isMarkedContextOutput(existing))
308
+ return true;
309
+ // A workspace materialized before this marker existed carries the body
310
+ // alone. Adopt it once, and only when it is byte-identical to the body this
311
+ // plan writes, so upgrading the package does not turn every resumed session
312
+ // into a refusal. A body that differs is either a user's file or an earlier
313
+ // profile's, and neither can be told apart without the marker.
314
+ if (plannedContent === undefined)
315
+ return false;
316
+ if (!isMarkedContextOutput(plannedContent))
317
+ return false;
318
+ return existing === plannedContent.slice(plannedContent.indexOf("\n") + 1);
319
+ }
219
320
  if (relPath !== OPENCODE_CONFIG_FILE)
220
321
  return false;
221
322
  const bytes = content();
@@ -280,3 +381,4 @@ export function skillDirForHarness(harness) {
280
381
  assertMaterializerHarness(harness);
281
382
  return HARNESS_SKILL_DIRS[harness];
282
383
  }
384
+ import { createHash } from "node:crypto";
package/dist/index.d.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  /** Stable public facade for profile materialization and workspace application. */
2
2
  export { applyAgentCandidateWorkspacePlan, materializeCandidateProfile, } from "./candidate.js";
3
- export type { AgentCandidateWorkspacePlan, AgentCandidateWorkspacePlanReceipt, MaterializeCandidateProfileOptions, } from "./candidate-types.js";
3
+ export type { AgentCandidateWorkspacePlan, AgentCandidateWorkspacePlanReceipt, ApplyAgentCandidateWorkspacePlanOptions, MaterializeCandidateProfileOptions, } from "./candidate-types.js";
4
4
  export { OPENCODE_NATIVE_TOOL_IDS, openCodeNativeToolId, } from "./control-maps.js";
5
5
  export type { HarnessId } from "./harness.js";
6
- export { HARNESS_SKILL_DIRS, harnessAutoLoadsContextFile, harnessContextFile, isMaterializerHarness, MATERIALIZER_HARNESSES, skillDirForHarness, } from "./harness.js";
6
+ export { HARNESS_SKILL_DIRS, harnessAutoLoadsAgentContextFile, harnessAutoLoadsContextFile, harnessContextFile, isMaterializerHarness, MATERIALIZER_CONTEXT_PATHS, MATERIALIZER_HARNESSES, markMaterializerContextFile, skillDirForHarness, } from "./harness.js";
7
7
  export type { OpenCodeMcpConfig } from "./mcp.js";
8
8
  export { openCodeMcpConfig } from "./mcp.js";
9
9
  export { modelIdsMatch, qualifyModelId, unqualifyModelId, } from "./model-id.js";
10
- export { buildPiModelsConfig, buildPiModelsConfigFile, PI_MODELS_CONFIG_PATH, type PiModelsConfigFile, type PiModelsConfigInput, } from "./pi-models-config.js";
10
+ export { buildPiModelsConfig, buildPiModelsConfigFile, PI_MODELS_CONFIG_PATH, type PiModelsConfigFile, type PiModelsConfigInput, type PiRuntimePaths, resolvePiRuntimePaths, } 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";
13
13
  export { takeSystemPromptBinding } from "./plan-prompt.js";
14
14
  export { assertWorkspacePlanSupported, enforceWorkspacePlanSupport, } from "./plan-support.js";
15
- export type { ApplyWorkspacePlanOptions, BindableLaunchControl, ExecutorSystemPromptBinding, PlanFile, PlanFileSecretSlot, PlanRequirement, SkippableDimension, Unsupported, WorkspacePlan, WorkspacePlanApplication, WorkspacePlanArgument, WorkspacePlanArgumentTemplate, WorkspacePlanConfigValue, WorkspacePlanReceipt, WorkspacePlanSecretSegment, WorkspacePlanSpawnInputs, WorkspacePlanSystemPromptBinding, } from "./plan-types.js";
15
+ export type { ApplyWorkspacePlanOptions, BindableLaunchControl, ExecutorSystemPromptBinding, PlanFile, PlanFileRoot, PlanFileSecretSlot, PlanRequirement, SkippableDimension, Unsupported, WorkspacePlan, WorkspacePlanApplication, WorkspacePlanArgument, WorkspacePlanArgumentTemplate, WorkspacePlanConfigValue, WorkspacePlanReceipt, WorkspacePlanSecretSegment, WorkspacePlanSpawnInputs, WorkspacePlanSystemPromptBinding, WorkspacePlanWrittenFile, } from "./plan-types.js";
16
16
  export { BINDABLE_LAUNCH_CONTROLS, EXECUTOR_SYSTEM_PROMPT_BINDINGS, isExecutorSystemPromptBinding, isSkippableDimension, SKIPPABLE_DIMENSIONS, WorkspacePlanCleanupError, } from "./plan-types.js";
17
17
  export type { PrimeMaterializeOptions } from "./prime-agent-dir.js";
18
18
  export { materializePrimeProfileControls, prunePrimeProfileMaterial, seedPrimeProfileSubagents, writePrimeAgentFile, } from "./prime-agent-dir.js";
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /** Stable public facade for profile materialization and workspace application. */
2
2
  export { applyAgentCandidateWorkspacePlan, materializeCandidateProfile, } from "./candidate.js";
3
3
  export { OPENCODE_NATIVE_TOOL_IDS, openCodeNativeToolId, } from "./control-maps.js";
4
- export { HARNESS_SKILL_DIRS, harnessAutoLoadsContextFile, harnessContextFile, isMaterializerHarness, MATERIALIZER_HARNESSES, skillDirForHarness, } from "./harness.js";
4
+ export { HARNESS_SKILL_DIRS, harnessAutoLoadsAgentContextFile, harnessAutoLoadsContextFile, harnessContextFile, isMaterializerHarness, MATERIALIZER_CONTEXT_PATHS, MATERIALIZER_HARNESSES, markMaterializerContextFile, skillDirForHarness, } from "./harness.js";
5
5
  export { openCodeMcpConfig } from "./mcp.js";
6
6
  export { modelIdsMatch, qualifyModelId, unqualifyModelId, } from "./model-id.js";
7
- export { buildPiModelsConfig, buildPiModelsConfigFile, PI_MODELS_CONFIG_PATH, } from "./pi-models-config.js";
7
+ export { buildPiModelsConfig, buildPiModelsConfigFile, PI_MODELS_CONFIG_PATH, resolvePiRuntimePaths, } 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";
10
10
  export { takeSystemPromptBinding } from "./plan-prompt.js";
@@ -1,9 +1,3 @@
1
- /**
2
- * One custom-model shape for upstream Pi and the Prime fork.
3
- *
4
- * Both agents read the same `models.json` provider schema.
5
- * Callers own the file lifecycle, but they must not build different payloads.
6
- */
7
1
  export interface PiModelsConfigInput {
8
2
  /** Provider key in models.json and the matching `--provider` value. */
9
3
  providerName: string;
@@ -25,6 +19,12 @@ export interface PiModelsConfigFile {
25
19
  mode: number;
26
20
  }
27
21
  export declare const PI_MODELS_CONFIG_PATH = ".pi/agent/models.json";
22
+ export interface PiRuntimePaths {
23
+ agentDir: string;
24
+ sessionDir: string;
25
+ }
26
+ /** Resolve Pi's private roots from the one runtime HOME supplied to Pi. */
27
+ export declare function resolvePiRuntimePaths(runtimeHome: string): PiRuntimePaths;
28
28
  export declare function buildPiModelsConfig(input: PiModelsConfigInput): Record<string, unknown>;
29
29
  /** Build the owner-only Pi file used by headless and interactive processes. */
30
30
  export declare function buildPiModelsConfigFile(input: PiModelsConfigInput): PiModelsConfigFile;
@@ -1,4 +1,20 @@
1
+ /**
2
+ * One custom-model shape for upstream Pi and the Prime fork.
3
+ *
4
+ * Both agents read the same `models.json` provider schema.
5
+ * Callers own the file lifecycle, but they must not build different payloads.
6
+ */
7
+ import path from "node:path";
1
8
  export const PI_MODELS_CONFIG_PATH = ".pi/agent/models.json";
9
+ /** Resolve Pi's private roots from the one runtime HOME supplied to Pi. */
10
+ export function resolvePiRuntimePaths(runtimeHome) {
11
+ const home = path.resolve(runtimeHome);
12
+ const agentDir = path.dirname(path.join(home, PI_MODELS_CONFIG_PATH));
13
+ return {
14
+ agentDir,
15
+ sessionDir: path.join(agentDir, "sessions"),
16
+ };
17
+ }
2
18
  const DEFAULT_CONTEXT_WINDOW = 128_000;
3
19
  export function buildPiModelsConfig(input) {
4
20
  for (const [field, value] of [
@@ -2,6 +2,7 @@ export const sha256 = (value) => createHash("sha256").update(value, "utf8").dige
2
2
  export function hashWorkspacePlan(plan) {
3
3
  const files = normalizePlanFiles(plan.files).map((file) => ({
4
4
  relPath: file.relPath,
5
+ ...(file.root === "workspace" ? {} : { root: file.root }),
5
6
  mode: file.mode,
6
7
  contentSha256: sha256(file.content),
7
8
  // Ownership decides whether re-application may replace the file, so two
@@ -42,20 +43,35 @@ export function hashWorkspacePlan(plan) {
42
43
  }
43
44
  /** Build the exact digest-free identity document required by agent-interface. */
44
45
  export function agentCandidateProfilePlanMaterial(plan) {
46
+ const files = normalizePlanFiles(plan.files);
47
+ const materialFiles = files.map((file) => ({
48
+ relPath: file.relPath,
49
+ mode: file.mode,
50
+ contentSha256: `sha256:${sha256(file.content)}`,
51
+ ...(file.root === "agent" ? { root: "agent" } : {}),
52
+ }));
45
53
  const material = {
46
54
  sourceProfileDigest: plan.sourceProfileDigest,
47
55
  harness: plan.harness,
48
- files: normalizePlanFiles(plan.files).map((file) => ({
49
- relPath: file.relPath,
50
- mode: file.mode,
51
- contentSha256: `sha256:${sha256(file.content)}`,
52
- })),
56
+ // Interface 1 has the same candidate material shape without the optional
57
+ // root field. Keep the local file model richer, then validate against the
58
+ // installed peer schema at the compatibility boundary.
59
+ files: materialFiles,
53
60
  env: Object.fromEntries(Object.entries(plan.env).sort(([left], [right]) => compareStrings(left, right))),
54
61
  flags: [...plan.flags],
55
62
  ...promptIntentFields(plan, samePromptIntentValue),
56
63
  unsupported: [...plan.unsupported].sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`)),
57
64
  };
58
- return agentCandidateProfilePlanMaterialSchema.parse(material);
65
+ const hasAgentRoot = materialFiles.some((file) => file.root === "agent");
66
+ if (!hasAgentRoot) {
67
+ return agentCandidateProfilePlanMaterialSchema.parse(material);
68
+ }
69
+ const parsed = agentCandidateProfilePlanMaterialSchema.safeParse(material);
70
+ if (!parsed.success) {
71
+ throw new Error("Agent-root candidate evidence requires @tangle-network/agent-interface >=2.1.1; " +
72
+ 'the installed peer schema cannot preserve root:"agent". Upgrade the peer before applying this candidate plan.');
73
+ }
74
+ return parsed.data;
59
75
  }
60
76
  /**
61
77
  * Return the RFC 8785 UTF-8 bytes and SHA-256 identity of a candidate profile
@@ -2,12 +2,17 @@ export declare function mergeJsonFiles(files: PlanFile[]): PlanFile[];
2
2
  export interface NormalizedPlanFile {
3
3
  relPath: string;
4
4
  content: string;
5
+ root: PlanFileRoot;
5
6
  mode: number;
6
7
  ownership: NonNullable<PlanFile["ownership"]>;
7
8
  secretSlots: PlanFileSecretSlot[];
8
9
  }
9
- export declare function valueAtJsonPath(root: unknown, path: readonly (string | number)[]): unknown;
10
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
11
+ [key: string]: JsonValue;
12
+ };
13
+ export declare function normalizePlanFileRoot(root: PlanFileRoot | undefined): PlanFileRoot;
14
+ export declare function valueAtJsonPath(root: JsonValue, path: readonly (string | number)[]): JsonValue | undefined;
10
15
  export declare function normalizePlanFileSecretSlots(file: PlanFile): PlanFileSecretSlot[];
11
16
  export declare function normalizePlanFiles(files: PlanFile[]): NormalizedPlanFile[];
12
17
  export declare function normalizeWorkspacePlanArgument(value: WorkspacePlanArgument): WorkspacePlanArgument;
13
- import type { PlanFile, PlanFileSecretSlot, WorkspacePlanArgument } from "./plan-types.js";
18
+ import type { PlanFile, PlanFileRoot, PlanFileSecretSlot, WorkspacePlanArgument } from "./plan-types.js";
@@ -1,9 +1,11 @@
1
1
  export function mergeJsonFiles(files) {
2
2
  const byPath = new Map();
3
3
  for (const f of files) {
4
- const prev = byPath.get(f.relPath);
4
+ const root = normalizePlanFileRoot(f.root);
5
+ const identity = `${root}\0${f.relPath}`;
6
+ const prev = byPath.get(identity);
5
7
  if (!prev) {
6
- byPath.set(f.relPath, f);
8
+ byPath.set(identity, f);
7
9
  continue;
8
10
  }
9
11
  if (f.relPath.endsWith(".json")) {
@@ -20,7 +22,7 @@ export function mergeJsonFiles(files) {
20
22
  ...(f.secretSlots ?? []),
21
23
  ];
22
24
  const { secretSlots: _previousSecretSlots, ...previousFile } = prev;
23
- byPath.set(f.relPath, {
25
+ byPath.set(identity, {
24
26
  ...previousFile,
25
27
  content: JSON.stringify(merged, null, 2),
26
28
  ...(secretSlots.length > 0 ? { mode: PRIVATE_FILE_MODE } : {}),
@@ -32,10 +34,17 @@ export function mergeJsonFiles(files) {
32
34
  /* fall through to last-wins */
33
35
  }
34
36
  }
35
- byPath.set(f.relPath, f); // non-JSON or parse-fail: last wins
37
+ byPath.set(identity, f); // non-JSON or parse-fail: last wins
36
38
  }
37
39
  return [...byPath.values()];
38
40
  }
41
+ export function normalizePlanFileRoot(root) {
42
+ if (root === undefined || root === "workspace")
43
+ return "workspace";
44
+ if (root === "agent")
45
+ return root;
46
+ throw new Error(`Unsupported workspace plan file root: ${String(root)}`);
47
+ }
39
48
  export function valueAtJsonPath(root, path) {
40
49
  let current = root;
41
50
  for (const segment of path) {
@@ -94,11 +103,13 @@ export function normalizePlanFiles(files) {
94
103
  const seen = new Set();
95
104
  return files
96
105
  .map((file) => {
106
+ const root = normalizePlanFileRoot(file.root);
97
107
  const relPath = normalizeWorkspaceRelativePath(file.relPath);
98
- if (seen.has(relPath)) {
99
- throw new Error(`Duplicate workspace plan file: ${relPath}`);
108
+ const identity = `${root}\0${relPath}`;
109
+ if (seen.has(identity)) {
110
+ throw new Error(`Duplicate ${root} plan file: ${relPath}`);
100
111
  }
101
- seen.add(relPath);
112
+ seen.add(identity);
102
113
  if (typeof file.content !== "string") {
103
114
  throw new Error(`Workspace file content must be a string: ${relPath}`);
104
115
  }
@@ -110,12 +121,13 @@ export function normalizePlanFiles(files) {
110
121
  return {
111
122
  relPath,
112
123
  content: file.content,
124
+ root,
113
125
  mode,
114
126
  ownership: file.ownership ?? "shared",
115
127
  secretSlots,
116
128
  };
117
129
  })
118
- .sort((a, b) => compareStrings(a.relPath, b.relPath));
130
+ .sort((a, b) => compareStrings(`${a.root}\0${a.relPath}`, `${b.root}\0${b.relPath}`));
119
131
  }
120
132
  export function normalizeWorkspacePlanArgument(value) {
121
133
  if (typeof value === "string")
@@ -1,7 +1,11 @@
1
1
  export type { HarnessId } from "./harness.js";
2
+ /** The executor-owned root that receives a materialized file. */
3
+ export type PlanFileRoot = "workspace" | "agent";
2
4
  export interface PlanFile {
3
5
  relPath: string;
4
6
  content: string;
7
+ /** Defaults to the run workspace. `agent` is the harness's private agent directory. */
8
+ root?: PlanFileRoot;
5
9
  /** chmod mode (e.g. 0o755 for hook scripts); default 0o644. */
6
10
  mode?: number;
7
11
  /**
@@ -29,19 +33,23 @@ export interface PlanFile {
29
33
  * replaced.
30
34
  *
31
35
  * `"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
+ * package's output: a name this package invented, the generated context file
37
+ * (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `HERMES.md`), or `opencode.json`,
38
+ * which carries only the registrations generated from the profile. A file
39
+ * already there is this materializer's own output from an earlier application
40
+ * into the same workspace, and replacing it is how turn two of a resumed
41
+ * session — or the first turn of a branched one — gets the current profile
42
+ * instead of the previous one. Application replaces it only when the existing
43
+ * bytes prove that provenance — by the name for an invented path, by a marker
44
+ * line committing to the body digest for a context file, by the content for
45
+ * `opencode.json`.
39
46
  *
40
47
  * `"shared"` (the default, and every other file) = a path the user or the
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.
48
+ * harness also owns. `.gemini/system.md` is a documented, user-authorable
49
+ * name whose content this package cannot tell from its own, and so is a
50
+ * context file mounted as an explicit profile resource rather than generated.
51
+ * An existing file there may be the caller's own work, so it is never
52
+ * replaced.
45
53
  *
46
54
  * Optional because hand-constructed plans have no path provenance to report;
47
55
  * absent reads as `"shared"`, the safe answer.
@@ -114,6 +122,11 @@ export interface WorkspacePlanSystemPromptBinding {
114
122
  /** Exact text the control receives. */
115
123
  systemPrompt: string;
116
124
  }
125
+ /** One file applied by a plan, with the executor root kept explicit. */
126
+ export interface WorkspacePlanWrittenFile {
127
+ root: PlanFileRoot;
128
+ relPath: string;
129
+ }
117
130
  /**
118
131
  * The dimensions `materializeProfile`'s `skip` option honors.
119
132
  *
@@ -254,7 +267,14 @@ export interface WorkspacePlanReceipt {
254
267
  /** The launcher-bound intent, so a receipt says which control owed the work. */
255
268
  systemPromptBinding?: WorkspacePlanSystemPromptBinding;
256
269
  unsupported: Unsupported[];
270
+ /**
271
+ * Legacy workspace-root paths for existing workspace-only consumers.
272
+ * Agent-root files are available in `writtenFiles` and are never encoded as
273
+ * an `agent:` path because that spelling is legal in the workspace root.
274
+ */
257
275
  written: string[];
276
+ /** Every applied file, including its executor-owned root. */
277
+ writtenFiles: WorkspacePlanWrittenFile[];
258
278
  }
259
279
  /** Private values handed directly to the provider spawn, never to receipts. */
260
280
  export interface WorkspacePlanSpawnInputs {
@@ -281,6 +301,8 @@ export declare class WorkspacePlanCleanupError extends AggregateError {
281
301
  export interface ApplyWorkspacePlanOptions {
282
302
  /** Plan files consumed in memory by the runtime and never persisted to cwd. */
283
303
  runtimeOnlyFiles?: readonly string[];
304
+ /** Absolute private agent directory required by files whose root is `agent`. */
305
+ agentDir?: string;
284
306
  /** Own newly written workspace files as the process that will read them. */
285
307
  ownership?: {
286
308
  uid: number;
@@ -1,6 +1,6 @@
1
1
  import type { AgentProfile, AgentProfileSecretProvider } from "@tangle-network/agent-interface";
2
2
  import { type HarnessId } from "./harness.js";
3
- import { type BindableLaunchControl, type SkippableDimension, type WorkspacePlan } from "./plan-types.js";
3
+ import { type BindableLaunchControl, type PlanFileRoot, type SkippableDimension, type WorkspacePlan } from "./plan-types.js";
4
4
  export declare const UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER: AgentProfileSecretProvider;
5
5
  /**
6
6
  * Build a pure workspace plan for one canonical harness.
@@ -9,4 +9,5 @@ export declare const UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER: AgentProfileSecr
9
9
  export declare function materializeProfile(inputProfile: AgentProfile, harness: HarnessId, opts?: {
10
10
  skip?: SkippableDimension[];
11
11
  binds?: readonly BindableLaunchControl[];
12
+ instructionsRoot?: PlanFileRoot;
12
13
  }): WorkspacePlan;
@@ -1,6 +1,6 @@
1
1
  import { agentProfileSchema } from "@tangle-network/agent-interface";
2
2
  import { materializeExecutionControls } from "./execution-controls.js";
3
- import { assertMaterializerHarness } from "./harness.js";
3
+ import { assertMaterializerHarness, harnessAutoLoadsAgentContextFile, } from "./harness.js";
4
4
  import { materializeHooks } from "./hooks.js";
5
5
  import { materializeMcp } from "./mcp.js";
6
6
  import { assertBindableLaunchControls, assertSkippableDimensions, } from "./plan-types.js";
@@ -23,9 +23,17 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
23
23
  const profile = agentProfileSchema.parse(inputProfile);
24
24
  const skip = new Set(assertSkippableDimensions(opts.skip));
25
25
  const binds = assertBindableLaunchControls(opts.binds);
26
+ const instructionsRoot = opts.instructionsRoot ?? "workspace";
27
+ if (instructionsRoot !== "workspace" && instructionsRoot !== "agent") {
28
+ throw new Error(`materializeProfile: unsupported instructions root ${String(instructionsRoot)}`);
29
+ }
30
+ if (instructionsRoot === "agent" &&
31
+ !harnessAutoLoadsAgentContextFile(harness)) {
32
+ throw new Error(`materializeProfile: harness "${harness}" does not load instructions from its agent directory`);
33
+ }
26
34
  const builder = createProfilePlanBuilder(harness);
27
35
  lowerProfileResources(profile, harness, builder);
28
- lowerProfilePrompt(profile, harness, binds, builder);
36
+ lowerProfilePrompt(profile, harness, binds, instructionsRoot, builder);
29
37
  materializeExecutionControls(harness, profile.tools, profile.permissions, profile.modes, builder.plan, builder.add, builder.unsupported);
30
38
  if ((profile.connections?.length ?? 0) > 0) {
31
39
  builder.unsupported("connections", `${harness}: Hub connections must be resolved to authorized MCP servers by the execution runtime`);
@@ -1,14 +1,16 @@
1
1
  import type { AgentProfileConfigValue } from "@tangle-network/agent-interface";
2
2
  import type { HarnessId } from "./harness.js";
3
- import type { AddPlanFile } from "./plan-json.js";
4
- import type { Unsupported, WorkspacePlan } from "./plan-types.js";
3
+ import type { AddPlanFile, SerializedPlanFileContent } from "./plan-json.js";
4
+ import type { PlanFileRoot, Unsupported, WorkspacePlan } from "./plan-types.js";
5
5
  export interface ProfilePlanBuilder {
6
6
  plan: WorkspacePlan;
7
7
  add: AddPlanFile;
8
+ addAtRoot: AddRootedPlanFile;
8
9
  addExplicitResource: AddExplicitResource;
9
10
  unsupported: (dimension: Unsupported["dimension"], reason: string) => void;
10
11
  finalize: (failOnError?: boolean) => WorkspacePlan;
11
12
  }
13
+ export type AddRootedPlanFile = (root: PlanFileRoot, path: string, content: string | SerializedPlanFileContent, mode?: number) => void;
12
14
  export type AddExplicitResource = (dimension: Unsupported["dimension"], path: string, content: string, mode?: number) => void;
13
15
  export declare function createProfilePlanBuilder(harness: HarnessId): ProfilePlanBuilder;
14
16
  export declare function setPlanEnvironment(plan: WorkspacePlan, name: string, value: string | AgentProfileConfigValue): void;
@@ -12,7 +12,7 @@ export function createProfilePlanBuilder(harness) {
12
12
  unsupported: [],
13
13
  requires: [],
14
14
  };
15
- const pushPlanFile = (relPath, serialized, source, mode) => {
15
+ const pushPlanFile = (relPath, serialized, source, mode, root = "workspace") => {
16
16
  const normalizedPath = normalizeWorkspaceRelativePath(relPath);
17
17
  const content = typeof serialized === "string" ? serialized : serialized.content;
18
18
  const secretSlots = typeof serialized === "string" ? undefined : serialized.secretSlots;
@@ -24,6 +24,7 @@ export function createProfilePlanBuilder(harness) {
24
24
  relPath: normalizedPath,
25
25
  content,
26
26
  source,
27
+ ...(root === "workspace" ? {} : { root }),
27
28
  ...(source === "generated" && isMaterializerOwnedPath(normalizedPath)
28
29
  ? { ownership: "materializer" }
29
30
  : {}),
@@ -39,6 +40,7 @@ export function createProfilePlanBuilder(harness) {
39
40
  });
40
41
  };
41
42
  const add = (relPath, content, mode) => pushPlanFile(relPath, content, "generated", mode);
43
+ const addAtRoot = (root, relPath, content, mode) => pushPlanFile(relPath, content, "generated", mode, root);
42
44
  const unsupported = (dimension, reason) => plan.unsupported.push({ dimension, reason });
43
45
  const explicitResourcePaths = new Map();
44
46
  const addExplicitResource = (dimension, relPath, content, mode) => {
@@ -58,16 +60,18 @@ export function createProfilePlanBuilder(harness) {
58
60
  return {
59
61
  plan,
60
62
  add,
63
+ addAtRoot,
61
64
  addExplicitResource,
62
65
  unsupported,
63
66
  finalize(failOnError) {
64
67
  for (const [relPath, dimension] of explicitResourcePaths) {
65
- const writers = plan.files.filter((file) => file.relPath === relPath).length;
68
+ const writers = plan.files.filter((file) => (file.root ?? "workspace") === "workspace" &&
69
+ file.relPath === relPath).length;
66
70
  if (writers > 1) {
67
71
  throw new Error(`Profile ${dimension} resource conflicts with generated harness file: ${relPath}`);
68
72
  }
69
73
  }
70
- plan.files = mergeJsonFiles(plan.files).sort((a, b) => compareStrings(a.relPath, b.relPath));
74
+ plan.files = mergeJsonFiles(plan.files).sort((a, b) => compareStrings(`${a.root ?? "workspace"}\0${a.relPath}`, `${b.root ?? "workspace"}\0${b.relPath}`));
71
75
  if (failOnError && plan.unsupported.length) {
72
76
  throw new Error(formatUnsupported(plan.unsupported));
73
77
  }
@@ -1,4 +1,4 @@
1
1
  import type { AgentProfile } from "@tangle-network/agent-interface";
2
- import type { BindableLaunchControl, HarnessId } from "./plan-types.js";
2
+ import type { BindableLaunchControl, HarnessId, PlanFileRoot } from "./plan-types.js";
3
3
  import type { ProfilePlanBuilder } from "./profile-plan.js";
4
- export declare function lowerProfilePrompt(profile: AgentProfile, harness: HarnessId, binds: ReadonlySet<BindableLaunchControl>, builder: ProfilePlanBuilder): void;
4
+ export declare function lowerProfilePrompt(profile: AgentProfile, harness: HarnessId, binds: ReadonlySet<BindableLaunchControl>, instructionsRoot: PlanFileRoot, builder: ProfilePlanBuilder): void;
@@ -1,8 +1,8 @@
1
- import { appendSystemPromptRefusal, CODEX_SYSTEM_PROMPT_FILE, CONTEXT_FILE, markMaterializerSubagent, OPENCODE_APPEND_SYSTEM_PROMPT_FILE, OPENCODE_CONFIG_FILE, OPENCODE_CONFIG_SCHEMA, OPENCODE_PRIMARY_AGENT_PREFIX, systemPromptRefusal, } from "./harness.js";
1
+ import { appendSystemPromptRefusal, CODEX_SYSTEM_PROMPT_FILE, CONTEXT_FILE, markMaterializerContextFile, markMaterializerSubagent, 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
- export function lowerProfilePrompt(profile, harness, binds, builder) {
5
- const { plan, add, unsupported } = builder;
4
+ export function lowerProfilePrompt(profile, harness, binds, instructionsRoot, builder) {
5
+ const { plan, add, addAtRoot, unsupported } = builder;
6
6
  if (profile.prompt?.systemPrompt !== undefined) {
7
7
  if (harness === "claude-code" || harness === "pi" || harness === "prime") {
8
8
  plan.systemPrompt = profile.prompt.systemPrompt;
@@ -90,7 +90,7 @@ export function lowerProfilePrompt(profile, harness, binds, builder) {
90
90
  }
91
91
  else {
92
92
  const instructionPath = CONTEXT_FILE[harness];
93
- add(instructionPath, `${instructions.join("\n\n")}\n`);
93
+ addAtRoot(instructionsRoot, instructionPath, markMaterializerContextFile(instructionPath, `${instructions.join("\n\n")}\n`));
94
94
  if (harness === "opencode") {
95
95
  openCodeInstructionFiles.push(instructionPath);
96
96
  }
@@ -47,7 +47,8 @@ function sha256Hex(content) {
47
47
  * over.
48
48
  */
49
49
  function workspaceFileConflictMessage(file, existingFiles, planDigest) {
50
- const prefix = `Refusing to replace existing workspace file: ${file.relPath}`;
50
+ const rootLabel = file.root === "workspace" ? "workspace" : "agent-directory";
51
+ const prefix = `Refusing to replace existing ${rootLabel} file: ${file.relPath}`;
51
52
  if (existingFiles === "reject") {
52
53
  return `${prefix} — the reject policy forbids any pre-existing plan path (plan ${planDigest})`;
53
54
  }
@@ -7,6 +7,6 @@ export interface ResolvedWorkspacePlan {
7
7
  appendSystemPrompt?: string;
8
8
  systemPromptBinding?: WorkspacePlanSystemPromptBinding;
9
9
  }
10
- export declare function safeWorkspacePlanReceipt(plan: WorkspacePlan, written: string[], workspacePlanDigest?: string): WorkspacePlanReceipt;
10
+ export declare function safeWorkspacePlanReceipt(plan: WorkspacePlan, writtenFiles: WorkspacePlanWrittenFile[], workspacePlanDigest?: string): WorkspacePlanReceipt;
11
11
  import { type NormalizedPlanFile } from "./plan-normalize.js";
12
- import type { ApplyWorkspacePlanOptions, WorkspacePlan, WorkspacePlanApplication, WorkspacePlanReceipt, WorkspacePlanSystemPromptBinding } from "./plan-types.js";
12
+ import type { ApplyWorkspacePlanOptions, WorkspacePlan, WorkspacePlanApplication, WorkspacePlanReceipt, WorkspacePlanSystemPromptBinding, WorkspacePlanWrittenFile } from "./plan-types.js";
@@ -1,23 +1,36 @@
1
1
  export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, options, publicPlanDigest = hashWorkspacePlan(publicPlan)) {
2
2
  const workspaceRoot = resolve(workspaceDir);
3
+ if (options.agentDir !== undefined && !isAbsolute(options.agentDir)) {
4
+ throw new Error("ApplyWorkspacePlanOptions.agentDir must be absolute");
5
+ }
6
+ const agentRoot = options.agentDir === undefined ? undefined : resolve(options.agentDir);
7
+ const publicFiles = normalizePlanFiles(publicPlan.files);
8
+ if (publicFiles.some((file) => file.root === "agent") && !agentRoot) {
9
+ throw new Error("Workspace plan contains agent-root files but ApplyWorkspacePlanOptions.agentDir is missing");
10
+ }
11
+ const rootFor = (root) => root === "workspace" ? workspaceRoot : agentRoot;
12
+ const fileIdentity = (file) => `${file.root}\0${file.relPath}`;
13
+ const receiptFile = (file) => ({
14
+ root: file.root,
15
+ relPath: file.relPath,
16
+ });
3
17
  const runtimeOnlyFiles = new Set((options.runtimeOnlyFiles ?? []).map(normalizeWorkspaceRelativePath));
4
18
  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));
19
+ const privateFilePaths = new Set(publicFiles.filter((file) => file.secretSlots.length > 0).map(fileIdentity));
8
20
  const existingFiles = options.existingFiles ?? "allow-identical";
9
21
  if (existingFiles !== "allow-identical" && existingFiles !== "reject") {
10
22
  throw new Error(`Unsupported existing workspace file policy: ${existingFiles}`);
11
23
  }
12
24
  const resolvedFiles = resolvedPlan.files;
13
25
  const privateRuntimeFiles = Object.fromEntries(resolvedFiles
14
- .filter((file) => runtimeOnlyFiles.has(file.relPath))
26
+ .filter((file) => file.root === "workspace" && runtimeOnlyFiles.has(file.relPath))
15
27
  .map((file) => [file.relPath, file.content]));
16
28
  const files = resolvedFiles
17
- .filter((file) => !runtimeOnlyFiles.has(file.relPath))
29
+ .filter((file) => file.root !== "workspace" || !runtimeOnlyFiles.has(file.relPath))
18
30
  .map((file) => ({
19
31
  ...file,
20
- absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
32
+ rootDir: rootFor(file.root),
33
+ absPath: resolveWorkspaceFilePath(rootFor(file.root), file.relPath),
21
34
  }));
22
35
  // A prior application of this exact plan makes the workspace live agent
23
36
  // state. Preserve changed and removed files. Refresh private generated files
@@ -31,13 +44,13 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
31
44
  ? files.filter((file) => {
32
45
  if (file.ownership !== "materializer")
33
46
  return false;
34
- assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
47
+ assertNoSymlinkPathComponents(file.rootDir, file.absPath);
35
48
  if (!workspaceFileExists(file.absPath))
36
49
  return false;
37
50
  const matches = workspaceFileMatches(file);
38
51
  const hasCurrentOwnership = ownership === undefined ||
39
52
  workspaceFileHasOwnership(file.absPath, ownership);
40
- if (privateFilePaths.has(file.relPath)) {
53
+ if (privateFilePaths.has(fileIdentity(file))) {
41
54
  return !matches || !hasCurrentOwnership;
42
55
  }
43
56
  return matches && !hasCurrentOwnership;
@@ -45,7 +58,7 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
45
58
  : files;
46
59
  if (resumeSamePlan && filesToApply.length === 0) {
47
60
  return {
48
- receipt: safeWorkspacePlanReceipt(publicPlan, files.map((file) => file.relPath), publicPlanDigest),
61
+ receipt: safeWorkspacePlanReceipt(publicPlan, files.map(receiptFile), publicPlanDigest),
49
62
  spawn: {
50
63
  env: { ...resolvedPlan.env },
51
64
  flags: [...resolvedPlan.flags],
@@ -66,7 +79,7 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
66
79
  // Validate the complete plan before the first write so a late traversal or
67
80
  // existing symlink cannot leave a partially materialized workspace.
68
81
  for (const file of filesToApply) {
69
- assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
82
+ assertNoSymlinkPathComponents(file.rootDir, file.absPath);
70
83
  if (workspaceFileExists(file.absPath) &&
71
84
  !mayReplace(file) &&
72
85
  (existingFiles === "reject" || !workspaceFileMatches(file))) {
@@ -79,7 +92,7 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
79
92
  mkdirSync(dirname(file.absPath), { recursive: true });
80
93
  }
81
94
  for (const file of filesToApply) {
82
- assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
95
+ assertNoSymlinkPathComponents(file.rootDir, file.absPath);
83
96
  }
84
97
  const prepared = [];
85
98
  // Prepared temp links stay alive through publication and rollback. Their
@@ -94,10 +107,10 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
94
107
  workspaceFileHasOwnership(file.absPath, ownership))) {
95
108
  continue;
96
109
  }
97
- prepared.push(prepareWorkspaceFile(workspaceRoot, file, ownership));
110
+ prepared.push(prepareWorkspaceFile(file.rootDir, file, ownership));
98
111
  }
99
112
  for (const entry of prepared) {
100
- assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
113
+ assertNoSymlinkPathComponents(rootFor(entry.file.root), entry.file.absPath);
101
114
  try {
102
115
  linkSync(entry.temporaryPath, entry.file.absPath);
103
116
  entry.published = true;
@@ -120,7 +133,7 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
120
133
  // superseded bytes. That is the fail-closed direction: the harness
121
134
  // whose flag names a missing file refuses to start, whereas silently
122
135
  // restored previous-turn bytes would run the wrong prompt.
123
- assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
136
+ assertNoSymlinkPathComponents(rootFor(entry.file.root), entry.file.absPath);
124
137
  renameSync(entry.temporaryPath, entry.file.absPath);
125
138
  entry.published = true;
126
139
  continue;
@@ -158,7 +171,7 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
158
171
  }
159
172
  throw error;
160
173
  }
161
- const receipt = safeWorkspacePlanReceipt(publicPlan, files.map((file) => file.relPath), publicPlanDigest);
174
+ const receipt = safeWorkspacePlanReceipt(publicPlan, files.map(receiptFile), publicPlanDigest);
162
175
  const cleanupErrors = [];
163
176
  const temporaryPaths = [];
164
177
  for (const entry of prepared) {
@@ -186,7 +199,7 @@ export function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDi
186
199
  runtimeOnlyFiles: privateRuntimeFiles,
187
200
  };
188
201
  }
189
- export function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
202
+ export function safeWorkspacePlanReceipt(plan, writtenFiles, workspacePlanDigest = hashWorkspacePlan(plan)) {
190
203
  return {
191
204
  workspacePlanDigest,
192
205
  env: Object.fromEntries(Object.entries(plan.env).map(([name, value]) => [
@@ -197,11 +210,14 @@ export function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = ha
197
210
  ...promptIntentFields(plan, samePromptIntentValue),
198
211
  ...systemPromptBindingField(plan),
199
212
  unsupported: plan.unsupported.map((entry) => ({ ...entry })),
200
- written: [...written],
213
+ written: writtenFiles
214
+ .filter((file) => file.root === "workspace")
215
+ .map((file) => file.relPath),
216
+ writtenFiles: writtenFiles.map((file) => ({ ...file })),
201
217
  };
202
218
  }
203
219
  import { linkSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
204
- import { dirname, resolve } from "node:path";
220
+ import { dirname, isAbsolute, resolve } from "node:path";
205
221
  import { isMaterializerOutput } from "./harness.js";
206
222
  import { hashWorkspacePlan } from "./plan-digest.js";
207
223
  import { normalizePlanFiles, normalizeWorkspacePlanArgument, } from "./plan-normalize.js";
@@ -1,4 +1,4 @@
1
- export declare const compareStrings: (a: string, b: string) => 0 | 1 | -1;
1
+ export declare const compareStrings: (a: string, b: string) => 1 | -1 | 0;
2
2
  export declare const DEFAULT_FILE_MODE = 420;
3
3
  export declare const PRIVATE_FILE_MODE = 384;
4
4
  export declare const EXECUTABLE_FILE_MODE = 493;
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-profile-materialize",
3
- "version": "0.18.1",
3
+ "version": "0.19.1",
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
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tangle-network/agent-dev-container.git",
9
+ "directory": "packages/agent-profile-materialize"
10
+ },
6
11
  "main": "./dist/index.js",
7
12
  "types": "./dist/index.d.ts",
8
13
  "exports": {
@@ -20,7 +25,8 @@
20
25
  "dist"
21
26
  ],
22
27
  "devDependencies": {
23
- "@tangle-network/agent-interface": "2.0.0",
28
+ "@tangle-network/agent-interface": "2.1.1",
29
+ "@tangle-network/agent-interface-current": "npm:@tangle-network/agent-interface@2.1.1",
24
30
  "@types/node": "25.6.0",
25
31
  "typescript": "^6.0.3",
26
32
  "vitest": "^4.1.10"
@@ -32,7 +38,9 @@
32
38
  "build": "tsc -p tsconfig.json",
33
39
  "check-types": "tsc --noEmit",
34
40
  "clean": "rm -rf dist",
35
- "test": "vitest run",
41
+ "test": "pnpm run test:installed-peer && pnpm run test:current-peer",
42
+ "test:installed-peer": "vitest run",
43
+ "test:current-peer": "vitest run --config vitest.current-peer.config.ts",
36
44
  "test:watch": "vitest"
37
45
  }
38
46
  }