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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -22,10 +22,13 @@
22
22
  * cli-bridge and agent-dev-container — this is the single source those re-export.
23
23
  */
24
24
  export { modelIdsMatch, qualifyModelId } from "./model-id.js";
25
+ export { matchPrimeKernelDeadMarker, PRIME_AGENT_DIR_ENV_VAR, PRIME_AGENT_DIR_ENV_VARS, PRIME_FORK_INSTALL, PRIME_FORK_PIN, PRIME_HARNESS_STATE_ENV_DENYLIST, PRIME_KERNEL_DEAD_MARKERS, PRIME_KERNEL_ENV_PASSTHROUGH, PRIME_MCP_UNSUPPORTED_REASON, PRIME_THINKING_LEVELS, PrimeHarnessStateError, PrimeProfileError, parsePrimeModelsJson, primeAgentDirEnv, primeApiKeyEnvNames, primeThinkingLevel, stripPrimeJsonComments, } from "./prime-fork.js";
25
26
  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";
27
+ import { closeSync, existsSync, fchmodSync, constants as fsConstants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
27
28
  import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32, } from "node:path";
28
29
  import { agentCandidateProfileActivationSchema, agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, agentProfileSchema, agentProfileSecretRefSchema, canonicalCandidateBytes, canonicalCandidateDigest, resolveNativeWebTools, } from "@tangle-network/agent-interface";
30
+ import { modelIdsMatch, qualifyModelId } from "./model-id.js";
31
+ import { PRIME_MCP_UNSUPPORTED_REASON, PrimeHarnessStateError, rejectPrimeControl, rejectPrimeDeclaration, } from "./prime-fork.js";
29
32
  export const UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER = Object.freeze({
30
33
  async get() {
31
34
  return undefined;
@@ -86,6 +89,28 @@ function assertMaterializerHarness(harness) {
86
89
  throw new Error(`Unsupported profile materialization harness "${harness}"`);
87
90
  }
88
91
  }
92
+ /**
93
+ * The subset of {@link BINDABLE_LAUNCH_CONTROLS} the plan HANDS OVER rather than
94
+ * executes: controls a caller reaches only by composing the harness's own
95
+ * configuration, so no file, flag or env var the plan writes can touch them.
96
+ *
97
+ * - `opencode.agent-prompt` — opencode's `agent.<name>.prompt` config field,
98
+ * the one control that replaces its built-in provider prompt. It binds to a
99
+ * SINGLE agent, and the agent is chosen at launch. A caller that builds
100
+ * opencode's config itself and picks the agent there reads neither
101
+ * `plan.files` nor `plan.flags`, so for that one the plan names the control
102
+ * and hands over the text in {@link WorkspacePlan.systemPromptBinding}.
103
+ *
104
+ * Every other bindable control names a capability the plan still lowers itself
105
+ * once the caller has declared the plan's launch inputs really reach the spawn.
106
+ */
107
+ export const EXECUTOR_SYSTEM_PROMPT_BINDINGS = [
108
+ "opencode.agent-prompt",
109
+ ];
110
+ const EXECUTOR_SYSTEM_PROMPT_BINDING_SET = new Set(EXECUTOR_SYSTEM_PROMPT_BINDINGS);
111
+ export function isExecutorSystemPromptBinding(value) {
112
+ return EXECUTOR_SYSTEM_PROMPT_BINDING_SET.has(value);
113
+ }
89
114
  /**
90
115
  * The dimensions `materializeProfile`'s `skip` option honors.
91
116
  *
@@ -120,13 +145,26 @@ function assertSkippableDimensions(skip) {
120
145
  * system-prompt replacement (`agent.<name>.prompt`) binds to the ONE agent
121
146
  * whoever starts the process selects, which a plan-forwarding executor cannot
122
147
  * 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.
148
+ * @tangle-network/agent-interface answers `replace: false` for opencode).
149
+ *
150
+ * The two declarations describe the two executor shapes that CAN reach it, and
151
+ * each gets the lowering it can actually apply:
152
+ *
153
+ * - `systemPrompt` — the executor applies the plan's launch flags to the
154
+ * spawn it owns, so the plan writes the generated `mode: primary` agent and
155
+ * selects it with `--agent`.
156
+ * - `opencode.agent-prompt` — the executor composes opencode's own config and
157
+ * picks the agent there, reading neither plan files nor plan flags, so the
158
+ * plan hands the text over in {@link WorkspacePlan.systemPromptBinding}
159
+ * instead of writing a workspace agent that would never be selected.
160
+ *
161
+ * They are mutually exclusive claims about the same caller: declaring both is a
162
+ * caller bug, not a broader capability.
128
163
  */
129
- export const BINDABLE_LAUNCH_CONTROLS = ["systemPrompt"];
164
+ export const BINDABLE_LAUNCH_CONTROLS = [
165
+ "systemPrompt",
166
+ ...EXECUTOR_SYSTEM_PROMPT_BINDINGS,
167
+ ];
130
168
  const BINDABLE_LAUNCH_CONTROL_SET = new Set(BINDABLE_LAUNCH_CONTROLS);
131
169
  /** Reject a binding declaration this materializer has no lowering for, rather than ignoring it. */
132
170
  function assertBindableLaunchControls(binds) {
@@ -137,7 +175,14 @@ function assertBindableLaunchControls(binds) {
137
175
  throw new Error(`materializeProfile: cannot bind ${unknown.join(", ")}. ` +
138
176
  `Bindable launch controls are: ${BINDABLE_LAUNCH_CONTROLS.join(", ")}.`);
139
177
  }
140
- return new Set(binds);
178
+ const declared = new Set(binds);
179
+ const handedOver = [...EXECUTOR_SYSTEM_PROMPT_BINDINGS].filter((control) => declared.has(control));
180
+ if (declared.has("systemPrompt") && handedOver.length > 0) {
181
+ throw new Error(`materializeProfile: cannot bind systemPrompt together with ${handedOver.join(", ")}. ` +
182
+ "The first says the plan's launch flags reach the spawn; the second says " +
183
+ "the caller composes the harness config itself and reads neither.");
184
+ }
185
+ return declared;
141
186
  }
142
187
  /** The plan was applied, but one or more private staging files remain. */
143
188
  export class WorkspacePlanCleanupError extends AggregateError {
@@ -171,6 +216,66 @@ export function enforceWorkspacePlanSupport(plan, failOnError) {
171
216
  assertWorkspacePlanSupported(plan);
172
217
  return [];
173
218
  }
219
+ /**
220
+ * The two prompt intents a plan carries, projected together onto an outgoing
221
+ * object with each field omitted when the source does not carry it.
222
+ *
223
+ * One helper instead of a pair of inline spreads at each of the digest,
224
+ * receipt, candidate, and spawn boundaries: the intents must travel as a pair,
225
+ * and a projection that copies the replacement and forgets the addition drops
226
+ * it silently — downstream cannot tell "the caller asked for nothing" from
227
+ * "this boundary lost it". Adding a third intent later is one edit here rather
228
+ * than nine.
229
+ */
230
+ function promptIntentFields(source, project) {
231
+ return {
232
+ ...(source.systemPrompt === undefined
233
+ ? {}
234
+ : { systemPrompt: project(source.systemPrompt) }),
235
+ ...(source.appendSystemPrompt === undefined
236
+ ? {}
237
+ : { appendSystemPrompt: project(source.appendSystemPrompt) }),
238
+ };
239
+ }
240
+ /** Identity projection for boundaries that pass the prompt intents through. */
241
+ const samePromptIntentValue = (value) => value;
242
+ /**
243
+ * The launcher-bound intent, projected onto an outgoing object and omitted when
244
+ * the source does not carry one.
245
+ *
246
+ * Beside {@link promptIntentFields} rather than inside it, because the two
247
+ * disagree on both ends: the candidate boundaries that helper also serves have
248
+ * no field for a binding — an {@link AgentCandidateWorkspacePlan} is built by a
249
+ * caller that declares none, so it can never hold one — and a value projection
250
+ * such as `candidatePublicString` has nothing to do to a structured binding.
251
+ * Every boundary that carries the plan's own prompt intents calls both.
252
+ */
253
+ function systemPromptBindingField(source) {
254
+ return source.systemPromptBinding === undefined
255
+ ? {}
256
+ : { systemPromptBinding: { ...source.systemPromptBinding } };
257
+ }
258
+ /**
259
+ * The replacement text this launcher must bind to `control`, or `undefined`
260
+ * when the plan carries no launcher-bound intent.
261
+ *
262
+ * Throws for a binding addressed to a DIFFERENT control: the reader cannot
263
+ * execute it, and answering `undefined` would start the agent with the
264
+ * harness's own prompt still in force while nothing reported a problem — the
265
+ * silent downgrade the binding exists to prevent. This is the seam where an
266
+ * executor that cannot bind an intent says so, by name, instead of running a
267
+ * profile whose prompt never took effect.
268
+ */
269
+ export function takeSystemPromptBinding(spawn, control) {
270
+ const binding = spawn.systemPromptBinding;
271
+ if (!binding)
272
+ return undefined;
273
+ if (binding.control !== control) {
274
+ throw new Error(`Workspace plan carries a system-prompt ${binding.intent} bound to ` +
275
+ `${binding.control}, which this executor cannot apply; it binds ${control}`);
276
+ }
277
+ return binding.systemPrompt;
278
+ }
174
279
  // ─── Verified per-harness conventions (capability matrix) ───────────────────
175
280
  /**
176
281
  * Whether the harness LOADS its {@link CONTEXT_FILE} from cwd on its own.
@@ -204,6 +309,61 @@ export function harnessContextFile(harness) {
204
309
  }
205
310
  return path;
206
311
  }
312
+ /**
313
+ * File codex's `model_instructions_file` config key reads.
314
+ *
315
+ * cwd-relative on purpose: codex resolves the key against the process working
316
+ * directory, which is the same workspace root the plan is applied into, so the
317
+ * flag stays correct under an executor that binds the workspace at a different
318
+ * absolute path (docker, a jail, a remote box).
319
+ */
320
+ const CODEX_SYSTEM_PROMPT_FILE = ".codex/system-prompt.md";
321
+ /**
322
+ * File carrying opencode's ADDITION to its system prompt.
323
+ *
324
+ * Separate from opencode's instruction file rather than merged into it: the two
325
+ * are different privilege levels of the same request, and one file for both
326
+ * would leave the plan unable to say which intent produced which bytes.
327
+ */
328
+ const OPENCODE_APPEND_SYSTEM_PROMPT_FILE = ".opencode/agent-system-prompt.md";
329
+ /**
330
+ * Why a harness refuses a system-prompt REPLACEMENT.
331
+ *
332
+ * The reason names the control that does exist, so the refusal is actionable
333
+ * without a capability-matrix lookup — a bare "unsupported" sends the caller
334
+ * looking for a flag that was never there.
335
+ */
336
+ function systemPromptRefusal(harness) {
337
+ switch (harness) {
338
+ case "opencode":
339
+ // The control exists, so the reason names the two declarations that reach
340
+ // it rather than claiming opencode has none.
341
+ 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"]';
342
+ default:
343
+ return `${harness}: no verified exact system-prompt replacement control`;
344
+ }
345
+ }
346
+ /**
347
+ * Why a harness refuses an ADDITION to the system prompt.
348
+ *
349
+ * gemini puts its context file in the system-prompt position, so the temptation
350
+ * is to lower an addition there. That is refused because GEMINI.md is the ONE
351
+ * file it auto-loads: an addition written there is byte-identical to the same
352
+ * text under `prompt.instructions`, and no consumer could tell which intent the
353
+ * caller expressed. opencode is not refused for the same situation, because it
354
+ * loads exactly the files its config names and can therefore carry the two
355
+ * intents in separate, separately-ordered files.
356
+ */
357
+ function appendSystemPromptRefusal(harness) {
358
+ switch (harness) {
359
+ case "gemini":
360
+ 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";
361
+ case "codex":
362
+ return "codex: no verified additive system-prompt control — its AGENTS.md lands in a developer/user message, not the system channel";
363
+ default:
364
+ return `${harness}: no verified additive system-prompt control`;
365
+ }
366
+ }
207
367
  /** Project context file each harness actually reads from cwd. NEVER a constant. */
208
368
  const CONTEXT_FILE = {
209
369
  "claude-code": "CLAUDE.md",
@@ -220,6 +380,35 @@ const CONTEXT_FILE = {
220
380
  hermes: "HERMES.md", // highest-priority native name; only ONE context loads (first match)
221
381
  openclaw: "AGENTS.md",
222
382
  };
383
+ /**
384
+ * The paths whose only possible author is this package, per
385
+ * {@link PlanFile.ownership}.
386
+ *
387
+ * Codex names no system-prompt file of its own — `model_instructions_file`
388
+ * takes whatever path it is given — and opencode names none either, since it
389
+ * reads exactly the files its config lists. All three names below were chosen
390
+ * here, so a file already at one of them is this materializer's own output from
391
+ * an earlier application into the same workspace.
392
+ *
393
+ * That is an inference from the path name, not verified provenance: nothing
394
+ * checks the existing bytes are prior materializer output, so a workspace that
395
+ * carried a foreign file at one of these invented names is overwritten on the
396
+ * first application. Accepted cost of stateless multi-turn re-application,
397
+ * bounded by the names being ones no harness or user convention uses.
398
+ *
399
+ * Deliberately absent: `.gemini/system.md` (gemini's own documented name),
400
+ * `GEMINI.md`, `CLAUDE.md`, `AGENTS.md`, `opencode.json`. A user's repository
401
+ * can carry any of those, and clobbering one is worse than refusing the turn.
402
+ * For `opencode.json` this means a profile change that alters the
403
+ * `instructions[]` membership between turns of one workspace refuses loudly
404
+ * (the consolidated config no longer matches byte-for-byte) rather than
405
+ * replacing a possibly user-authored config.
406
+ */
407
+ const MATERIALIZER_OWNED_PATHS = new Set([
408
+ CODEX_SYSTEM_PROMPT_FILE,
409
+ OPENCODE_APPEND_SYSTEM_PROMPT_FILE,
410
+ CONTEXT_FILE.opencode,
411
+ ]);
223
412
  /**
224
413
  * cwd-relative native skill dir per harness, or null if the harness has no
225
414
  * cwd skill primitive (skills live in a user/global dir the workspace plan
@@ -271,8 +460,6 @@ const MAX_PROFILE_RESOURCE_BYTES = 32 * 1024 * 1024;
271
460
  const MAX_PROFILE_RESOURCE_REFS = 128;
272
461
  const MAX_CONCURRENT_RESOURCE_FETCHES = 8;
273
462
  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
463
  const SAFE_WRITE_FLAGS = fsConstants.O_WRONLY |
277
464
  fsConstants.O_CREAT |
278
465
  fsConstants.O_EXCL |
@@ -645,6 +832,12 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
645
832
  relPath: normalizedPath,
646
833
  content,
647
834
  source,
835
+ // Only a path this package invented AND generated here is its own to
836
+ // replace; the same path carrying a caller's `resources` mount is that
837
+ // caller's file, whatever its name.
838
+ ...(source === "generated" && MATERIALIZER_OWNED_PATHS.has(normalizedPath)
839
+ ? { ownership: "materializer" }
840
+ : {}),
648
841
  ...(fileMode === undefined ? {} : { mode: validateFileMode(fileMode) }),
649
842
  ...(secretSlots === undefined
650
843
  ? {}
@@ -665,6 +858,14 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
665
858
  if (previousDimension) {
666
859
  throw new Error(`Duplicate profile resource path: ${normalizedPath} (${previousDimension}, ${dimension})`);
667
860
  }
861
+ // Refuse at plan time so {@link MATERIALIZER_OWNED_PATHS} stays true: those
862
+ // paths are replaced on every re-application, so a mount there would be
863
+ // silently overwritten by the next turn rather than kept.
864
+ if (MATERIALIZER_OWNED_PATHS.has(normalizedPath)) {
865
+ throw new Error(`Profile resource path is reserved by the materializer: ${normalizedPath} (${dimension}). ` +
866
+ "This package writes that path itself and replaces it on re-application, so a mount " +
867
+ "there cannot survive; choose another path.");
868
+ }
668
869
  explicitResourcePaths.set(normalizedPath, dimension);
669
870
  pushPlanFile(normalizedPath, content, "user", mode);
670
871
  };
@@ -725,36 +926,84 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
725
926
  }
726
927
  }
727
928
  }
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.
929
+ // 4) PROMPT. Replacing the harness's own system prompt and adding to it are
930
+ // two intents against one channel, and a harness almost never has both. Each
931
+ // lowers ONLY to a control whose measured job is exactly that intent; the
932
+ // other is refused. Substituting one for the other is the silent downgrade
933
+ // this section exists to prevent — an addition standing in for a replacement
934
+ // leaves the instructions the caller asked to delete in force, and a
935
+ // replacement standing in for an addition deletes the harness's own prompt.
936
+ // Neither is visible in the result, which is why both fail closed instead.
734
937
  //
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 = [];
938
+ // `instructions` is a third, lower-privilege surface (the harness's
939
+ // AGENTS.md/CLAUDE.md-style context file). On gemini and opencode that surface
940
+ // does reach the model in the system-prompt position, so an addition can be
941
+ // lowered there ONLY where the plan can keep the two apart. opencode can: it
942
+ // loads exactly the files `opencode.json` `instructions[]` names, so the
943
+ // addition gets its own file registered ahead of the instruction file. gemini
944
+ // cannot: GEMINI.md is the one context file it auto-loads, so an addition
945
+ // lowered there would be byte-indistinguishable from `instructions`, which is
946
+ // one-channel-two-meanings recreated a level down.
947
+ //
948
+ // Which harness owns which intent is the measured harnessSystemPromptIntents
949
+ // table in @tangle-network/agent-interface; the mechanics per owned cell live
950
+ // here.
740
951
  if (profile.prompt?.systemPrompt !== undefined) {
741
- if (h === "opencode") {
952
+ if (h === "claude-code" || h === "pi" || h === "prime") {
953
+ // claude-code `--system-prompt`, pi `--system-prompt`: both take the
954
+ // replacement as a launch argument the executor supplies, and both
955
+ // measurably drop the harness's built-in prompt from the wire request.
956
+ // prime inherits the same flag pair from its pi line (fork
957
+ // cli/args.ts:139-143 → resourceLoaderOptions main.ts:745-746, the
958
+ // replacement deletes the built-in prompt: resource-loader.ts:482-485,
959
+ // system-prompt.ts:72-73).
960
+ plan.systemPrompt = profile.prompt.systemPrompt;
961
+ }
962
+ else if (h === "codex") {
963
+ // codex has no replacement FLAG; its control is the config key
964
+ // `model_instructions_file`, which reads a cwd-relative file and becomes
965
+ // the request's entire `instructions` field. File + flag keeps the plan
966
+ // self-applying for every executor that already forwards plan.flags.
967
+ add(CODEX_SYSTEM_PROMPT_FILE, profile.prompt.systemPrompt);
968
+ plan.flags.push("-c", `model_instructions_file=${CODEX_SYSTEM_PROMPT_FILE}`);
969
+ }
970
+ else if (h === "gemini") {
971
+ add(".gemini/system.md", profile.prompt.systemPrompt);
972
+ plan.env.GEMINI_SYSTEM_MD = "1";
973
+ }
974
+ else if (h === "opencode") {
975
+ // opencode's replacement control is `agent.<name>.prompt`: it binds to
976
+ // ONE agent, and the agent is chosen at launch, so executing it means
977
+ // writing that agent AND selecting it. There are two shapes of executor
978
+ // that can promise one of those, and each gets the lowering it can apply;
979
+ // an executor that promises neither gets the refusal, because a plan
980
+ // written for an unknown launcher cannot deliver either half.
981
+ //
982
+ // No shape can express an EMPTY replacement — an agent `prompt` with no
983
+ // text falls back to the built-in prompt instead of deleting it — so the
984
+ // guard sits above the split and no path bypasses it.
742
985
  const systemPrompt = profile.prompt.systemPrompt;
743
986
  if (systemPrompt.trim().length === 0) {
744
987
  throw new Error("opencode cannot express an empty exact system-prompt replacement");
745
988
  }
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"]');
989
+ if (binds.has("opencode.agent-prompt")) {
990
+ // A caller that composes opencode's own config and picks the agent
991
+ // itself owns the control outright; it does not read plan files or
992
+ // forward plan flags, so a workspace agent would never be selected.
993
+ // Hand it the intent named against the exact control it claimed.
994
+ plan.systemPromptBinding = {
995
+ intent: "replace",
996
+ control: "opencode.agent-prompt",
997
+ systemPrompt,
998
+ };
756
999
  }
757
- else {
1000
+ else if (binds.has("systemPrompt")) {
1001
+ // A caller that applies the plan's launch flags to the spawn it owns
1002
+ // lets the plan execute the control itself: a `mode: primary` agent
1003
+ // file holding the prompt, plus the `--agent` flag that selects it.
1004
+ // Without the flag the file is inert and the built-in prompt the
1005
+ // caller asked to delete stays in force, so the two are pushed
1006
+ // together or not at all.
758
1007
  const primaryAgentName = `${OPENCODE_PRIMARY_AGENT_PREFIX}${sha256(systemPrompt).slice(0, 16)}`;
759
1008
  const primaryAgentPath = `.opencode/agents/${primaryAgentName}.md`;
760
1009
  if (profile.subagents?.[primaryAgentName] !== undefined) {
@@ -764,61 +1013,57 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
764
1013
  add(primaryAgentPath, ["---", "mode: primary", "---", systemPrompt, ""].join("\n"));
765
1014
  plan.flags.push("--agent", primaryAgentName);
766
1015
  }
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";
1016
+ else {
1017
+ unsupported("systemPrompt", systemPromptRefusal(h));
1018
+ }
785
1019
  }
786
1020
  else {
787
- unsupported("systemPrompt", `${h}: no verified exact system-prompt replacement control`);
1021
+ unsupported("systemPrompt", systemPromptRefusal(h));
788
1022
  }
789
1023
  }
1024
+ // Ordered registration list for opencode, whose TUI and headless runs read
1025
+ // ONLY the files named here. Addition first so it frames the lower-privilege
1026
+ // instruction file that follows, matching the order the model receives them.
1027
+ const openCodeInstructionFiles = [];
1028
+ // A launcher that declared `opencode.agent-prompt` composes opencode's config
1029
+ // itself and reads neither plan files nor plan flags, so the workspace
1030
+ // `opencode.json` that registers these additive files would never load.
1031
+ // Lowering them anyway would be the silent drop this module refuses to
1032
+ // perform, so each additive intent is refused by name instead: the launcher
1033
+ // owns the `instructions[]` channel too and must compose it in the config it
1034
+ // builds.
1035
+ const openCodeConfigUnreachable = h === "opencode" && binds.has("opencode.agent-prompt");
790
1036
  if (profile.prompt?.appendSystemPrompt !== undefined) {
791
1037
  const appendSystemPrompt = profile.prompt.appendSystemPrompt;
792
1038
  if (appendSystemPrompt.trim().length === 0) {
793
1039
  throw new Error("prompt.appendSystemPrompt must be non-empty text");
794
1040
  }
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;
1041
+ if (openCodeConfigUnreachable) {
1042
+ 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
1043
  }
802
1044
  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")
1045
+ // opencode composes config-declared `instructions[]` file contents into
1046
+ // the same single role:"system" message as its built-in prompt, which
1047
+ // stays in place (its replacement control is the separate per-agent
1048
+ // `prompt` field). A dedicated file keeps this intent addressable on its
1049
+ // own rather than merged into the instruction file.
1050
+ add(OPENCODE_APPEND_SYSTEM_PROMPT_FILE, appendSystemPrompt.endsWith("\n")
808
1051
  ? appendSystemPrompt
809
1052
  : `${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");
1053
+ openCodeInstructionFiles.push(OPENCODE_APPEND_SYSTEM_PROMPT_FILE);
815
1054
  }
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");
1055
+ else if (h === "claude-code" || h === "pi" || h === "prime") {
1056
+ // claude-code `--append-system-prompt`, pi `--append-system-prompt`: both
1057
+ // add the caller's text to the harness's own system prompt, which stays
1058
+ // on the wire intact. prime inherits the same flag from its pi line (fork
1059
+ // cli/args.ts:139-143, the addition appends after the built-in prompt:
1060
+ // system-prompt.ts:61,109-110). Carried beside the replacement rather
1061
+ // than merged into it, so an executor applies each through its own
1062
+ // control and the plan's identity distinguishes the two intents.
1063
+ plan.appendSystemPrompt = appendSystemPrompt;
819
1064
  }
820
1065
  else {
821
- unsupported("systemPrompt", `${h}: no verified additive system-prompt control`);
1066
+ unsupported("appendSystemPrompt", appendSystemPromptRefusal(h));
822
1067
  }
823
1068
  }
824
1069
  const instr = [];
@@ -833,12 +1078,20 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
833
1078
  unsupported("instructions", "instruction ref is not inline (github refs need async pre-resolution)");
834
1079
  }
835
1080
  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.
1081
+ if (openCodeConfigUnreachable) {
1082
+ 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[]");
1083
+ }
1084
+ else {
1085
+ const instructionPath = CONTEXT_FILE[h];
1086
+ add(instructionPath, `${instr.join("\n\n")}\n`);
1087
+ if (h === "opencode")
1088
+ openCodeInstructionFiles.push(instructionPath);
1089
+ // gemini reads AGENTS.md only via settings; we write GEMINI.md so no extra step.
1090
+ }
841
1091
  }
1092
+ // ONE opencode.json write for the whole prompt section: top-level JSON merge
1093
+ // is last-wins per key, and two `add` calls at the same relPath are a
1094
+ // duplicate-file error, so a second write would drop whichever lost.
842
1095
  if (openCodeInstructionFiles.length > 0) {
843
1096
  add("opencode.json", serializeWorkspacePlanJson({
844
1097
  $schema: "https://opencode.ai/config.json",
@@ -957,14 +1210,7 @@ export function materializeCandidateProfile(profile, harness, opts = {}) {
957
1210
  files: classicPlan.files,
958
1211
  env: candidatePublicRecord(classicPlan.env),
959
1212
  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
- }),
1213
+ ...promptIntentFields(classicPlan, candidatePublicValue),
968
1214
  unsupported: classicPlan.unsupported,
969
1215
  };
970
1216
  }
@@ -1281,6 +1527,7 @@ function normalizePlanFiles(files) {
1281
1527
  relPath,
1282
1528
  content: file.content,
1283
1529
  mode,
1530
+ ownership: file.ownership ?? "shared",
1284
1531
  secretSlots,
1285
1532
  };
1286
1533
  })
@@ -1332,6 +1579,10 @@ export function hashWorkspacePlan(plan) {
1332
1579
  relPath: file.relPath,
1333
1580
  mode: file.mode,
1334
1581
  contentSha256: sha256(file.content),
1582
+ // Ownership decides whether re-application may replace the file, so two
1583
+ // plans identical in bytes but different here behave differently in the
1584
+ // same workspace — the digest a receipt proves must say which one ran.
1585
+ ownership: file.ownership,
1335
1586
  ...(file.secretSlots.length === 0
1336
1587
  ? {}
1337
1588
  : {
@@ -1355,12 +1606,10 @@ export function hashWorkspacePlan(plan) {
1355
1606
  files,
1356
1607
  env,
1357
1608
  flags: plan.flags.map(normalizeWorkspacePlanArgument),
1358
- ...(plan.systemPrompt === undefined
1359
- ? {}
1360
- : { systemPrompt: plan.systemPrompt }),
1361
- ...(plan.appendSystemPrompt === undefined
1362
- ? {}
1363
- : { appendSystemPrompt: plan.appendSystemPrompt }),
1609
+ ...promptIntentFields(plan, samePromptIntentValue),
1610
+ // A plan that owes a launcher a replacement is not the same plan as one
1611
+ // that owes nothing, even when every byte it writes is identical.
1612
+ ...systemPromptBindingField(plan),
1364
1613
  unsupported,
1365
1614
  ...(requires.length === 0 ? {} : { requires }),
1366
1615
  });
@@ -1378,12 +1627,7 @@ export function agentCandidateProfilePlanMaterial(plan) {
1378
1627
  })),
1379
1628
  env: Object.fromEntries(Object.entries(plan.env).sort(([left], [right]) => compareStrings(left, right))),
1380
1629
  flags: [...plan.flags],
1381
- ...(plan.systemPrompt === undefined
1382
- ? {}
1383
- : { systemPrompt: plan.systemPrompt }),
1384
- ...(plan.appendSystemPrompt === undefined
1385
- ? {}
1386
- : { appendSystemPrompt: plan.appendSystemPrompt }),
1630
+ ...promptIntentFields(plan, samePromptIntentValue),
1387
1631
  unsupported: [...plan.unsupported].sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`)),
1388
1632
  };
1389
1633
  return agentCandidateProfilePlanMaterialSchema.parse(material);
@@ -1443,14 +1687,7 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
1443
1687
  candidatePublicString(value),
1444
1688
  ])),
1445
1689
  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
- }),
1690
+ ...promptIntentFields(plan, candidatePublicString),
1454
1691
  unsupported: plan.unsupported,
1455
1692
  }, workspaceDir);
1456
1693
  return {
@@ -1462,12 +1699,7 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
1462
1699
  },
1463
1700
  env: { ...plan.env },
1464
1701
  flags: [...plan.flags],
1465
- ...(plan.systemPrompt === undefined
1466
- ? {}
1467
- : { systemPrompt: plan.systemPrompt }),
1468
- ...(plan.appendSystemPrompt === undefined
1469
- ? {}
1470
- : { appendSystemPrompt: plan.appendSystemPrompt }),
1702
+ ...promptIntentFields(plan, samePromptIntentValue),
1471
1703
  unsupported: [...plan.unsupported],
1472
1704
  };
1473
1705
  }
@@ -1552,12 +1784,8 @@ function resolvePublicWorkspacePlan(plan) {
1552
1784
  files,
1553
1785
  env,
1554
1786
  flags,
1555
- ...(plan.systemPrompt === undefined
1556
- ? {}
1557
- : { systemPrompt: plan.systemPrompt }),
1558
- ...(plan.appendSystemPrompt === undefined
1559
- ? {}
1560
- : { appendSystemPrompt: plan.appendSystemPrompt }),
1787
+ ...promptIntentFields(plan, samePromptIntentValue),
1788
+ ...systemPromptBindingField(plan),
1561
1789
  };
1562
1790
  }
1563
1791
  async function resolvePrivateWorkspacePlan(plan, secretProvider) {
@@ -1623,12 +1851,8 @@ async function resolvePrivateWorkspacePlan(plan, secretProvider) {
1623
1851
  files,
1624
1852
  env,
1625
1853
  flags,
1626
- ...(plan.systemPrompt === undefined
1627
- ? {}
1628
- : { systemPrompt: plan.systemPrompt }),
1629
- ...(plan.appendSystemPrompt === undefined
1630
- ? {}
1631
- : { appendSystemPrompt: plan.appendSystemPrompt }),
1854
+ ...promptIntentFields(plan, samePromptIntentValue),
1855
+ ...systemPromptBindingField(plan),
1632
1856
  };
1633
1857
  }
1634
1858
  function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
@@ -1639,12 +1863,8 @@ function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorks
1639
1863
  typeof value === "string" ? value : { ...value },
1640
1864
  ])),
1641
1865
  flags: plan.flags.map(normalizeWorkspacePlanArgument),
1642
- ...(plan.systemPrompt === undefined
1643
- ? {}
1644
- : { systemPrompt: plan.systemPrompt }),
1645
- ...(plan.appendSystemPrompt === undefined
1646
- ? {}
1647
- : { appendSystemPrompt: plan.appendSystemPrompt }),
1866
+ ...promptIntentFields(plan, samePromptIntentValue),
1867
+ ...systemPromptBindingField(plan),
1648
1868
  unsupported: plan.unsupported.map((entry) => ({ ...entry })),
1649
1869
  written: [...written],
1650
1870
  };
@@ -1666,11 +1886,17 @@ function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, opti
1666
1886
  ...file,
1667
1887
  absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
1668
1888
  }));
1889
+ // A path this package invented can only hold this package's own earlier
1890
+ // output, so re-applying into the same workspace replaces it rather than
1891
+ // failing the turn. `reject` still refuses: that policy is "nothing may
1892
+ // pre-exist here", which is a statement about the workspace, not authorship.
1893
+ const mayReplace = (file) => existingFiles === "allow-identical" && file.ownership === "materializer";
1669
1894
  // Validate the complete plan before the first write so a late traversal or
1670
1895
  // existing symlink cannot leave a partially materialized workspace.
1671
1896
  for (const file of files) {
1672
1897
  assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
1673
1898
  if (workspaceFileExists(file.absPath) &&
1899
+ !mayReplace(file) &&
1674
1900
  (existingFiles === "reject" || !workspaceFileMatches(file))) {
1675
1901
  throw new Error(`Refusing to replace existing workspace file: ${file.relPath}`);
1676
1902
  }
@@ -1707,6 +1933,20 @@ function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, opti
1707
1933
  workspaceFileMatches(entry.file)) {
1708
1934
  continue;
1709
1935
  }
1936
+ if (isFsError(error, "EEXIST") && mayReplace(entry.file)) {
1937
+ // rename() replaces in one step, so no window exists in which the
1938
+ // path is missing and a concurrent spawn reads nothing. The staged
1939
+ // inode moves onto the path, which is what rollback identifies the
1940
+ // file by, so an abort still removes exactly what this call
1941
+ // published — leaving the path ABSENT rather than restored to the
1942
+ // superseded bytes. That is the fail-closed direction: the harness
1943
+ // whose flag names a missing file refuses to start, whereas silently
1944
+ // restored previous-turn bytes would run the wrong prompt.
1945
+ assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
1946
+ renameSync(entry.temporaryPath, entry.file.absPath);
1947
+ entry.published = true;
1948
+ continue;
1949
+ }
1710
1950
  if (isFsError(error, "EEXIST")) {
1711
1951
  throw new Error(`Refusing to replace existing workspace file: ${entry.file.relPath}`, { cause: error });
1712
1952
  }
@@ -1762,12 +2002,8 @@ function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, opti
1762
2002
  spawn: {
1763
2003
  env: { ...resolvedPlan.env },
1764
2004
  flags: [...resolvedPlan.flags],
1765
- ...(resolvedPlan.systemPrompt === undefined
1766
- ? {}
1767
- : { systemPrompt: resolvedPlan.systemPrompt }),
1768
- ...(resolvedPlan.appendSystemPrompt === undefined
1769
- ? {}
1770
- : { appendSystemPrompt: resolvedPlan.appendSystemPrompt }),
2005
+ ...promptIntentFields(resolvedPlan, samePromptIntentValue),
2006
+ ...systemPromptBindingField(resolvedPlan),
1771
2007
  },
1772
2008
  runtimeOnlyFiles: privateRuntimeFiles,
1773
2009
  };
@@ -2940,12 +3176,29 @@ function materializeCommand(h, name, body, add, unsupported) {
2940
3176
  // and the addition appends after it (system-prompt.ts:61,109-110). Values
2941
3177
  // are materialized as FILES under the agent dir because the flag value is
2942
3178
  // read as a file when the path exists (resource-loader.ts:41-56), which
2943
- // keeps literal-vs-path resolution deterministic.
3179
+ // keeps literal-vs-path resolution deterministic AND removes the argv size
3180
+ // ceiling an inline flag value would run into (Linux MAX_ARG_STRLEN).
3181
+ //
3182
+ // - `prompt.instructions` / `resources.instructions` → `<agentDir>/AGENTS.md`.
3183
+ // The agent dir IS an instruction surface: `loadProjectContextFiles`
3184
+ // (resource-loader.ts:76) reads the agent dir's own AGENTS.md/CLAUDE.md as
3185
+ // the GLOBAL context file (:86 `loadContextFileFromDir(resolvedAgentDir)`,
3186
+ // candidates at :59) BEFORE walking the cwd ancestors (:94-112), and the
3187
+ // result is loaded unless `--no-context-files` (:476). Writing it here
3188
+ // touches no caller workspace content, which is what the agent-dir lowering
3189
+ // exists to guarantee. (`materializeProfile(profile, "prime")` lowers the
3190
+ // same intent to the cwd AGENTS.md for callers that own the workspace
3191
+ // instead of the agent dir.)
2944
3192
  //
2945
3193
  // - `resources.skills` (inline refs) → `--skill <agentDir>/profile/skills`
2946
3194
  // (cli/args.ts:207-209 → main.ts:652 → agent-session-config.ts:67 →
2947
- // additionalSkillPaths, resource-loader.ts:437-442). The loader recurses
2948
- // into `<name>/SKILL.md` subdirs (skills.ts:328-372) and loaded skills are
3195
+ // additionalSkillPaths, resource-loader.ts:437-442). An explicit flag rather
3196
+ // than the agent dir's auto-discovered `skills/` tree (package-manager.ts:884
3197
+ // global base, :2171 skills dir, :2240-2246 discovery), which also loads but
3198
+ // goes through the fork's global-vs-project dedupe (package-manager.ts:880,
3199
+ // "project scope wins over global") — so an unrelated project-scope skill of
3200
+ // the same name silently shadows the profile's. The loader recurses into
3201
+ // `<name>/SKILL.md` subdirs (skills.ts:328-372) and loaded skills are
2949
3202
  // rendered into the system prompt (agent-session.ts:4272-4291,
2950
3203
  // system-prompt.ts:87-89). The fork silently DROPS a SKILL.md without a
2951
3204
  // frontmatter description (skills.ts:416-419), so content is normalized
@@ -2959,25 +3212,17 @@ function materializeCommand(h, name, body, add, unsupported) {
2959
3212
  // reaches the same store through the host-set RLM_GLOBAL_HARNESS_STATE_DIR
2960
3213
  // (agent-session.ts:8805).
2961
3214
  //
2962
- // Kept REJECTED with the fork evidence:
3215
+ // Kept REJECTED with the fork evidence — see {@link PRIME_MCP_UNSUPPORTED_REASON}
3216
+ // for `mcp`/`connections`, and the per-control reasons in
3217
+ // {@link readPrimeProfileControls} for permissions, tools, hooks, modes,
3218
+ // confidential, and the resource kinds with no agent-dir read path.
2963
3219
  //
2964
- // - `mcp`: the fork's MCP config surface (`mcpServers` in
2965
- // `<agentDir>/settings.json`, settings-manager.ts:98-120,219) IS read at
2966
- // startup, but a user-declared server never becomes model-visible: the host
2967
- // McpManager only registers OAuth providers and serves mcp.config/refresh
2968
- // host-requests (core/mcp/mcp-manager.ts:62-124,156-181), skips stdio
2969
- // servers entirely (mcp-manager.ts:73), and nothing generates a skill or
2970
- // prompt section for it — model-visible MCP is exactly the built-in
2971
- // linear/notion catalog skills (packages/coding-agent/skills/…). Wiring
2972
- // `profile.mcp` into settings.json would materialize config the model can
2973
- // never discover: a silently dropped control.
2974
- //
2975
- // - `prompt.instructions`: the fork's caller-instruction surface is the
2976
- // workspace AGENTS.md/CLAUDE.md context file (resource-loader.ts:58-66),
2977
- // i.e. caller workspace content this agent-dir lowering must not write
2978
- // into. (`materializeProfile(profile, "prime")` lowers instructions to the
2979
- // cwd AGENTS.md instead, which the fork walks from cwd:
2980
- // resource-loader.ts:94-109.)
3220
+ // The store is AGENT-OWNED: it is written by the agent mid-session and rides
3221
+ // every later system prompt. A store that exists but no longer parses
3222
+ // therefore fails the turn ({@link PrimeHarnessStateError}) rather than being
3223
+ // reseeded from empty — the fork's own loader degrades to empty in-process
3224
+ // (refinement.ts:290-300), but an executor that OVERWRITES the file makes that
3225
+ // degradation permanent and destroys the evidence of what happened.
2981
3226
  /** Skill name rules enforced by the fork's Agent Skills loader (skills.ts:122-147). */
2982
3227
  const PRIME_SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2983
3228
  const PRIME_MAX_SKILL_NAME_LENGTH = 64;
@@ -2985,6 +3230,8 @@ const PRIME_PROFILE_DIR_NAME = "profile";
2985
3230
  const PRIME_SKILLS_DIR_NAME = "skills";
2986
3231
  const PRIME_SYSTEM_PROMPT_FILE = "system-prompt.md";
2987
3232
  const PRIME_APPEND_SYSTEM_PROMPT_FILE = "append-system-prompt.md";
3233
+ /** Agent-dir global context file the fork reads (resource-loader.ts:59,86). */
3234
+ const PRIME_CONTEXT_FILE = "AGENTS.md";
2988
3235
  /** Fork continual-harness store location under the agent dir (refinement.ts:269-279). */
2989
3236
  const PRIME_HARNESS_DIR_NAME = "harness";
2990
3237
  const PRIME_HARNESS_STATE_FILE = "harness_state.json";
@@ -2995,22 +3242,101 @@ const PRIME_HARNESS_KINDS = ["prompt", "memory", "skill", "subagent"];
2995
3242
  * touched.
2996
3243
  */
2997
3244
  const PRIME_PROFILE_ENTRY_SOURCE = "profile";
2998
- function rejectPrimeControl(control, reason) {
2999
- throw new Error(`prime does not support AgentProfile ${control}: ${reason}`);
3245
+ /**
3246
+ * Provenance markers written by executors that predate this shared lowering.
3247
+ *
3248
+ * Pruning keys on the source tag, so an executor that switches to the shared
3249
+ * implementation would otherwise leave its own earlier entries behind forever:
3250
+ * they match no current profile and no current source, and the model's
3251
+ * spawnable roster silently accumulates duplicates of every subagent. They are
3252
+ * adopted (pruned or replaced) on the first shared run instead.
3253
+ */
3254
+ const PRIME_LEGACY_PROFILE_ENTRY_SOURCES = [
3255
+ "cli-bridge-profile",
3256
+ ];
3257
+ /** True when the controls materialize anything at all. */
3258
+ export function hasPrimeProfileMaterial(controls) {
3259
+ return (controls.systemPrompt !== undefined ||
3260
+ controls.appendSystemPrompt !== undefined ||
3261
+ primeProfileNeedsAgentDir(controls) ||
3262
+ controls.skills.length > 0);
3263
+ }
3264
+ /**
3265
+ * True when the controls must WRITE INTO the agent dir itself.
3266
+ *
3267
+ * Prompt files and skill dirs are named by a flag, so they can live anywhere
3268
+ * the agent can read (`--system-prompt` / `--append-system-prompt` resolve a
3269
+ * path when it exists, fork resource-loader.ts:41-56; `--skill` takes an
3270
+ * arbitrary dir, :437-442). The context file and the continual-harness store
3271
+ * are located BY the agent dir and have no flag, so they cannot move.
3272
+ *
3273
+ * An executor pointed at an agent dir it does not own (an operator's shared
3274
+ * dir) uses this to refuse exactly the controls it would have to write there,
3275
+ * while still binding the ones it can materialize elsewhere.
3276
+ */
3277
+ export function primeProfileNeedsAgentDir(controls) {
3278
+ return controls.instructions !== undefined || controls.subagents.length > 0;
3279
+ }
3280
+ /**
3281
+ * Read one instruction block, which the profile may carry inline or as a
3282
+ * resource ref. Only inline refs are accepted: resolving a GitHub-backed ref
3283
+ * fetches remote content, which is `resolveAgentProfileResources`' job and
3284
+ * must happen before a profile reaches an executor.
3285
+ */
3286
+ function primeInstructionBlock(value, control) {
3287
+ if (typeof value === "string")
3288
+ return value;
3289
+ if (value.kind === "inline")
3290
+ return value.content;
3291
+ rejectPrimeControl(control, "only inline resource refs are accepted — resolve GitHub-backed refs " +
3292
+ "(resolveAgentProfileResources) before handing the profile to this lowering.");
3000
3293
  }
3001
3294
  /**
3002
3295
  * Validate and extract the profile controls the prime agent-dir lowering
3003
- * materializes. Throws on any control (or control facet) the fork has no
3004
- * verified read path for — partial acceptance would silently drop the rest.
3296
+ * materializes. Throws {@link PrimeProfileError} on any control (or control
3297
+ * facet) the fork has no verified read path for — partial acceptance would
3298
+ * silently drop the rest.
3005
3299
  */
3006
3300
  export function readPrimeProfileControls(profile) {
3007
3301
  const controls = { skills: [], subagents: [] };
3008
3302
  if (!profile)
3009
3303
  return controls;
3010
3304
  if (profile.harness !== undefined && profile.harness !== "prime") {
3011
- throw new Error(`prime cannot run a profile pinned to harness "${profile.harness}" — ` +
3305
+ rejectPrimeDeclaration("harness", `prime cannot run a profile pinned to harness "${profile.harness}" — ` +
3012
3306
  'only "prime" (or an unset harness preference) is accepted.');
3013
3307
  }
3308
+ // An entry with `enabled: false` is OFF, not requested. A fully disabled map
3309
+ // asks for nothing, so accepting it drops no control — which is the only
3310
+ // thing this refusal exists to prevent.
3311
+ const requestedMcp = Object.keys(activeMcpServers(profile.mcp));
3312
+ if (requestedMcp.length > 0) {
3313
+ rejectPrimeControl(`mcp: ${requestedMcp.join(", ")}`, PRIME_MCP_UNSUPPORTED_REASON);
3314
+ }
3315
+ if (profile.connections !== undefined) {
3316
+ rejectPrimeControl("connections", `a hub connection grant resolves to MCP tools, and ${PRIME_MCP_UNSUPPORTED_REASON}`);
3317
+ }
3318
+ if (profile.permissions !== undefined) {
3319
+ rejectPrimeControl("permissions", "prime-agent exposes no permission control at all (full flag scan of fork " +
3320
+ "cli/args.ts:93-295; native tools run unconditionally), so a permission map " +
3321
+ "would be accepted and never enforced.");
3322
+ }
3323
+ if (profile.tools !== undefined) {
3324
+ rejectPrimeControl("tools", "the only per-run tool control is `--tools` over the single builtin `ipython` " +
3325
+ "(fork cli/args.ts:63-64,156-167); bash/edit and the rest are extension-provided " +
3326
+ "and not addressable per run, so a harness-neutral tool map cannot bind to it.");
3327
+ }
3328
+ if (profile.hooks !== undefined) {
3329
+ rejectPrimeControl("hooks", "prime-agent has no hook mechanism (fork cli/args.ts flag scan; extensions are " +
3330
+ "host-trusted JS modules loaded into the agent process, not caller command hooks).");
3331
+ }
3332
+ if (profile.modes !== undefined) {
3333
+ rejectPrimeControl("modes", "prime-agent has no mode concept (fork cli/args.ts flag scan).");
3334
+ }
3335
+ if (profile.confidential !== undefined) {
3336
+ rejectPrimeControl("confidential", "confidential execution is a property of the executor's isolation boundary, not " +
3337
+ "of the agent dir; this lowering cannot establish it.");
3338
+ }
3339
+ const instructionBlocks = [];
3014
3340
  if (profile.prompt) {
3015
3341
  const { systemPrompt, appendSystemPrompt, instructions } = profile.prompt;
3016
3342
  const knownPromptFields = new Set([
@@ -3024,15 +3350,9 @@ export function readPrimeProfileControls(profile) {
3024
3350
  if (unknown.length > 0) {
3025
3351
  rejectPrimeControl(`prompt fields: ${unknown.join(", ")}`, "no verified prime-agent read path exists for them.");
3026
3352
  }
3027
- if (instructions !== undefined) {
3028
- rejectPrimeControl("prompt.instructions", "prime-agent's caller-instruction surface is the workspace " +
3029
- "AGENTS.md context file (fork resource-loader.ts:58-66), which is " +
3030
- "caller workspace content this adapter must not write into. Use " +
3031
- "prompt.appendSystemPrompt instead.");
3032
- }
3033
3353
  if (systemPrompt !== undefined) {
3034
3354
  if (typeof systemPrompt !== "string" || systemPrompt.trim() === "") {
3035
- rejectPrimeControl("prompt.systemPrompt", "an empty replacement would delete the built-in system prompt and " +
3355
+ rejectPrimeDeclaration("prompt.systemPrompt", "an empty replacement would delete the built-in system prompt and " +
3036
3356
  "install nothing.");
3037
3357
  }
3038
3358
  controls.systemPrompt = systemPrompt;
@@ -3040,52 +3360,69 @@ export function readPrimeProfileControls(profile) {
3040
3360
  if (appendSystemPrompt !== undefined) {
3041
3361
  if (typeof appendSystemPrompt !== "string" ||
3042
3362
  appendSystemPrompt.trim() === "") {
3043
- rejectPrimeControl("prompt.appendSystemPrompt", "must be non-empty text.");
3363
+ rejectPrimeDeclaration("prompt.appendSystemPrompt", "must be non-empty text.");
3044
3364
  }
3045
3365
  controls.appendSystemPrompt = appendSystemPrompt;
3046
3366
  }
3367
+ for (const block of instructions ?? []) {
3368
+ if (typeof block === "string" && block.trim() !== "")
3369
+ instructionBlocks.push(block);
3370
+ }
3047
3371
  }
3048
3372
  if (profile.resources) {
3049
- const { skills, failOnError, ...unsupported } = profile.resources;
3373
+ const { skills, instructions, failOnError, ...unsupported } = profile.resources;
3050
3374
  const rejectedKeys = Object.entries(unsupported)
3051
3375
  .filter(([, value]) => value !== undefined)
3052
3376
  .map(([key]) => key);
3053
3377
  if (rejectedKeys.length > 0) {
3054
- rejectPrimeControl(`resources: ${rejectedKeys.join(", ")}`, "only resources.skills has a verified prime-agent read path " +
3055
- "(--skill dirs, fork resource-loader.ts:437-442).");
3378
+ rejectPrimeControl(`resources: ${rejectedKeys.join(", ")}`, "only resources.skills (--skill dirs, fork resource-loader.ts:437-442) and " +
3379
+ "resources.instructions (agent-dir AGENTS.md, resource-loader.ts:86) have a " +
3380
+ "verified prime-agent read path. Generic workspace files, tool files, " +
3381
+ "subagent-definition files and prompt templates either have no headless read " +
3382
+ "path or would run caller-authored code inside the agent process.");
3056
3383
  }
3057
3384
  if (failOnError === false) {
3058
- rejectPrimeControl("resources.failOnError: false", "this adapter always fails loud on a resource it cannot " +
3385
+ rejectPrimeControl("resources.failOnError: false", "this lowering always fails loud on a resource it cannot " +
3059
3386
  "materialize; lenient materialization is not implemented.");
3060
3387
  }
3388
+ if (instructions !== undefined) {
3389
+ const block = primeInstructionBlock(instructions, "resources.instructions");
3390
+ if (block.trim() !== "")
3391
+ instructionBlocks.push(block);
3392
+ }
3061
3393
  const seen = new Set();
3062
- for (const ref of skills ?? []) {
3394
+ for (const [index, ref] of (skills ?? []).entries()) {
3063
3395
  if (ref.kind !== "inline") {
3064
- rejectPrimeControl(`resources.skills ref "${"path" in ref ? ref.path : ""}"`, "only inline skill refs are accepted — resolve GitHub refs " +
3396
+ rejectPrimeControl(`resources.skills[${index}]`, "only inline skill refs are accepted — resolve GitHub-backed refs " +
3065
3397
  "(resolveAgentProfileResources) before handing the profile to " +
3066
- "this adapter.");
3398
+ "this lowering.");
3067
3399
  }
3068
3400
  const name = ref.name;
3069
3401
  if (!PRIME_SKILL_NAME_RE.test(name) ||
3070
3402
  name.length > PRIME_MAX_SKILL_NAME_LENGTH) {
3071
- rejectPrimeControl(`resources.skills name "${name}"`, "prime-agent skill names must be lowercase a-z0-9 with single " +
3072
- `hyphens, at most ${PRIME_MAX_SKILL_NAME_LENGTH} chars (fork skills.ts:122-147).`);
3403
+ rejectPrimeDeclaration(`resources.skills[${index}]`, `name ${JSON.stringify(name)} is not a valid prime skill name — it becomes the ` +
3404
+ "skill directory name, so it must be lowercase a-z0-9 with single hyphens, at " +
3405
+ `most ${PRIME_MAX_SKILL_NAME_LENGTH} chars (fork skills.ts:122-147).`);
3073
3406
  }
3074
3407
  if (seen.has(name)) {
3075
- rejectPrimeControl(`resources.skills name "${name}"`, "duplicate skill names collide in the fork's skill registry.");
3408
+ rejectPrimeDeclaration("resources.skills", `declares ${JSON.stringify(name)} twice; duplicate skill names collide in the ` +
3409
+ "fork's skill registry, which keeps only the first and warns.");
3076
3410
  }
3077
3411
  seen.add(name);
3078
3412
  if (ref.content.trim() === "") {
3079
- rejectPrimeControl(`resources.skills "${name}"`, "skill content is empty.");
3413
+ rejectPrimeDeclaration(`resources.skills[${index}]`, `skill ${JSON.stringify(name)} declares no content.`);
3080
3414
  }
3081
3415
  controls.skills.push({ name, content: ref.content });
3082
3416
  }
3083
3417
  }
3418
+ if (instructionBlocks.length > 0) {
3419
+ controls.instructions = instructionBlocks.join("\n\n");
3420
+ }
3084
3421
  if (profile.subagents) {
3085
3422
  const usedIds = new Map();
3086
3423
  for (const [name, spec] of Object.entries(profile.subagents)) {
3087
3424
  if (name.trim() === "") {
3088
- rejectPrimeControl("subagents", "a subagent name must be non-empty.");
3425
+ rejectPrimeDeclaration("subagents", "a subagent name must be non-empty.");
3089
3426
  }
3090
3427
  const inexpressible = [
3091
3428
  ["model", spec.model],
@@ -3096,19 +3433,20 @@ export function readPrimeProfileControls(profile) {
3096
3433
  .filter(([, value]) => value !== undefined)
3097
3434
  .map(([key]) => key);
3098
3435
  if (inexpressible.length > 0) {
3099
- rejectPrimeControl(`subagents["${name}"] fields: ${inexpressible.join(", ")}`, "a prime continual-harness subagent spec is prompt-roster " +
3436
+ rejectPrimeControl(`subagents[${JSON.stringify(name)}] fields: ${inexpressible.join(", ")}`, "a prime continual-harness subagent spec is prompt-roster " +
3100
3437
  "guidance for `await rlm('<task>')` (fork refinement.ts:483-496) " +
3101
3438
  "and pins no model, tool set, permission, or step budget on the " +
3102
3439
  "spawned child.");
3103
3440
  }
3104
3441
  if (!spec.description?.trim() && !spec.prompt?.trim()) {
3105
- rejectPrimeControl(`subagents["${name}"]`, "a spec needs a description and/or prompt — an empty entry gives " +
3106
- "the model an empty roster line.");
3442
+ rejectPrimeDeclaration(`subagents[${JSON.stringify(name)}]`, "declares no behavior — a spec needs a description and/or prompt, since an " +
3443
+ "empty entry gives the model an empty roster line.");
3107
3444
  }
3108
3445
  const id = primeSubagentEntryId(name);
3109
3446
  const clash = usedIds.get(id);
3110
3447
  if (clash !== undefined) {
3111
- rejectPrimeControl(`subagents["${name}"]`, `its id "${id}" collides with subagent "${clash}".`);
3448
+ rejectPrimeDeclaration(`subagents[${JSON.stringify(name)}]`, `collides with subagent ${JSON.stringify(clash)} on harness-state id ` +
3449
+ `${JSON.stringify(id)}.`);
3112
3450
  }
3113
3451
  usedIds.set(id, name);
3114
3452
  controls.subagents.push({
@@ -3121,6 +3459,39 @@ export function readPrimeProfileControls(profile) {
3121
3459
  }
3122
3460
  return controls;
3123
3461
  }
3462
+ /**
3463
+ * Refuse a run whose selected model disagrees with the profile's own.
3464
+ *
3465
+ * One authority per run: a profile names the model it was written and scored
3466
+ * against, so running it on a different one silently scores a different agent.
3467
+ * Both sides go through {@link modelIdsMatch}, so the qualification level a
3468
+ * caller happened to use is not a conflict.
3469
+ *
3470
+ * `provider` is the executor's PROVIDER SELECTION, and is compared only when
3471
+ * the executor's provider is part of model identity. An executor whose
3472
+ * provider key is merely a namespace inside the agent dir passes it as
3473
+ * undefined and gets the model-name comparison alone.
3474
+ */
3475
+ export function assertPrimeModelAgreement(profile, selected) {
3476
+ const declared = profile?.model?.default;
3477
+ const declaredProvider = profile?.model?.provider;
3478
+ if (declared !== undefined) {
3479
+ const selectedId = qualifyModelId(selected.provider, selected.model);
3480
+ const declaredId = qualifyModelId(declaredProvider, declared);
3481
+ if (!modelIdsMatch(selectedId, declaredId, selected.provider)) {
3482
+ rejectPrimeDeclaration("model.default", `the run selects ${JSON.stringify(selectedId)} but the profile names ` +
3483
+ `${JSON.stringify(declaredId)}; a profile must not be scored against a model it ` +
3484
+ "does not name.");
3485
+ }
3486
+ return;
3487
+ }
3488
+ if (selected.provider !== undefined &&
3489
+ declaredProvider !== undefined &&
3490
+ declaredProvider !== selected.provider) {
3491
+ rejectPrimeDeclaration("model.provider", `the run selects provider ${JSON.stringify(selected.provider)} but the profile names ` +
3492
+ `${JSON.stringify(declaredProvider)}.`);
3493
+ }
3494
+ }
3124
3495
  /** Deterministic harness-state entry id for a profile subagent name. */
3125
3496
  export function primeSubagentEntryId(name) {
3126
3497
  const slug = name
@@ -3130,96 +3501,127 @@ export function primeSubagentEntryId(name) {
3130
3501
  return `profile-${slug || "subagent"}`;
3131
3502
  }
3132
3503
  /**
3133
- * Materialize the validated controls into `agentDir` and return the extra
3134
- * spawn args that bind them. Runs before every spawn (same place models.json
3135
- * is materialized), so a continual agent dir is re-pinned to the current
3136
- * profile on each fresh prime process.
3504
+ * Write one agent-dir file atomically (temp + rename).
3505
+ *
3506
+ * Every file this lowering writes lands in a directory a LIVE agent may be
3507
+ * reading: prime reloads models.json at model-resolution time and re-reads the
3508
+ * harness store on every system-prompt build, so a partial write is observable.
3509
+ * `mode` is applied to the temp file before the rename, so the final file is
3510
+ * never briefly world-readable.
3511
+ */
3512
+ export function writePrimeAgentFile(path, data, mode = 0o600) {
3513
+ mkdirSync(dirname(path), { recursive: true });
3514
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
3515
+ writeFileSync(temp, data, { mode });
3516
+ renameSync(temp, path);
3517
+ }
3518
+ /**
3519
+ * Materialize the validated controls and return the extra spawn args that bind
3520
+ * them. Runs before every spawn (same place models.json is materialized), so a
3521
+ * continual agent dir is re-pinned to the current profile on each fresh prime
3522
+ * process.
3137
3523
  */
3138
- export function materializePrimeProfileControls(agentDir, controls, warn) {
3524
+ export function materializePrimeProfileControls(agentDir, controls, options = {}) {
3525
+ const { flagFileRoot = agentDir, warn } = options;
3139
3526
  const args = [];
3140
- const profileDir = join(agentDir, PRIME_PROFILE_DIR_NAME);
3527
+ const profileDir = join(flagFileRoot, PRIME_PROFILE_DIR_NAME);
3141
3528
  if (controls.systemPrompt !== undefined) {
3142
- mkdirSync(profileDir, { recursive: true });
3143
3529
  const promptPath = join(profileDir, PRIME_SYSTEM_PROMPT_FILE);
3144
- writeFileSync(promptPath, controls.systemPrompt, "utf8");
3530
+ writePrimeAgentFile(promptPath, controls.systemPrompt);
3145
3531
  args.push("--system-prompt", promptPath);
3146
3532
  }
3147
3533
  if (controls.appendSystemPrompt !== undefined) {
3148
- mkdirSync(profileDir, { recursive: true });
3149
3534
  const appendPath = join(profileDir, PRIME_APPEND_SYSTEM_PROMPT_FILE);
3150
- writeFileSync(appendPath, controls.appendSystemPrompt, "utf8");
3535
+ writePrimeAgentFile(appendPath, controls.appendSystemPrompt);
3151
3536
  args.push("--append-system-prompt", appendPath);
3152
3537
  }
3538
+ if (controls.instructions !== undefined) {
3539
+ writePrimeAgentFile(join(agentDir, PRIME_CONTEXT_FILE), `${controls.instructions}\n`);
3540
+ }
3153
3541
  if (controls.skills.length > 0) {
3154
3542
  const skillsRoot = join(profileDir, PRIME_SKILLS_DIR_NAME);
3155
3543
  for (const skill of controls.skills) {
3156
- const skillDir = join(skillsRoot, skill.name);
3157
- mkdirSync(skillDir, { recursive: true });
3158
- writeFileSync(join(skillDir, "SKILL.md"), normalizeSkillMd(skill.name, skill.content), "utf8");
3544
+ writePrimeAgentFile(join(skillsRoot, skill.name, "SKILL.md"), normalizeSkillMd(skill.name, skill.content));
3159
3545
  }
3160
3546
  args.push("--skill", skillsRoot);
3161
3547
  }
3162
3548
  seedPrimeProfileSubagents(agentDir, controls.subagents, warn);
3163
3549
  return args;
3164
3550
  }
3165
- function emptyPrimeHarnessState() {
3166
- const entries = {};
3167
- for (const kind of PRIME_HARNESS_KINDS)
3168
- entries[kind] = {};
3169
- return { schema: 1, entries, refinements: [] };
3551
+ /**
3552
+ * Remove the files a PREVIOUS materialization of this lowering wrote, so a
3553
+ * profile that shrank cannot leave a stale instruction file or skill behind for
3554
+ * the next run against the same dirs.
3555
+ *
3556
+ * Only the paths this lowering NAMES are removed. There is deliberately no
3557
+ * manifest: a manifest would live where the agent can write it, and reading a
3558
+ * delete list out of agent-writable state turns pruning into an arbitrary-path
3559
+ * delete.
3560
+ */
3561
+ export function prunePrimeProfileMaterial(agentDir, options = {}) {
3562
+ const { flagFileRoot = agentDir } = options;
3563
+ rmSync(join(flagFileRoot, PRIME_PROFILE_DIR_NAME), {
3564
+ recursive: true,
3565
+ force: true,
3566
+ });
3567
+ rmSync(join(agentDir, PRIME_CONTEXT_FILE), { force: true });
3170
3568
  }
3171
3569
  /**
3172
3570
  * Upsert profile subagent specs into the fork's global continual-harness
3173
3571
  * store and remove profile-sourced entries the current profile no longer
3174
- * declares. Agent-written entries (`source: "agent"`) are never touched.
3572
+ * declares. Agent-written entries (`source: "agent"`) — and every top-level
3573
+ * key this lowering does not manage — are preserved untouched.
3175
3574
  *
3176
- * A pre-existing unparsable store degrades to empty exactly like the fork's
3177
- * own loader (refinement.ts:290-300 — "a corrupt state file must degrade to
3178
- * empty rather than throw"), with a warning so the reset is visible.
3575
+ * A store that exists but no longer parses raises
3576
+ * {@link PrimeHarnessStateError}: the file is the agent's own self-modification
3577
+ * state, and overwriting it destroys what the run was supposed to observe.
3179
3578
  */
3180
3579
  export function seedPrimeProfileSubagents(agentDir, subagents, warn) {
3181
3580
  const statePath = join(agentDir, PRIME_HARNESS_DIR_NAME, PRIME_HARNESS_STATE_FILE);
3182
- let state = emptyPrimeHarnessState();
3581
+ let state = {};
3183
3582
  if (existsSync(statePath)) {
3583
+ let parsed;
3184
3584
  try {
3185
- const raw = JSON.parse(readFileSync(statePath, "utf8"));
3186
- if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
3187
- const parsed = raw;
3188
- state.schema =
3189
- typeof parsed.schema === "number" ? parsed.schema : state.schema;
3190
- for (const kind of PRIME_HARNESS_KINDS) {
3191
- const records = parsed.entries?.[kind];
3192
- if (records && typeof records === "object") {
3193
- state.entries[kind] = { ...records };
3194
- }
3195
- }
3196
- if (Array.isArray(parsed.refinements)) {
3197
- state.refinements = parsed.refinements;
3198
- }
3199
- }
3200
- else {
3201
- warn("prime: existing harness_state.json is not an object — reseeding", {
3202
- statePath,
3203
- });
3204
- }
3585
+ parsed = JSON.parse(readFileSync(statePath, "utf8"));
3205
3586
  }
3206
3587
  catch (err) {
3207
- warn("prime: existing harness_state.json is unparsable — reseeding", {
3208
- statePath,
3209
- error: err instanceof Error ? err.message : String(err),
3210
- });
3211
- state = emptyPrimeHarnessState();
3588
+ throw new PrimeHarnessStateError(statePath, err instanceof Error ? err.message : String(err));
3589
+ }
3590
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3591
+ throw new PrimeHarnessStateError(statePath, "the store is not a JSON object");
3212
3592
  }
3593
+ state = parsed;
3213
3594
  }
3214
3595
  else if (subagents.length === 0) {
3215
3596
  // Nothing to pin and nothing stale to remove.
3216
3597
  return;
3217
3598
  }
3218
- const existing = state.entries.subagent ?? {};
3599
+ const rawEntries = state.entries;
3600
+ const entries = rawEntries && typeof rawEntries === "object" && !Array.isArray(rawEntries)
3601
+ ? rawEntries
3602
+ : {};
3603
+ for (const kind of PRIME_HARNESS_KINDS) {
3604
+ const records = entries[kind];
3605
+ if (!records || typeof records !== "object" || Array.isArray(records)) {
3606
+ entries[kind] = {};
3607
+ }
3608
+ }
3609
+ const existing = entries.subagent;
3219
3610
  const desiredIds = new Set(subagents.map((s) => primeSubagentEntryId(s.name)));
3220
3611
  let changed = false;
3221
3612
  for (const [id, entry] of Object.entries(existing)) {
3222
- if (entry?.source === PRIME_PROFILE_ENTRY_SOURCE && !desiredIds.has(id)) {
3613
+ const source = entry?.source;
3614
+ const isLegacy = typeof source === "string" &&
3615
+ PRIME_LEGACY_PROFILE_ENTRY_SOURCES.includes(source);
3616
+ if (isLegacy) {
3617
+ warn?.("prime: adopting a subagent entry left by a pre-shared executor", {
3618
+ statePath,
3619
+ id,
3620
+ source,
3621
+ });
3622
+ }
3623
+ if ((source === PRIME_PROFILE_ENTRY_SOURCE || isLegacy) &&
3624
+ !desiredIds.has(id)) {
3223
3625
  delete existing[id];
3224
3626
  changed = true;
3225
3627
  }
@@ -3242,7 +3644,7 @@ export function seedPrimeProfileSubagents(agentDir, subagents, warn) {
3242
3644
  kind: "subagent",
3243
3645
  title: spec.name,
3244
3646
  content,
3245
- path: "profile",
3647
+ path: PRIME_PROFILE_ENTRY_SOURCE,
3246
3648
  scope: "global",
3247
3649
  reference: {},
3248
3650
  arguments: {},
@@ -3256,9 +3658,12 @@ export function seedPrimeProfileSubagents(agentDir, subagents, warn) {
3256
3658
  };
3257
3659
  changed = true;
3258
3660
  }
3259
- state.entries.subagent = existing;
3260
3661
  if (!changed)
3261
3662
  return;
3262
- mkdirSync(join(agentDir, PRIME_HARNESS_DIR_NAME), { recursive: true });
3263
- writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
3663
+ writePrimeAgentFile(statePath, `${JSON.stringify({
3664
+ ...state,
3665
+ schema: typeof state.schema === "number" ? state.schema : 1,
3666
+ entries,
3667
+ refinements: Array.isArray(state.refinements) ? state.refinements : [],
3668
+ }, null, 2)}\n`);
3264
3669
  }