@tangle-network/agent-profile-materialize 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +133 -16
  2. package/dist/index.js +368 -135
  3. package/package.json +1 -1
package/dist/index.d.ts CHANGED
@@ -57,6 +57,25 @@ export interface PlanFile {
57
57
  * this on every file it emits.
58
58
  */
59
59
  source?: "user" | "generated";
60
+ /**
61
+ * Who owns the PATH — which decides whether an existing file there may be
62
+ * replaced.
63
+ *
64
+ * `"materializer"` = a path this package invented. No harness convention
65
+ * names it and no user authors it, so a file already there can only be this
66
+ * materializer's own output from an earlier application into the same
67
+ * workspace, and replacing it is how turn two of a resumed session gets the
68
+ * current profile instead of the previous one.
69
+ *
70
+ * `"shared"` (the default, and every other file) = a path the user or the
71
+ * harness also owns — `CLAUDE.md`, `AGENTS.md`, `opencode.json`,
72
+ * `.gemini/system.md` are all documented, user-authorable names. An existing
73
+ * file there may be the caller's own work, so it is never replaced.
74
+ *
75
+ * Optional because hand-constructed plans have no path provenance to report;
76
+ * absent reads as `"shared"`, the safe answer.
77
+ */
78
+ ownership?: "materializer" | "shared";
60
79
  /**
61
80
  * Secret leaves in a generated JSON document. `content` contains `null` at
62
81
  * each path until the private executor resolves the named reference. The
@@ -83,9 +102,47 @@ export type WorkspacePlanArgument = string | WorkspacePlanArgumentTemplate;
83
102
  /** Public bytes stay strings; secret references stay opaque until application. */
84
103
  export type WorkspacePlanConfigValue = string | AgentProfileSecretRef;
85
104
  export interface Unsupported {
86
- dimension: "files" | "skills" | "mcp" | "hooks" | "subagents" | "commands" | "instructions" | "systemPrompt" | "connections" | "confidential" | "permissions" | "tools" | "modes" | "extensions";
105
+ dimension: "files" | "skills" | "mcp" | "hooks" | "subagents" | "commands" | "instructions" | "systemPrompt" | "appendSystemPrompt" | "connections" | "confidential" | "permissions" | "tools" | "modes" | "extensions";
87
106
  reason: string;
88
107
  }
108
+ /**
109
+ * The subset of {@link BINDABLE_LAUNCH_CONTROLS} the plan HANDS OVER rather than
110
+ * executes: controls a caller reaches only by composing the harness's own
111
+ * configuration, so no file, flag or env var the plan writes can touch them.
112
+ *
113
+ * - `opencode.agent-prompt` — opencode's `agent.<name>.prompt` config field,
114
+ * the one control that replaces its built-in provider prompt. It binds to a
115
+ * SINGLE agent, and the agent is chosen at launch. A caller that builds
116
+ * opencode's config itself and picks the agent there reads neither
117
+ * `plan.files` nor `plan.flags`, so for that one the plan names the control
118
+ * and hands over the text in {@link WorkspacePlan.systemPromptBinding}.
119
+ *
120
+ * Every other bindable control names a capability the plan still lowers itself
121
+ * once the caller has declared the plan's launch inputs really reach the spawn.
122
+ */
123
+ export declare const EXECUTOR_SYSTEM_PROMPT_BINDINGS: readonly ["opencode.agent-prompt"];
124
+ export type ExecutorSystemPromptBinding = (typeof EXECUTOR_SYSTEM_PROMPT_BINDINGS)[number];
125
+ export declare function isExecutorSystemPromptBinding(value: string): value is ExecutorSystemPromptBinding;
126
+ /**
127
+ * A system-prompt intent handed to the launcher that declared it owns the
128
+ * control, instead of being lowered into the workspace.
129
+ *
130
+ * Distinct from {@link WorkspacePlan.systemPrompt} — a launch ARGUMENT the
131
+ * executor hands to a harness flag — and from the file/flag/env lowerings the
132
+ * plan performs on its own. This names the exact control that must receive the
133
+ * text and which of the two intents it is, so the launcher cannot apply it to
134
+ * the wrong channel. Never written into an additive channel and never dropped:
135
+ * a caller that has not declared it owns `control` gets the plan's own lowering
136
+ * (or a refusal where the harness has no control at all) instead of this field.
137
+ */
138
+ export interface WorkspacePlanSystemPromptBinding {
139
+ /** Which of the two prompt intents this is — never inferred from presence. */
140
+ intent: "replace";
141
+ /** The exact launcher control that must receive `systemPrompt`. */
142
+ control: ExecutorSystemPromptBinding;
143
+ /** Exact text the control receives. */
144
+ systemPrompt: string;
145
+ }
89
146
  /**
90
147
  * The dimensions `materializeProfile`'s `skip` option honors.
91
148
  *
@@ -104,13 +161,23 @@ export declare function isSkippableDimension(value: string): value is SkippableD
104
161
  * system-prompt replacement (`agent.<name>.prompt`) binds to the ONE agent
105
162
  * whoever starts the process selects, which a plan-forwarding executor cannot
106
163
  * guarantee — so the plan refuses the intent (harnessSystemPromptIntents in
107
- * @tangle-network/agent-interface answers `replace: false` for opencode). An
108
- * executor that really applies the plan's launch flags to the spawn it owns
109
- * (selecting the generated primary agent via the plan's `--agent` flag) states
110
- * so with `binds: ["systemPrompt"]`, which turns that refusal into the
111
- * primary-agent lowering the executor must then apply.
164
+ * @tangle-network/agent-interface answers `replace: false` for opencode).
165
+ *
166
+ * The two declarations describe the two executor shapes that CAN reach it, and
167
+ * each gets the lowering it can actually apply:
168
+ *
169
+ * - `systemPrompt` — the executor applies the plan's launch flags to the
170
+ * spawn it owns, so the plan writes the generated `mode: primary` agent and
171
+ * selects it with `--agent`.
172
+ * - `opencode.agent-prompt` — the executor composes opencode's own config and
173
+ * picks the agent there, reading neither plan files nor plan flags, so the
174
+ * plan hands the text over in {@link WorkspacePlan.systemPromptBinding}
175
+ * instead of writing a workspace agent that would never be selected.
176
+ *
177
+ * They are mutually exclusive claims about the same caller: declaring both is a
178
+ * caller bug, not a broader capability.
112
179
  */
113
- export declare const BINDABLE_LAUNCH_CONTROLS: readonly ["systemPrompt"];
180
+ export declare const BINDABLE_LAUNCH_CONTROLS: readonly ["systemPrompt", "opencode.agent-prompt"];
114
181
  export type BindableLaunchControl = (typeof BINDABLE_LAUNCH_CONTROLS)[number];
115
182
  /** The plan a caller writes into the run cwd, then applies to the spawn. */
116
183
  export interface WorkspacePlan {
@@ -121,18 +188,42 @@ export interface WorkspacePlan {
121
188
  env: Record<string, WorkspacePlanConfigValue>;
122
189
  /** Extra CLI flags to pass (e.g. kimi --mcp-config-file <path>). */
123
190
  flags: WorkspacePlanArgument[];
124
- /** Exact system-prompt replacement supplied to the harness. */
191
+ /**
192
+ * Exact text that REPLACES the harness's own system prompt, for harnesses
193
+ * whose replacement control takes it as a launch argument the executor
194
+ * supplies (claude-code `--system-prompt`, pi `--system-prompt`).
195
+ *
196
+ * Absent does NOT mean "no replacement was requested": a harness whose
197
+ * control reads a file (codex, gemini) carries the replacement in
198
+ * {@link WorkspacePlan.files} plus a flag or env var instead. Present means
199
+ * the executor MUST bind it to that harness's replacement control — never to
200
+ * an additive one, which would leave the built-in prompt in force.
201
+ */
125
202
  systemPrompt?: string;
126
203
  /**
127
- * Additive system-prompt text: the harness's own prompt stays in force and
128
- * this text is added after it. The OPPOSITE intent of {@link systemPrompt} on
129
- * the same channel — an executor must never bind one to the other's control
130
- * (an addition on the replacement flag deletes the prompt the caller asked to
131
- * keep; a replacement on the additive flag leaves the harness's prompt
132
- * running). Set only for harnesses whose own control executes the addition
133
- * (harnessSystemPromptIntents in @tangle-network/agent-interface).
204
+ * Exact text ADDED to the harness's own system prompt, which stays intact.
205
+ *
206
+ * Separate from {@link WorkspacePlan.systemPrompt} because the two are
207
+ * different intents against the same channel, and every harness that has one
208
+ * control may lack the other. An executor binds this ONLY to an additive
209
+ * control (claude-code `--append-system-prompt`, pi
210
+ * `--append-system-prompt`); there is no harness for which folding it into
211
+ * the replacement is correct, because that would delete the built-in prompt
212
+ * the caller asked to keep.
134
213
  */
135
214
  appendSystemPrompt?: string;
215
+ /**
216
+ * A prompt intent no workspace file, flag, or env var can execute, addressed
217
+ * to the launcher control that can — see
218
+ * {@link WorkspacePlanSystemPromptBinding}.
219
+ *
220
+ * Present ONLY when the caller declared it owns the named control, so a plan
221
+ * built by a caller that forwards it to someone else's launcher never carries
222
+ * one; that caller gets the intent in {@link WorkspacePlan.unsupported}
223
+ * instead. Whoever reads it must bind it or throw — {@link
224
+ * takeSystemPromptBinding} does both.
225
+ */
226
+ systemPromptBinding?: WorkspacePlanSystemPromptBinding;
136
227
  /** Dimensions requested but with NO cwd path on this harness — fail-closed record. */
137
228
  unsupported: Unsupported[];
138
229
  /**
@@ -185,6 +276,8 @@ export interface WorkspacePlanReceipt {
185
276
  flags: WorkspacePlanArgument[];
186
277
  systemPrompt?: string;
187
278
  appendSystemPrompt?: string;
279
+ /** The launcher-bound intent, so a receipt says which control owed the work. */
280
+ systemPromptBinding?: WorkspacePlanSystemPromptBinding;
188
281
  unsupported: Unsupported[];
189
282
  written: string[];
190
283
  }
@@ -194,6 +287,8 @@ export interface WorkspacePlanSpawnInputs {
194
287
  flags: string[];
195
288
  systemPrompt?: string;
196
289
  appendSystemPrompt?: string;
290
+ /** Read it with {@link takeSystemPromptBinding}, which refuses a foreign control. */
291
+ systemPromptBinding?: WorkspacePlanSystemPromptBinding;
197
292
  }
198
293
  /** Result of applying a public plan at the private executor boundary. */
199
294
  export interface WorkspacePlanApplication {
@@ -248,6 +343,18 @@ export interface AgentCandidateWorkspacePlanReceipt {
248
343
  appendSystemPrompt?: AgentCandidateConfigValue;
249
344
  unsupported: Unsupported[];
250
345
  }
346
+ /**
347
+ * The replacement text this launcher must bind to `control`, or `undefined`
348
+ * when the plan carries no launcher-bound intent.
349
+ *
350
+ * Throws for a binding addressed to a DIFFERENT control: the reader cannot
351
+ * execute it, and answering `undefined` would start the agent with the
352
+ * harness's own prompt still in force while nothing reported a problem — the
353
+ * silent downgrade the binding exists to prevent. This is the seam where an
354
+ * executor that cannot bind an intent says so, by name, instead of running a
355
+ * profile whose prompt never took effect.
356
+ */
357
+ export declare function takeSystemPromptBinding(spawn: Pick<WorkspacePlanSpawnInputs, "systemPromptBinding">, control: ExecutorSystemPromptBinding): string | undefined;
251
358
  export interface MaterializeCandidateProfileOptions {
252
359
  /** Exact text fetched from pinned resource refs, keyed by declared SHA-256. */
253
360
  resolvedResources?: ReadonlyMap<Sha256Digest, string>;
@@ -308,7 +415,17 @@ export declare function normalizeSkillMd(name: string, raw: string): string;
308
415
  */
309
416
  export declare function materializeProfile(inputProfile: AgentProfile, harness: HarnessId, opts?: {
310
417
  skip?: SkippableDimension[];
311
- /** Launcher controls the calling executor owns — see {@link BINDABLE_LAUNCH_CONTROLS}. */
418
+ /**
419
+ * Launcher controls THIS caller owns and will bind itself — see
420
+ * {@link BINDABLE_LAUNCH_CONTROLS}. Each names a control no workspace file,
421
+ * flag or env var reaches on its own, so declaring one turns a plan-time
422
+ * refusal into the lowering (or the hand-over) that control can execute.
423
+ *
424
+ * A promise, not a hint: declare a control only from the code that starts
425
+ * the harness and writes that control. A caller that hands the plan to
426
+ * someone else's launcher declares nothing and keeps the refusal, which is
427
+ * what it can actually deliver.
428
+ */
312
429
  binds?: readonly BindableLaunchControl[];
313
430
  }): WorkspacePlan;
314
431
  /**
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@
23
23
  */
24
24
  export { modelIdsMatch, qualifyModelId } from "./model-id.js";
25
25
  import { createHash, randomUUID } from "node:crypto";
26
- import { closeSync, existsSync, fchmodSync, constants as fsConstants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
26
+ import { closeSync, existsSync, fchmodSync, constants as fsConstants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
27
27
  import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32, } from "node:path";
28
28
  import { agentCandidateProfileActivationSchema, agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, agentProfileSchema, agentProfileSecretRefSchema, canonicalCandidateBytes, canonicalCandidateDigest, resolveNativeWebTools, } from "@tangle-network/agent-interface";
29
29
  export const UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER = Object.freeze({
@@ -86,6 +86,28 @@ function assertMaterializerHarness(harness) {
86
86
  throw new Error(`Unsupported profile materialization harness "${harness}"`);
87
87
  }
88
88
  }
89
+ /**
90
+ * The subset of {@link BINDABLE_LAUNCH_CONTROLS} the plan HANDS OVER rather than
91
+ * executes: controls a caller reaches only by composing the harness's own
92
+ * configuration, so no file, flag or env var the plan writes can touch them.
93
+ *
94
+ * - `opencode.agent-prompt` — opencode's `agent.<name>.prompt` config field,
95
+ * the one control that replaces its built-in provider prompt. It binds to a
96
+ * SINGLE agent, and the agent is chosen at launch. A caller that builds
97
+ * opencode's config itself and picks the agent there reads neither
98
+ * `plan.files` nor `plan.flags`, so for that one the plan names the control
99
+ * and hands over the text in {@link WorkspacePlan.systemPromptBinding}.
100
+ *
101
+ * Every other bindable control names a capability the plan still lowers itself
102
+ * once the caller has declared the plan's launch inputs really reach the spawn.
103
+ */
104
+ export const EXECUTOR_SYSTEM_PROMPT_BINDINGS = [
105
+ "opencode.agent-prompt",
106
+ ];
107
+ const EXECUTOR_SYSTEM_PROMPT_BINDING_SET = new Set(EXECUTOR_SYSTEM_PROMPT_BINDINGS);
108
+ export function isExecutorSystemPromptBinding(value) {
109
+ return EXECUTOR_SYSTEM_PROMPT_BINDING_SET.has(value);
110
+ }
89
111
  /**
90
112
  * The dimensions `materializeProfile`'s `skip` option honors.
91
113
  *
@@ -120,13 +142,26 @@ function assertSkippableDimensions(skip) {
120
142
  * system-prompt replacement (`agent.<name>.prompt`) binds to the ONE agent
121
143
  * whoever starts the process selects, which a plan-forwarding executor cannot
122
144
  * guarantee — so the plan refuses the intent (harnessSystemPromptIntents in
123
- * @tangle-network/agent-interface answers `replace: false` for opencode). An
124
- * executor that really applies the plan's launch flags to the spawn it owns
125
- * (selecting the generated primary agent via the plan's `--agent` flag) states
126
- * so with `binds: ["systemPrompt"]`, which turns that refusal into the
127
- * primary-agent lowering the executor must then apply.
145
+ * @tangle-network/agent-interface answers `replace: false` for opencode).
146
+ *
147
+ * The two declarations describe the two executor shapes that CAN reach it, and
148
+ * each gets the lowering it can actually apply:
149
+ *
150
+ * - `systemPrompt` — the executor applies the plan's launch flags to the
151
+ * spawn it owns, so the plan writes the generated `mode: primary` agent and
152
+ * selects it with `--agent`.
153
+ * - `opencode.agent-prompt` — the executor composes opencode's own config and
154
+ * picks the agent there, reading neither plan files nor plan flags, so the
155
+ * plan hands the text over in {@link WorkspacePlan.systemPromptBinding}
156
+ * instead of writing a workspace agent that would never be selected.
157
+ *
158
+ * They are mutually exclusive claims about the same caller: declaring both is a
159
+ * caller bug, not a broader capability.
128
160
  */
129
- export const BINDABLE_LAUNCH_CONTROLS = ["systemPrompt"];
161
+ export const BINDABLE_LAUNCH_CONTROLS = [
162
+ "systemPrompt",
163
+ ...EXECUTOR_SYSTEM_PROMPT_BINDINGS,
164
+ ];
130
165
  const BINDABLE_LAUNCH_CONTROL_SET = new Set(BINDABLE_LAUNCH_CONTROLS);
131
166
  /** Reject a binding declaration this materializer has no lowering for, rather than ignoring it. */
132
167
  function assertBindableLaunchControls(binds) {
@@ -137,7 +172,14 @@ function assertBindableLaunchControls(binds) {
137
172
  throw new Error(`materializeProfile: cannot bind ${unknown.join(", ")}. ` +
138
173
  `Bindable launch controls are: ${BINDABLE_LAUNCH_CONTROLS.join(", ")}.`);
139
174
  }
140
- return new Set(binds);
175
+ const declared = new Set(binds);
176
+ const handedOver = [...EXECUTOR_SYSTEM_PROMPT_BINDINGS].filter((control) => declared.has(control));
177
+ if (declared.has("systemPrompt") && handedOver.length > 0) {
178
+ throw new Error(`materializeProfile: cannot bind systemPrompt together with ${handedOver.join(", ")}. ` +
179
+ "The first says the plan's launch flags reach the spawn; the second says " +
180
+ "the caller composes the harness config itself and reads neither.");
181
+ }
182
+ return declared;
141
183
  }
142
184
  /** The plan was applied, but one or more private staging files remain. */
143
185
  export class WorkspacePlanCleanupError extends AggregateError {
@@ -171,6 +213,66 @@ export function enforceWorkspacePlanSupport(plan, failOnError) {
171
213
  assertWorkspacePlanSupported(plan);
172
214
  return [];
173
215
  }
216
+ /**
217
+ * The two prompt intents a plan carries, projected together onto an outgoing
218
+ * object with each field omitted when the source does not carry it.
219
+ *
220
+ * One helper instead of a pair of inline spreads at each of the digest,
221
+ * receipt, candidate, and spawn boundaries: the intents must travel as a pair,
222
+ * and a projection that copies the replacement and forgets the addition drops
223
+ * it silently — downstream cannot tell "the caller asked for nothing" from
224
+ * "this boundary lost it". Adding a third intent later is one edit here rather
225
+ * than nine.
226
+ */
227
+ function promptIntentFields(source, project) {
228
+ return {
229
+ ...(source.systemPrompt === undefined
230
+ ? {}
231
+ : { systemPrompt: project(source.systemPrompt) }),
232
+ ...(source.appendSystemPrompt === undefined
233
+ ? {}
234
+ : { appendSystemPrompt: project(source.appendSystemPrompt) }),
235
+ };
236
+ }
237
+ /** Identity projection for boundaries that pass the prompt intents through. */
238
+ const samePromptIntentValue = (value) => value;
239
+ /**
240
+ * The launcher-bound intent, projected onto an outgoing object and omitted when
241
+ * the source does not carry one.
242
+ *
243
+ * Beside {@link promptIntentFields} rather than inside it, because the two
244
+ * disagree on both ends: the candidate boundaries that helper also serves have
245
+ * no field for a binding — an {@link AgentCandidateWorkspacePlan} is built by a
246
+ * caller that declares none, so it can never hold one — and a value projection
247
+ * such as `candidatePublicString` has nothing to do to a structured binding.
248
+ * Every boundary that carries the plan's own prompt intents calls both.
249
+ */
250
+ function systemPromptBindingField(source) {
251
+ return source.systemPromptBinding === undefined
252
+ ? {}
253
+ : { systemPromptBinding: { ...source.systemPromptBinding } };
254
+ }
255
+ /**
256
+ * The replacement text this launcher must bind to `control`, or `undefined`
257
+ * when the plan carries no launcher-bound intent.
258
+ *
259
+ * Throws for a binding addressed to a DIFFERENT control: the reader cannot
260
+ * execute it, and answering `undefined` would start the agent with the
261
+ * harness's own prompt still in force while nothing reported a problem — the
262
+ * silent downgrade the binding exists to prevent. This is the seam where an
263
+ * executor that cannot bind an intent says so, by name, instead of running a
264
+ * profile whose prompt never took effect.
265
+ */
266
+ export function takeSystemPromptBinding(spawn, control) {
267
+ const binding = spawn.systemPromptBinding;
268
+ if (!binding)
269
+ return undefined;
270
+ if (binding.control !== control) {
271
+ throw new Error(`Workspace plan carries a system-prompt ${binding.intent} bound to ` +
272
+ `${binding.control}, which this executor cannot apply; it binds ${control}`);
273
+ }
274
+ return binding.systemPrompt;
275
+ }
174
276
  // ─── Verified per-harness conventions (capability matrix) ───────────────────
175
277
  /**
176
278
  * Whether the harness LOADS its {@link CONTEXT_FILE} from cwd on its own.
@@ -204,6 +306,61 @@ export function harnessContextFile(harness) {
204
306
  }
205
307
  return path;
206
308
  }
309
+ /**
310
+ * File codex's `model_instructions_file` config key reads.
311
+ *
312
+ * cwd-relative on purpose: codex resolves the key against the process working
313
+ * directory, which is the same workspace root the plan is applied into, so the
314
+ * flag stays correct under an executor that binds the workspace at a different
315
+ * absolute path (docker, a jail, a remote box).
316
+ */
317
+ const CODEX_SYSTEM_PROMPT_FILE = ".codex/system-prompt.md";
318
+ /**
319
+ * File carrying opencode's ADDITION to its system prompt.
320
+ *
321
+ * Separate from opencode's instruction file rather than merged into it: the two
322
+ * are different privilege levels of the same request, and one file for both
323
+ * would leave the plan unable to say which intent produced which bytes.
324
+ */
325
+ const OPENCODE_APPEND_SYSTEM_PROMPT_FILE = ".opencode/agent-system-prompt.md";
326
+ /**
327
+ * Why a harness refuses a system-prompt REPLACEMENT.
328
+ *
329
+ * The reason names the control that does exist, so the refusal is actionable
330
+ * without a capability-matrix lookup — a bare "unsupported" sends the caller
331
+ * looking for a flag that was never there.
332
+ */
333
+ function systemPromptRefusal(harness) {
334
+ switch (harness) {
335
+ case "opencode":
336
+ // The control exists, so the reason names the two declarations that reach
337
+ // it rather than claiming opencode has none.
338
+ return 'opencode: the only system-prompt replacement is per-agent (`agent.<name>.prompt`), which binds to the agent selected at launch and a workspace plan cannot guarantee; an executor that applies the plan\'s "--agent" flag to its own spawn declares binds: ["systemPrompt"], and one that composes opencode\'s config itself declares binds: ["opencode.agent-prompt"]';
339
+ default:
340
+ return `${harness}: no verified exact system-prompt replacement control`;
341
+ }
342
+ }
343
+ /**
344
+ * Why a harness refuses an ADDITION to the system prompt.
345
+ *
346
+ * gemini puts its context file in the system-prompt position, so the temptation
347
+ * is to lower an addition there. That is refused because GEMINI.md is the ONE
348
+ * file it auto-loads: an addition written there is byte-identical to the same
349
+ * text under `prompt.instructions`, and no consumer could tell which intent the
350
+ * caller expressed. opencode is not refused for the same situation, because it
351
+ * loads exactly the files its config names and can therefore carry the two
352
+ * intents in separate, separately-ordered files.
353
+ */
354
+ function appendSystemPromptRefusal(harness) {
355
+ switch (harness) {
356
+ case "gemini":
357
+ return "gemini: the only additive system-prompt channel is GEMINI.md memory, the single context file it auto-loads, which `prompt.instructions` already materializes — use it instead";
358
+ case "codex":
359
+ return "codex: no verified additive system-prompt control — its AGENTS.md lands in a developer/user message, not the system channel";
360
+ default:
361
+ return `${harness}: no verified additive system-prompt control`;
362
+ }
363
+ }
207
364
  /** Project context file each harness actually reads from cwd. NEVER a constant. */
208
365
  const CONTEXT_FILE = {
209
366
  "claude-code": "CLAUDE.md",
@@ -220,6 +377,35 @@ const CONTEXT_FILE = {
220
377
  hermes: "HERMES.md", // highest-priority native name; only ONE context loads (first match)
221
378
  openclaw: "AGENTS.md",
222
379
  };
380
+ /**
381
+ * The paths whose only possible author is this package, per
382
+ * {@link PlanFile.ownership}.
383
+ *
384
+ * Codex names no system-prompt file of its own — `model_instructions_file`
385
+ * takes whatever path it is given — and opencode names none either, since it
386
+ * reads exactly the files its config lists. All three names below were chosen
387
+ * here, so a file already at one of them is this materializer's own output from
388
+ * an earlier application into the same workspace.
389
+ *
390
+ * That is an inference from the path name, not verified provenance: nothing
391
+ * checks the existing bytes are prior materializer output, so a workspace that
392
+ * carried a foreign file at one of these invented names is overwritten on the
393
+ * first application. Accepted cost of stateless multi-turn re-application,
394
+ * bounded by the names being ones no harness or user convention uses.
395
+ *
396
+ * Deliberately absent: `.gemini/system.md` (gemini's own documented name),
397
+ * `GEMINI.md`, `CLAUDE.md`, `AGENTS.md`, `opencode.json`. A user's repository
398
+ * can carry any of those, and clobbering one is worse than refusing the turn.
399
+ * For `opencode.json` this means a profile change that alters the
400
+ * `instructions[]` membership between turns of one workspace refuses loudly
401
+ * (the consolidated config no longer matches byte-for-byte) rather than
402
+ * replacing a possibly user-authored config.
403
+ */
404
+ const MATERIALIZER_OWNED_PATHS = new Set([
405
+ CODEX_SYSTEM_PROMPT_FILE,
406
+ OPENCODE_APPEND_SYSTEM_PROMPT_FILE,
407
+ CONTEXT_FILE.opencode,
408
+ ]);
223
409
  /**
224
410
  * cwd-relative native skill dir per harness, or null if the harness has no
225
411
  * cwd skill primitive (skills live in a user/global dir the workspace plan
@@ -271,8 +457,6 @@ const MAX_PROFILE_RESOURCE_BYTES = 32 * 1024 * 1024;
271
457
  const MAX_PROFILE_RESOURCE_REFS = 128;
272
458
  const MAX_CONCURRENT_RESOURCE_FETCHES = 8;
273
459
  const CLAUDE_SETTINGS_PATH = ".tangle/claude-settings.json";
274
- const CODEX_SYSTEM_PROMPT_PATH = ".codex/system-prompt.md";
275
- const OPENCODE_APPEND_SYSTEM_PROMPT_PATH = ".opencode/agent-system-prompt.md";
276
460
  const SAFE_WRITE_FLAGS = fsConstants.O_WRONLY |
277
461
  fsConstants.O_CREAT |
278
462
  fsConstants.O_EXCL |
@@ -645,6 +829,12 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
645
829
  relPath: normalizedPath,
646
830
  content,
647
831
  source,
832
+ // Only a path this package invented AND generated here is its own to
833
+ // replace; the same path carrying a caller's `resources` mount is that
834
+ // caller's file, whatever its name.
835
+ ...(source === "generated" && MATERIALIZER_OWNED_PATHS.has(normalizedPath)
836
+ ? { ownership: "materializer" }
837
+ : {}),
648
838
  ...(fileMode === undefined ? {} : { mode: validateFileMode(fileMode) }),
649
839
  ...(secretSlots === undefined
650
840
  ? {}
@@ -665,6 +855,14 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
665
855
  if (previousDimension) {
666
856
  throw new Error(`Duplicate profile resource path: ${normalizedPath} (${previousDimension}, ${dimension})`);
667
857
  }
858
+ // Refuse at plan time so {@link MATERIALIZER_OWNED_PATHS} stays true: those
859
+ // paths are replaced on every re-application, so a mount there would be
860
+ // silently overwritten by the next turn rather than kept.
861
+ if (MATERIALIZER_OWNED_PATHS.has(normalizedPath)) {
862
+ throw new Error(`Profile resource path is reserved by the materializer: ${normalizedPath} (${dimension}). ` +
863
+ "This package writes that path itself and replaces it on re-application, so a mount " +
864
+ "there cannot survive; choose another path.");
865
+ }
668
866
  explicitResourcePaths.set(normalizedPath, dimension);
669
867
  pushPlanFile(normalizedPath, content, "user", mode);
670
868
  };
@@ -725,36 +923,84 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
725
923
  }
726
924
  }
727
925
  }
728
- // 4) PROMPT. A system prompt is a replacement, an appended system prompt is
729
- // an addition on the SAME channel, and instructions are additive workspace
730
- // context. Never lower one into another: substituting controls is a silent
731
- // semantic change nothing in the run would reveal. Which harness owns which
732
- // intent is the measured harnessSystemPromptIntents table in
733
- // @tangle-network/agent-interface; the mechanics per owned cell live here.
926
+ // 4) PROMPT. Replacing the harness's own system prompt and adding to it are
927
+ // two intents against one channel, and a harness almost never has both. Each
928
+ // lowers ONLY to a control whose measured job is exactly that intent; the
929
+ // other is refused. Substituting one for the other is the silent downgrade
930
+ // this section exists to prevent — an addition standing in for a replacement
931
+ // leaves the instructions the caller asked to delete in force, and a
932
+ // replacement standing in for an addition deletes the harness's own prompt.
933
+ // Neither is visible in the result, which is why both fail closed instead.
734
934
  //
735
- // opencode registers every additive prompt file in ONE generated
736
- // opencode.json `instructions` array (top-level JSON merge is last-wins per
737
- // key, so two writers would clobber each other). Order is privilege order:
738
- // the profile's explicit addition leads, project instructions follow.
739
- const openCodeInstructionFiles = [];
935
+ // `instructions` is a third, lower-privilege surface (the harness's
936
+ // AGENTS.md/CLAUDE.md-style context file). On gemini and opencode that surface
937
+ // does reach the model in the system-prompt position, so an addition can be
938
+ // lowered there ONLY where the plan can keep the two apart. opencode can: it
939
+ // loads exactly the files `opencode.json` `instructions[]` names, so the
940
+ // addition gets its own file registered ahead of the instruction file. gemini
941
+ // cannot: GEMINI.md is the one context file it auto-loads, so an addition
942
+ // lowered there would be byte-indistinguishable from `instructions`, which is
943
+ // one-channel-two-meanings recreated a level down.
944
+ //
945
+ // Which harness owns which intent is the measured harnessSystemPromptIntents
946
+ // table in @tangle-network/agent-interface; the mechanics per owned cell live
947
+ // here.
740
948
  if (profile.prompt?.systemPrompt !== undefined) {
741
- if (h === "opencode") {
949
+ if (h === "claude-code" || h === "pi" || h === "prime") {
950
+ // claude-code `--system-prompt`, pi `--system-prompt`: both take the
951
+ // replacement as a launch argument the executor supplies, and both
952
+ // measurably drop the harness's built-in prompt from the wire request.
953
+ // prime inherits the same flag pair from its pi line (fork
954
+ // cli/args.ts:139-143 → resourceLoaderOptions main.ts:745-746, the
955
+ // replacement deletes the built-in prompt: resource-loader.ts:482-485,
956
+ // system-prompt.ts:72-73).
957
+ plan.systemPrompt = profile.prompt.systemPrompt;
958
+ }
959
+ else if (h === "codex") {
960
+ // codex has no replacement FLAG; its control is the config key
961
+ // `model_instructions_file`, which reads a cwd-relative file and becomes
962
+ // the request's entire `instructions` field. File + flag keeps the plan
963
+ // self-applying for every executor that already forwards plan.flags.
964
+ add(CODEX_SYSTEM_PROMPT_FILE, profile.prompt.systemPrompt);
965
+ plan.flags.push("-c", `model_instructions_file=${CODEX_SYSTEM_PROMPT_FILE}`);
966
+ }
967
+ else if (h === "gemini") {
968
+ add(".gemini/system.md", profile.prompt.systemPrompt);
969
+ plan.env.GEMINI_SYSTEM_MD = "1";
970
+ }
971
+ else if (h === "opencode") {
972
+ // opencode's replacement control is `agent.<name>.prompt`: it binds to
973
+ // ONE agent, and the agent is chosen at launch, so executing it means
974
+ // writing that agent AND selecting it. There are two shapes of executor
975
+ // that can promise one of those, and each gets the lowering it can apply;
976
+ // an executor that promises neither gets the refusal, because a plan
977
+ // written for an unknown launcher cannot deliver either half.
978
+ //
979
+ // No shape can express an EMPTY replacement — an agent `prompt` with no
980
+ // text falls back to the built-in prompt instead of deleting it — so the
981
+ // guard sits above the split and no path bypasses it.
742
982
  const systemPrompt = profile.prompt.systemPrompt;
743
983
  if (systemPrompt.trim().length === 0) {
744
984
  throw new Error("opencode cannot express an empty exact system-prompt replacement");
745
985
  }
746
- if (!binds.has("systemPrompt")) {
747
- // opencode's exact replacement (`agent.<name>.prompt`) binds to the one
748
- // agent selected at launch, which a plan-forwarding executor cannot
749
- // guarantee — its only system-prompt replacement is per-agent. An
750
- // executor that selects the plan's generated primary agent declares
751
- // binds: ["systemPrompt"].
752
- unsupported("systemPrompt", "opencode: only system-prompt replacement is per-agent (`agent.<name>.prompt` " +
753
- "binds to the agent selected at launch, which a workspace plan cannot " +
754
- 'guarantee); an executor that applies the plan\'s "--agent" flag to its own ' +
755
- 'spawn declares binds: ["systemPrompt"]');
986
+ if (binds.has("opencode.agent-prompt")) {
987
+ // A caller that composes opencode's own config and picks the agent
988
+ // itself owns the control outright; it does not read plan files or
989
+ // forward plan flags, so a workspace agent would never be selected.
990
+ // Hand it the intent named against the exact control it claimed.
991
+ plan.systemPromptBinding = {
992
+ intent: "replace",
993
+ control: "opencode.agent-prompt",
994
+ systemPrompt,
995
+ };
756
996
  }
757
- else {
997
+ else if (binds.has("systemPrompt")) {
998
+ // A caller that applies the plan's launch flags to the spawn it owns
999
+ // lets the plan execute the control itself: a `mode: primary` agent
1000
+ // file holding the prompt, plus the `--agent` flag that selects it.
1001
+ // Without the flag the file is inert and the built-in prompt the
1002
+ // caller asked to delete stays in force, so the two are pushed
1003
+ // together or not at all.
758
1004
  const primaryAgentName = `${OPENCODE_PRIMARY_AGENT_PREFIX}${sha256(systemPrompt).slice(0, 16)}`;
759
1005
  const primaryAgentPath = `.opencode/agents/${primaryAgentName}.md`;
760
1006
  if (profile.subagents?.[primaryAgentName] !== undefined) {
@@ -764,61 +1010,57 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
764
1010
  add(primaryAgentPath, ["---", "mode: primary", "---", systemPrompt, ""].join("\n"));
765
1011
  plan.flags.push("--agent", primaryAgentName);
766
1012
  }
767
- }
768
- else if (h === "claude-code" || h === "pi" || h === "prime") {
769
- // Launch-argument channel: claude-code/pi `--system-prompt`; prime
770
- // inherits the same flag pair from its pi line (fork cli/args.ts:139-143
771
- // → resourceLoaderOptions main.ts:745-746, replacement deletes the
772
- // built-in prompt: resource-loader.ts:482-485, system-prompt.ts:72-73).
773
- plan.systemPrompt = profile.prompt.systemPrompt;
774
- }
775
- else if (h === "codex") {
776
- // codex's replacement is a config file, never a launch-argument string:
777
- // `model_instructions_file` becomes the request's entire instructions
778
- // text (measured against codex 0.146.0).
779
- add(CODEX_SYSTEM_PROMPT_PATH, profile.prompt.systemPrompt);
780
- plan.flags.push("-c", `model_instructions_file=${CODEX_SYSTEM_PROMPT_PATH}`);
781
- }
782
- else if (h === "gemini") {
783
- add(".gemini/system.md", profile.prompt.systemPrompt);
784
- plan.env.GEMINI_SYSTEM_MD = "1";
1013
+ else {
1014
+ unsupported("systemPrompt", systemPromptRefusal(h));
1015
+ }
785
1016
  }
786
1017
  else {
787
- unsupported("systemPrompt", `${h}: no verified exact system-prompt replacement control`);
1018
+ unsupported("systemPrompt", systemPromptRefusal(h));
788
1019
  }
789
1020
  }
1021
+ // Ordered registration list for opencode, whose TUI and headless runs read
1022
+ // ONLY the files named here. Addition first so it frames the lower-privilege
1023
+ // instruction file that follows, matching the order the model receives them.
1024
+ const openCodeInstructionFiles = [];
1025
+ // A launcher that declared `opencode.agent-prompt` composes opencode's config
1026
+ // itself and reads neither plan files nor plan flags, so the workspace
1027
+ // `opencode.json` that registers these additive files would never load.
1028
+ // Lowering them anyway would be the silent drop this module refuses to
1029
+ // perform, so each additive intent is refused by name instead: the launcher
1030
+ // owns the `instructions[]` channel too and must compose it in the config it
1031
+ // builds.
1032
+ const openCodeConfigUnreachable = h === "opencode" && binds.has("opencode.agent-prompt");
790
1033
  if (profile.prompt?.appendSystemPrompt !== undefined) {
791
1034
  const appendSystemPrompt = profile.prompt.appendSystemPrompt;
792
1035
  if (appendSystemPrompt.trim().length === 0) {
793
1036
  throw new Error("prompt.appendSystemPrompt must be non-empty text");
794
1037
  }
795
- if (h === "claude-code" || h === "pi" || h === "prime") {
796
- // `--append-system-prompt` leaves the harness's own prompt in place and
797
- // adds this text after it (claude-code/pi measured; prime fork
798
- // cli/args.ts:139-143, addition appends after the built-in prompt:
799
- // system-prompt.ts:61,109-110).
800
- plan.appendSystemPrompt = appendSystemPrompt;
1038
+ if (openCodeConfigUnreachable) {
1039
+ unsupported("appendSystemPrompt", "opencode: the launcher that binds opencode.agent-prompt composes opencode's config itself and loads no workspace opencode.json, so an addition registered there would never reach the model; compose it into the launcher's own instructions[]");
801
1040
  }
802
1041
  else if (h === "opencode") {
803
- // opencode composes config-declared `instructions[]` files into the same
804
- // single role:"system" message as its built-in prompt, which stays in
805
- // place — a real additive channel, distinct from the instructions
806
- // surface only by position (registered first, below).
807
- add(OPENCODE_APPEND_SYSTEM_PROMPT_PATH, appendSystemPrompt.endsWith("\n")
1042
+ // opencode composes config-declared `instructions[]` file contents into
1043
+ // the same single role:"system" message as its built-in prompt, which
1044
+ // stays in place (its replacement control is the separate per-agent
1045
+ // `prompt` field). A dedicated file keeps this intent addressable on its
1046
+ // own rather than merged into the instruction file.
1047
+ add(OPENCODE_APPEND_SYSTEM_PROMPT_FILE, appendSystemPrompt.endsWith("\n")
808
1048
  ? appendSystemPrompt
809
1049
  : `${appendSystemPrompt}\n`);
810
- openCodeInstructionFiles.push(OPENCODE_APPEND_SYSTEM_PROMPT_PATH);
811
- }
812
- else if (h === "codex") {
813
- unsupported("systemPrompt", "codex: no verified additive system-prompt control — its AGENTS.md lands in a " +
814
- "developer/user message, not the system channel");
1050
+ openCodeInstructionFiles.push(OPENCODE_APPEND_SYSTEM_PROMPT_FILE);
815
1051
  }
816
- else if (h === "gemini") {
817
- unsupported("systemPrompt", "gemini: the only additive system-prompt channel is GEMINI.md memory, which IS " +
818
- "the instructions surface — use prompt.instructions");
1052
+ else if (h === "claude-code" || h === "pi" || h === "prime") {
1053
+ // claude-code `--append-system-prompt`, pi `--append-system-prompt`: both
1054
+ // add the caller's text to the harness's own system prompt, which stays
1055
+ // on the wire intact. prime inherits the same flag from its pi line (fork
1056
+ // cli/args.ts:139-143, the addition appends after the built-in prompt:
1057
+ // system-prompt.ts:61,109-110). Carried beside the replacement rather
1058
+ // than merged into it, so an executor applies each through its own
1059
+ // control and the plan's identity distinguishes the two intents.
1060
+ plan.appendSystemPrompt = appendSystemPrompt;
819
1061
  }
820
1062
  else {
821
- unsupported("systemPrompt", `${h}: no verified additive system-prompt control`);
1063
+ unsupported("appendSystemPrompt", appendSystemPromptRefusal(h));
822
1064
  }
823
1065
  }
824
1066
  const instr = [];
@@ -833,12 +1075,20 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
833
1075
  unsupported("instructions", "instruction ref is not inline (github refs need async pre-resolution)");
834
1076
  }
835
1077
  if (instr.length) {
836
- const instructionPath = CONTEXT_FILE[h];
837
- add(instructionPath, `${instr.join("\n\n")}\n`);
838
- if (h === "opencode")
839
- openCodeInstructionFiles.push(instructionPath);
840
- // gemini reads AGENTS.md only via settings; we write GEMINI.md so no extra step.
1078
+ if (openCodeConfigUnreachable) {
1079
+ unsupported("instructions", "opencode: the launcher that binds opencode.agent-prompt composes opencode's config itself and loads no workspace opencode.json, so an instruction file registered there would never reach the model; compose it into the launcher's own instructions[]");
1080
+ }
1081
+ else {
1082
+ const instructionPath = CONTEXT_FILE[h];
1083
+ add(instructionPath, `${instr.join("\n\n")}\n`);
1084
+ if (h === "opencode")
1085
+ openCodeInstructionFiles.push(instructionPath);
1086
+ // gemini reads AGENTS.md only via settings; we write GEMINI.md so no extra step.
1087
+ }
841
1088
  }
1089
+ // ONE opencode.json write for the whole prompt section: top-level JSON merge
1090
+ // is last-wins per key, and two `add` calls at the same relPath are a
1091
+ // duplicate-file error, so a second write would drop whichever lost.
842
1092
  if (openCodeInstructionFiles.length > 0) {
843
1093
  add("opencode.json", serializeWorkspacePlanJson({
844
1094
  $schema: "https://opencode.ai/config.json",
@@ -957,14 +1207,7 @@ export function materializeCandidateProfile(profile, harness, opts = {}) {
957
1207
  files: classicPlan.files,
958
1208
  env: candidatePublicRecord(classicPlan.env),
959
1209
  flags: classicPlan.flags.map(candidatePublicArgument),
960
- ...(classicPlan.systemPrompt === undefined
961
- ? {}
962
- : { systemPrompt: candidatePublicValue(classicPlan.systemPrompt) }),
963
- ...(classicPlan.appendSystemPrompt === undefined
964
- ? {}
965
- : {
966
- appendSystemPrompt: candidatePublicValue(classicPlan.appendSystemPrompt),
967
- }),
1210
+ ...promptIntentFields(classicPlan, candidatePublicValue),
968
1211
  unsupported: classicPlan.unsupported,
969
1212
  };
970
1213
  }
@@ -1281,6 +1524,7 @@ function normalizePlanFiles(files) {
1281
1524
  relPath,
1282
1525
  content: file.content,
1283
1526
  mode,
1527
+ ownership: file.ownership ?? "shared",
1284
1528
  secretSlots,
1285
1529
  };
1286
1530
  })
@@ -1332,6 +1576,10 @@ export function hashWorkspacePlan(plan) {
1332
1576
  relPath: file.relPath,
1333
1577
  mode: file.mode,
1334
1578
  contentSha256: sha256(file.content),
1579
+ // Ownership decides whether re-application may replace the file, so two
1580
+ // plans identical in bytes but different here behave differently in the
1581
+ // same workspace — the digest a receipt proves must say which one ran.
1582
+ ownership: file.ownership,
1335
1583
  ...(file.secretSlots.length === 0
1336
1584
  ? {}
1337
1585
  : {
@@ -1355,12 +1603,10 @@ export function hashWorkspacePlan(plan) {
1355
1603
  files,
1356
1604
  env,
1357
1605
  flags: plan.flags.map(normalizeWorkspacePlanArgument),
1358
- ...(plan.systemPrompt === undefined
1359
- ? {}
1360
- : { systemPrompt: plan.systemPrompt }),
1361
- ...(plan.appendSystemPrompt === undefined
1362
- ? {}
1363
- : { appendSystemPrompt: plan.appendSystemPrompt }),
1606
+ ...promptIntentFields(plan, samePromptIntentValue),
1607
+ // A plan that owes a launcher a replacement is not the same plan as one
1608
+ // that owes nothing, even when every byte it writes is identical.
1609
+ ...systemPromptBindingField(plan),
1364
1610
  unsupported,
1365
1611
  ...(requires.length === 0 ? {} : { requires }),
1366
1612
  });
@@ -1378,12 +1624,7 @@ export function agentCandidateProfilePlanMaterial(plan) {
1378
1624
  })),
1379
1625
  env: Object.fromEntries(Object.entries(plan.env).sort(([left], [right]) => compareStrings(left, right))),
1380
1626
  flags: [...plan.flags],
1381
- ...(plan.systemPrompt === undefined
1382
- ? {}
1383
- : { systemPrompt: plan.systemPrompt }),
1384
- ...(plan.appendSystemPrompt === undefined
1385
- ? {}
1386
- : { appendSystemPrompt: plan.appendSystemPrompt }),
1627
+ ...promptIntentFields(plan, samePromptIntentValue),
1387
1628
  unsupported: [...plan.unsupported].sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`)),
1388
1629
  };
1389
1630
  return agentCandidateProfilePlanMaterialSchema.parse(material);
@@ -1443,14 +1684,7 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
1443
1684
  candidatePublicString(value),
1444
1685
  ])),
1445
1686
  flags: plan.flags.map(candidatePublicString),
1446
- ...(plan.systemPrompt === undefined
1447
- ? {}
1448
- : { systemPrompt: candidatePublicString(plan.systemPrompt) }),
1449
- ...(plan.appendSystemPrompt === undefined
1450
- ? {}
1451
- : {
1452
- appendSystemPrompt: candidatePublicString(plan.appendSystemPrompt),
1453
- }),
1687
+ ...promptIntentFields(plan, candidatePublicString),
1454
1688
  unsupported: plan.unsupported,
1455
1689
  }, workspaceDir);
1456
1690
  return {
@@ -1462,12 +1696,7 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
1462
1696
  },
1463
1697
  env: { ...plan.env },
1464
1698
  flags: [...plan.flags],
1465
- ...(plan.systemPrompt === undefined
1466
- ? {}
1467
- : { systemPrompt: plan.systemPrompt }),
1468
- ...(plan.appendSystemPrompt === undefined
1469
- ? {}
1470
- : { appendSystemPrompt: plan.appendSystemPrompt }),
1699
+ ...promptIntentFields(plan, samePromptIntentValue),
1471
1700
  unsupported: [...plan.unsupported],
1472
1701
  };
1473
1702
  }
@@ -1552,12 +1781,8 @@ function resolvePublicWorkspacePlan(plan) {
1552
1781
  files,
1553
1782
  env,
1554
1783
  flags,
1555
- ...(plan.systemPrompt === undefined
1556
- ? {}
1557
- : { systemPrompt: plan.systemPrompt }),
1558
- ...(plan.appendSystemPrompt === undefined
1559
- ? {}
1560
- : { appendSystemPrompt: plan.appendSystemPrompt }),
1784
+ ...promptIntentFields(plan, samePromptIntentValue),
1785
+ ...systemPromptBindingField(plan),
1561
1786
  };
1562
1787
  }
1563
1788
  async function resolvePrivateWorkspacePlan(plan, secretProvider) {
@@ -1623,12 +1848,8 @@ async function resolvePrivateWorkspacePlan(plan, secretProvider) {
1623
1848
  files,
1624
1849
  env,
1625
1850
  flags,
1626
- ...(plan.systemPrompt === undefined
1627
- ? {}
1628
- : { systemPrompt: plan.systemPrompt }),
1629
- ...(plan.appendSystemPrompt === undefined
1630
- ? {}
1631
- : { appendSystemPrompt: plan.appendSystemPrompt }),
1851
+ ...promptIntentFields(plan, samePromptIntentValue),
1852
+ ...systemPromptBindingField(plan),
1632
1853
  };
1633
1854
  }
1634
1855
  function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
@@ -1639,12 +1860,8 @@ function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorks
1639
1860
  typeof value === "string" ? value : { ...value },
1640
1861
  ])),
1641
1862
  flags: plan.flags.map(normalizeWorkspacePlanArgument),
1642
- ...(plan.systemPrompt === undefined
1643
- ? {}
1644
- : { systemPrompt: plan.systemPrompt }),
1645
- ...(plan.appendSystemPrompt === undefined
1646
- ? {}
1647
- : { appendSystemPrompt: plan.appendSystemPrompt }),
1863
+ ...promptIntentFields(plan, samePromptIntentValue),
1864
+ ...systemPromptBindingField(plan),
1648
1865
  unsupported: plan.unsupported.map((entry) => ({ ...entry })),
1649
1866
  written: [...written],
1650
1867
  };
@@ -1666,11 +1883,17 @@ function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, opti
1666
1883
  ...file,
1667
1884
  absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
1668
1885
  }));
1886
+ // A path this package invented can only hold this package's own earlier
1887
+ // output, so re-applying into the same workspace replaces it rather than
1888
+ // failing the turn. `reject` still refuses: that policy is "nothing may
1889
+ // pre-exist here", which is a statement about the workspace, not authorship.
1890
+ const mayReplace = (file) => existingFiles === "allow-identical" && file.ownership === "materializer";
1669
1891
  // Validate the complete plan before the first write so a late traversal or
1670
1892
  // existing symlink cannot leave a partially materialized workspace.
1671
1893
  for (const file of files) {
1672
1894
  assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
1673
1895
  if (workspaceFileExists(file.absPath) &&
1896
+ !mayReplace(file) &&
1674
1897
  (existingFiles === "reject" || !workspaceFileMatches(file))) {
1675
1898
  throw new Error(`Refusing to replace existing workspace file: ${file.relPath}`);
1676
1899
  }
@@ -1707,6 +1930,20 @@ function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, opti
1707
1930
  workspaceFileMatches(entry.file)) {
1708
1931
  continue;
1709
1932
  }
1933
+ if (isFsError(error, "EEXIST") && mayReplace(entry.file)) {
1934
+ // rename() replaces in one step, so no window exists in which the
1935
+ // path is missing and a concurrent spawn reads nothing. The staged
1936
+ // inode moves onto the path, which is what rollback identifies the
1937
+ // file by, so an abort still removes exactly what this call
1938
+ // published — leaving the path ABSENT rather than restored to the
1939
+ // superseded bytes. That is the fail-closed direction: the harness
1940
+ // whose flag names a missing file refuses to start, whereas silently
1941
+ // restored previous-turn bytes would run the wrong prompt.
1942
+ assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
1943
+ renameSync(entry.temporaryPath, entry.file.absPath);
1944
+ entry.published = true;
1945
+ continue;
1946
+ }
1710
1947
  if (isFsError(error, "EEXIST")) {
1711
1948
  throw new Error(`Refusing to replace existing workspace file: ${entry.file.relPath}`, { cause: error });
1712
1949
  }
@@ -1762,12 +1999,8 @@ function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, opti
1762
1999
  spawn: {
1763
2000
  env: { ...resolvedPlan.env },
1764
2001
  flags: [...resolvedPlan.flags],
1765
- ...(resolvedPlan.systemPrompt === undefined
1766
- ? {}
1767
- : { systemPrompt: resolvedPlan.systemPrompt }),
1768
- ...(resolvedPlan.appendSystemPrompt === undefined
1769
- ? {}
1770
- : { appendSystemPrompt: resolvedPlan.appendSystemPrompt }),
2002
+ ...promptIntentFields(resolvedPlan, samePromptIntentValue),
2003
+ ...systemPromptBindingField(resolvedPlan),
1771
2004
  },
1772
2005
  runtimeOnlyFiles: privateRuntimeFiles,
1773
2006
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-profile-materialize",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Materialize an AgentProfile into the native per-harness workspace files (the one shared materializer for cli-bridge, agent-dev-container, and VB).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",