@tangle-network/agent-profile-materialize 0.10.3 → 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.
package/dist/index.js CHANGED
@@ -21,8 +21,9 @@
21
21
  * `node:fs` IO consumer. It was previously copied byte-for-byte into both
22
22
  * cli-bridge and agent-dev-container — this is the single source those re-export.
23
23
  */
24
+ export { modelIdsMatch, qualifyModelId } from "./model-id.js";
24
25
  import { createHash, randomUUID } from "node:crypto";
25
- import { closeSync, 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";
26
27
  import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32, } from "node:path";
27
28
  import { agentCandidateProfileActivationSchema, agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, agentProfileSchema, agentProfileSecretRefSchema, canonicalCandidateBytes, canonicalCandidateDigest, resolveNativeWebTools, } from "@tangle-network/agent-interface";
28
29
  export const UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER = Object.freeze({
@@ -71,6 +72,7 @@ export const MATERIALIZER_HARNESSES = [
71
72
  "opencode",
72
73
  "kimi-code",
73
74
  "pi",
75
+ "prime",
74
76
  "gemini",
75
77
  "hermes",
76
78
  "openclaw",
@@ -84,6 +86,28 @@ function assertMaterializerHarness(harness) {
84
86
  throw new Error(`Unsupported profile materialization harness "${harness}"`);
85
87
  }
86
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
+ }
87
111
  /**
88
112
  * The dimensions `materializeProfile`'s `skip` option honors.
89
113
  *
@@ -110,6 +134,53 @@ function assertSkippableDimensions(skip) {
110
134
  }
111
135
  return skip;
112
136
  }
137
+ /**
138
+ * Launcher controls an executor can declare it OWNS via `materializeProfile`'s
139
+ * `binds` option.
140
+ *
141
+ * Some harness controls live outside any workspace: opencode's exact
142
+ * system-prompt replacement (`agent.<name>.prompt`) binds to the ONE agent
143
+ * whoever starts the process selects, which a plan-forwarding executor cannot
144
+ * guarantee — so the plan refuses the intent (harnessSystemPromptIntents in
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.
160
+ */
161
+ export const BINDABLE_LAUNCH_CONTROLS = [
162
+ "systemPrompt",
163
+ ...EXECUTOR_SYSTEM_PROMPT_BINDINGS,
164
+ ];
165
+ const BINDABLE_LAUNCH_CONTROL_SET = new Set(BINDABLE_LAUNCH_CONTROLS);
166
+ /** Reject a binding declaration this materializer has no lowering for, rather than ignoring it. */
167
+ function assertBindableLaunchControls(binds) {
168
+ if (!binds)
169
+ return new Set();
170
+ const unknown = binds.filter((control) => !BINDABLE_LAUNCH_CONTROL_SET.has(control));
171
+ if (unknown.length > 0) {
172
+ throw new Error(`materializeProfile: cannot bind ${unknown.join(", ")}. ` +
173
+ `Bindable launch controls are: ${BINDABLE_LAUNCH_CONTROLS.join(", ")}.`);
174
+ }
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;
183
+ }
113
184
  /** The plan was applied, but one or more private staging files remain. */
114
185
  export class WorkspacePlanCleanupError extends AggregateError {
115
186
  receipt;
@@ -142,6 +213,66 @@ export function enforceWorkspacePlanSupport(plan, failOnError) {
142
213
  assertWorkspacePlanSupported(plan);
143
214
  return [];
144
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
+ }
145
276
  // ─── Verified per-harness conventions (capability matrix) ───────────────────
146
277
  /**
147
278
  * Whether the harness LOADS its {@link CONTEXT_FILE} from cwd on its own.
@@ -175,6 +306,61 @@ export function harnessContextFile(harness) {
175
306
  }
176
307
  return path;
177
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
+ }
178
364
  /** Project context file each harness actually reads from cwd. NEVER a constant. */
179
365
  const CONTEXT_FILE = {
180
366
  "claude-code": "CLAUDE.md",
@@ -183,10 +369,43 @@ const CONTEXT_FILE = {
183
369
  opencode: ".opencode/profile-instructions.md",
184
370
  "kimi-code": "AGENTS.md",
185
371
  pi: "AGENTS.md",
372
+ // prime (PrimeIntellect-ai/prime-agent v0.7.0, be9e2fa0) walks cwd and its
373
+ // ancestors for AGENTS.md/CLAUDE.md, cwd first (fork resource-loader.ts:59
374
+ // candidates, :94-109 walk from cwd inside loadProjectContextFiles).
375
+ prime: "AGENTS.md",
186
376
  gemini: "GEMINI.md", // current default; AGENTS.md only via .gemini/settings.json context.fileName
187
377
  hermes: "HERMES.md", // highest-priority native name; only ONE context loads (first match)
188
378
  openclaw: "AGENTS.md",
189
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
+ ]);
190
409
  /**
191
410
  * cwd-relative native skill dir per harness, or null if the harness has no
192
411
  * cwd skill primitive (skills live in a user/global dir the workspace plan
@@ -202,6 +421,12 @@ export const HARNESS_SKILL_DIRS = {
202
421
  opencode: ".opencode/skills",
203
422
  "kimi-code": ".kimi/skills",
204
423
  pi: ".pi/skills",
424
+ // prime auto-discovers project skills from <cwd>/<configDir>/skills where the
425
+ // fork ships configDir ".prime/agent" (fork package.json piConfig, config.ts:494;
426
+ // package-manager.ts:885 projectBaseDir, :2175-2180 projectDirs.skills,
427
+ // :2209-2213 collectAutoSkillEntries). The loader recurses into
428
+ // <name>/SKILL.md subdirs (skills.ts:328-372).
429
+ prime: ".prime/agent/skills",
205
430
  gemini: ".gemini/skills",
206
431
  hermes: null, // skills are ~/.hermes/skills (user) only — no cwd discovery
207
432
  openclaw: "skills", // <workspace>/skills/<name>/SKILL.md
@@ -226,6 +451,7 @@ const DEFAULT_FILE_MODE = 0o644;
226
451
  const PRIVATE_FILE_MODE = 0o600;
227
452
  const EXECUTABLE_FILE_MODE = 0o755;
228
453
  const DEFAULT_RESOURCE_FETCH_TIMEOUT_MS = 30_000;
454
+ const OPENCODE_PRIMARY_AGENT_PREFIX = "tangle-profile-";
229
455
  const MAX_RESOURCE_BYTES = 10 * 1024 * 1024;
230
456
  const MAX_PROFILE_RESOURCE_BYTES = 32 * 1024 * 1024;
231
457
  const MAX_PROFILE_RESOURCE_REFS = 128;
@@ -575,6 +801,7 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
575
801
  const profile = agentProfileSchema.parse(inputProfile);
576
802
  const h = harness;
577
803
  const skip = new Set(assertSkippableDimensions(opts.skip));
804
+ const binds = assertBindableLaunchControls(opts.binds);
578
805
  const plan = {
579
806
  harness: h,
580
807
  files: [],
@@ -602,6 +829,12 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
602
829
  relPath: normalizedPath,
603
830
  content,
604
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
+ : {}),
605
838
  ...(fileMode === undefined ? {} : { mode: validateFileMode(fileMode) }),
606
839
  ...(secretSlots === undefined
607
840
  ? {}
@@ -622,6 +855,14 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
622
855
  if (previousDimension) {
623
856
  throw new Error(`Duplicate profile resource path: ${normalizedPath} (${previousDimension}, ${dimension})`);
624
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
+ }
625
866
  explicitResourcePaths.set(normalizedPath, dimension);
626
867
  pushPlanFile(normalizedPath, content, "user", mode);
627
868
  };
@@ -682,21 +923,144 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
682
923
  }
683
924
  }
684
925
  }
685
- // 4) PROMPT. A system prompt is a replacement, while instructions are
686
- // additive workspace context. Never lower one into the other.
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.
934
+ //
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.
687
948
  if (profile.prompt?.systemPrompt !== undefined) {
688
- if (h === "claude-code" ||
689
- h === "codex" ||
690
- h === "opencode" ||
691
- h === "pi") {
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).
692
957
  plan.systemPrompt = profile.prompt.systemPrompt;
693
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
+ }
694
967
  else if (h === "gemini") {
695
968
  add(".gemini/system.md", profile.prompt.systemPrompt);
696
969
  plan.env.GEMINI_SYSTEM_MD = "1";
697
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.
982
+ const systemPrompt = profile.prompt.systemPrompt;
983
+ if (systemPrompt.trim().length === 0) {
984
+ throw new Error("opencode cannot express an empty exact system-prompt replacement");
985
+ }
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
+ };
996
+ }
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.
1004
+ const primaryAgentName = `${OPENCODE_PRIMARY_AGENT_PREFIX}${sha256(systemPrompt).slice(0, 16)}`;
1005
+ const primaryAgentPath = `.opencode/agents/${primaryAgentName}.md`;
1006
+ if (profile.subagents?.[primaryAgentName] !== undefined) {
1007
+ throw new Error(`opencode: subagent name "${primaryAgentName}" is reserved for the profile primary agent`);
1008
+ }
1009
+ plan.systemPrompt = systemPrompt;
1010
+ add(primaryAgentPath, ["---", "mode: primary", "---", systemPrompt, ""].join("\n"));
1011
+ plan.flags.push("--agent", primaryAgentName);
1012
+ }
1013
+ else {
1014
+ unsupported("systemPrompt", systemPromptRefusal(h));
1015
+ }
1016
+ }
698
1017
  else {
699
- unsupported("systemPrompt", `${h}: no verified exact system-prompt replacement control`);
1018
+ unsupported("systemPrompt", systemPromptRefusal(h));
1019
+ }
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");
1033
+ if (profile.prompt?.appendSystemPrompt !== undefined) {
1034
+ const appendSystemPrompt = profile.prompt.appendSystemPrompt;
1035
+ if (appendSystemPrompt.trim().length === 0) {
1036
+ throw new Error("prompt.appendSystemPrompt must be non-empty text");
1037
+ }
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[]");
1040
+ }
1041
+ else if (h === "opencode") {
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")
1048
+ ? appendSystemPrompt
1049
+ : `${appendSystemPrompt}\n`);
1050
+ openCodeInstructionFiles.push(OPENCODE_APPEND_SYSTEM_PROMPT_FILE);
1051
+ }
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;
1061
+ }
1062
+ else {
1063
+ unsupported("appendSystemPrompt", appendSystemPromptRefusal(h));
700
1064
  }
701
1065
  }
702
1066
  const instr = [];
@@ -711,15 +1075,25 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
711
1075
  unsupported("instructions", "instruction ref is not inline (github refs need async pre-resolution)");
712
1076
  }
713
1077
  if (instr.length) {
714
- const instructionPath = CONTEXT_FILE[h];
715
- add(instructionPath, `${instr.join("\n\n")}\n`);
716
- if (h === "opencode") {
717
- add("opencode.json", serializeWorkspacePlanJson({
718
- $schema: "https://opencode.ai/config.json",
719
- instructions: [instructionPath],
720
- }));
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[]");
721
1080
  }
722
- // gemini reads AGENTS.md only via settings; we write GEMINI.md so no extra step.
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
+ }
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.
1092
+ if (openCodeInstructionFiles.length > 0) {
1093
+ add("opencode.json", serializeWorkspacePlanJson({
1094
+ $schema: "https://opencode.ai/config.json",
1095
+ instructions: openCodeInstructionFiles,
1096
+ }));
723
1097
  }
724
1098
  // 5) ACTIVE EXECUTION CONTROLS. These alter the tools visible to the primary
725
1099
  // process and its permission policy, so they must be lowered into native
@@ -833,9 +1207,7 @@ export function materializeCandidateProfile(profile, harness, opts = {}) {
833
1207
  files: classicPlan.files,
834
1208
  env: candidatePublicRecord(classicPlan.env),
835
1209
  flags: classicPlan.flags.map(candidatePublicArgument),
836
- ...(classicPlan.systemPrompt === undefined
837
- ? {}
838
- : { systemPrompt: candidatePublicValue(classicPlan.systemPrompt) }),
1210
+ ...promptIntentFields(classicPlan, candidatePublicValue),
839
1211
  unsupported: classicPlan.unsupported,
840
1212
  };
841
1213
  }
@@ -1152,6 +1524,7 @@ function normalizePlanFiles(files) {
1152
1524
  relPath,
1153
1525
  content: file.content,
1154
1526
  mode,
1527
+ ownership: file.ownership ?? "shared",
1155
1528
  secretSlots,
1156
1529
  };
1157
1530
  })
@@ -1203,6 +1576,10 @@ export function hashWorkspacePlan(plan) {
1203
1576
  relPath: file.relPath,
1204
1577
  mode: file.mode,
1205
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,
1206
1583
  ...(file.secretSlots.length === 0
1207
1584
  ? {}
1208
1585
  : {
@@ -1226,9 +1603,10 @@ export function hashWorkspacePlan(plan) {
1226
1603
  files,
1227
1604
  env,
1228
1605
  flags: plan.flags.map(normalizeWorkspacePlanArgument),
1229
- ...(plan.systemPrompt === undefined
1230
- ? {}
1231
- : { systemPrompt: plan.systemPrompt }),
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),
1232
1610
  unsupported,
1233
1611
  ...(requires.length === 0 ? {} : { requires }),
1234
1612
  });
@@ -1246,9 +1624,7 @@ export function agentCandidateProfilePlanMaterial(plan) {
1246
1624
  })),
1247
1625
  env: Object.fromEntries(Object.entries(plan.env).sort(([left], [right]) => compareStrings(left, right))),
1248
1626
  flags: [...plan.flags],
1249
- ...(plan.systemPrompt === undefined
1250
- ? {}
1251
- : { systemPrompt: plan.systemPrompt }),
1627
+ ...promptIntentFields(plan, samePromptIntentValue),
1252
1628
  unsupported: [...plan.unsupported].sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`)),
1253
1629
  };
1254
1630
  return agentCandidateProfilePlanMaterialSchema.parse(material);
@@ -1308,9 +1684,7 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
1308
1684
  candidatePublicString(value),
1309
1685
  ])),
1310
1686
  flags: plan.flags.map(candidatePublicString),
1311
- ...(plan.systemPrompt === undefined
1312
- ? {}
1313
- : { systemPrompt: candidatePublicString(plan.systemPrompt) }),
1687
+ ...promptIntentFields(plan, candidatePublicString),
1314
1688
  unsupported: plan.unsupported,
1315
1689
  }, workspaceDir);
1316
1690
  return {
@@ -1322,9 +1696,7 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
1322
1696
  },
1323
1697
  env: { ...plan.env },
1324
1698
  flags: [...plan.flags],
1325
- ...(plan.systemPrompt === undefined
1326
- ? {}
1327
- : { systemPrompt: plan.systemPrompt }),
1699
+ ...promptIntentFields(plan, samePromptIntentValue),
1328
1700
  unsupported: [...plan.unsupported],
1329
1701
  };
1330
1702
  }
@@ -1409,9 +1781,8 @@ function resolvePublicWorkspacePlan(plan) {
1409
1781
  files,
1410
1782
  env,
1411
1783
  flags,
1412
- ...(plan.systemPrompt === undefined
1413
- ? {}
1414
- : { systemPrompt: plan.systemPrompt }),
1784
+ ...promptIntentFields(plan, samePromptIntentValue),
1785
+ ...systemPromptBindingField(plan),
1415
1786
  };
1416
1787
  }
1417
1788
  async function resolvePrivateWorkspacePlan(plan, secretProvider) {
@@ -1477,9 +1848,8 @@ async function resolvePrivateWorkspacePlan(plan, secretProvider) {
1477
1848
  files,
1478
1849
  env,
1479
1850
  flags,
1480
- ...(plan.systemPrompt === undefined
1481
- ? {}
1482
- : { systemPrompt: plan.systemPrompt }),
1851
+ ...promptIntentFields(plan, samePromptIntentValue),
1852
+ ...systemPromptBindingField(plan),
1483
1853
  };
1484
1854
  }
1485
1855
  function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
@@ -1490,9 +1860,8 @@ function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorks
1490
1860
  typeof value === "string" ? value : { ...value },
1491
1861
  ])),
1492
1862
  flags: plan.flags.map(normalizeWorkspacePlanArgument),
1493
- ...(plan.systemPrompt === undefined
1494
- ? {}
1495
- : { systemPrompt: plan.systemPrompt }),
1863
+ ...promptIntentFields(plan, samePromptIntentValue),
1864
+ ...systemPromptBindingField(plan),
1496
1865
  unsupported: plan.unsupported.map((entry) => ({ ...entry })),
1497
1866
  written: [...written],
1498
1867
  };
@@ -1514,11 +1883,17 @@ function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, opti
1514
1883
  ...file,
1515
1884
  absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
1516
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";
1517
1891
  // Validate the complete plan before the first write so a late traversal or
1518
1892
  // existing symlink cannot leave a partially materialized workspace.
1519
1893
  for (const file of files) {
1520
1894
  assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
1521
1895
  if (workspaceFileExists(file.absPath) &&
1896
+ !mayReplace(file) &&
1522
1897
  (existingFiles === "reject" || !workspaceFileMatches(file))) {
1523
1898
  throw new Error(`Refusing to replace existing workspace file: ${file.relPath}`);
1524
1899
  }
@@ -1555,6 +1930,20 @@ function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, opti
1555
1930
  workspaceFileMatches(entry.file)) {
1556
1931
  continue;
1557
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
+ }
1558
1947
  if (isFsError(error, "EEXIST")) {
1559
1948
  throw new Error(`Refusing to replace existing workspace file: ${entry.file.relPath}`, { cause: error });
1560
1949
  }
@@ -1610,9 +1999,8 @@ function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, opti
1610
1999
  spawn: {
1611
2000
  env: { ...resolvedPlan.env },
1612
2001
  flags: [...resolvedPlan.flags],
1613
- ...(resolvedPlan.systemPrompt === undefined
1614
- ? {}
1615
- : { systemPrompt: resolvedPlan.systemPrompt }),
2002
+ ...promptIntentFields(resolvedPlan, samePromptIntentValue),
2003
+ ...systemPromptBindingField(resolvedPlan),
1616
2004
  },
1617
2005
  runtimeOnlyFiles: privateRuntimeFiles,
1618
2006
  };
@@ -2214,6 +2602,19 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
2214
2602
  });
2215
2603
  break;
2216
2604
  }
2605
+ case "prime":
2606
+ // The fork's MCP config surface (`mcpServers` in <agentDir>/settings.json,
2607
+ // fork settings-manager.ts:98-120,219) IS read at startup, but a
2608
+ // user-declared server never becomes model-visible: the host McpManager
2609
+ // only registers OAuth providers and serves mcp.config/refresh
2610
+ // host-requests (fork core/mcp/mcp-manager.ts:62-124,156-181), skips
2611
+ // stdio servers entirely (mcp-manager.ts:73), and nothing generates a
2612
+ // skill or prompt section for it — materializing profile.mcp would be a
2613
+ // silently dropped control.
2614
+ unsupported("mcp", "prime: settings.json mcpServers never becomes model-visible (fork mcp-manager.ts:62-124 " +
2615
+ "registers OAuth providers only and skips stdio servers) — model-visible MCP is exactly " +
2616
+ "the fork's built-in catalog skills");
2617
+ break;
2217
2618
  case "hermes":
2218
2619
  unsupported("mcp", "hermes MCP is global config.yaml (mcp_servers), no cwd discovery — write to the hermes global config or pass a config flag");
2219
2620
  break;
@@ -2418,6 +2819,9 @@ function materializeHooks(h, hooks, add, unsupported, plan) {
2418
2819
  case "pi":
2419
2820
  unsupported("hooks", "pi hooks are TS extensions (.pi/extensions/*.ts) — build an extension");
2420
2821
  break;
2822
+ case "prime":
2823
+ unsupported("hooks", "prime hooks are TS extensions (fork core/extensions), not command hooks — build an extension");
2824
+ break;
2421
2825
  case "hermes":
2422
2826
  unsupported("hooks", "hermes project plugins do not expose an exact command-hook mapping");
2423
2827
  break;
@@ -2678,6 +3082,16 @@ function materializeSubagent(h, name, sa, plan, add, unsupported) {
2678
3082
  registerCodexAgent(plan, name, relPath, sa.description ?? name);
2679
3083
  break;
2680
3084
  }
3085
+ case "prime":
3086
+ // Prime subagents ARE expressible — as entries in the continual-harness
3087
+ // store at <agentDir>/harness/harness_state.json (fork refinement.ts:269-279,
3088
+ // re-read on every system-prompt build: agent-session.ts:7548-7554) — but
3089
+ // that store lives under the executor-owned agent dir, which no
3090
+ // workspace-relative plan file can reach. Executors that own the agent
3091
+ // dir seed it with seedPrimeProfileSubagents; the plan stays fail-closed
3092
+ // so a plan-forwarding caller can never believe the roster is in force.
3093
+ unsupported("subagents", `prime: subagents live in the agent-dir continual-harness store (<agentDir>/harness/harness_state.json), outside any workspace plan — seed "${name}" with seedPrimeProfileSubagents from the executor that owns the agent dir`);
3094
+ break;
2681
3095
  default:
2682
3096
  unsupported("subagents", `${h}: subagents have no cwd file (config/flag/global only); "${name}" not mounted`);
2683
3097
  }
@@ -2721,6 +3135,12 @@ function materializeCommand(h, name, body, add, unsupported) {
2721
3135
  case "pi":
2722
3136
  add(`.pi/prompts/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
2723
3137
  break;
3138
+ case "prime":
3139
+ // Auto-discovered project prompt templates: <cwd>/.prime/agent/prompts
3140
+ // (fork prompt-templates.ts:218 resolves CONFIG_DIR_NAME "prompts";
3141
+ // package-manager.ts:2176-2180 auto-adds the same projectDirs.prompts).
3142
+ add(`.prime/agent/prompts/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
3143
+ break;
2724
3144
  case "kimi-code":
2725
3145
  unsupported("commands", "kimi commands ARE skills (/skill:<name>) — provide as a skill instead");
2726
3146
  break;
@@ -2731,3 +3151,347 @@ function materializeCommand(h, name, body, add, unsupported) {
2731
3151
  unsupported("commands", `${h}: no cwd command path`);
2732
3152
  }
2733
3153
  }
3154
+ // ─── Prime agent-dir lowering ────────────────────────────────────────────────
3155
+ //
3156
+ // Prime (PrimeIntellect-ai/prime-agent v0.7.0, be9e2fa0) reads several profile
3157
+ // dimensions from its AGENT DIR — a per-process config root the EXECUTOR owns
3158
+ // (env PRIME_AGENT_CODING_AGENT_DIR, fork config.ts:498,521-527), which no
3159
+ // workspace-relative plan file can reach. The helpers below are that lowering,
3160
+ // shared so cli-bridge's prime backend and sdk-provider-prime consume one
3161
+ // implementation instead of hand-rolling byte-divergent copies. Every control
3162
+ // accepted here is backed by a verified read path in the pinned fork; every
3163
+ // other control is rejected loudly so a caller can never believe a
3164
+ // behavior-relevant setting is in force when it is not. The verified paths:
3165
+ //
3166
+ // - `prompt.systemPrompt` / `prompt.appendSystemPrompt` → `--system-prompt` /
3167
+ // `--append-system-prompt` (cli/args.ts:139-143). Both ride
3168
+ // `AgentSessionRuntimeConfig` (main.ts:643-644) through BOTH acp spawn
3169
+ // paths — the daemon-client create request (main.ts:1512-1520 →
3170
+ // daemon-mode.ts:1310/1478) and the in-process fallback — into
3171
+ // `resourceLoaderOptions` (main.ts:745-746), where the replacement DELETES
3172
+ // the built-in prompt (resource-loader.ts:482-485, system-prompt.ts:72-73)
3173
+ // and the addition appends after it (system-prompt.ts:61,109-110). Values
3174
+ // are materialized as FILES under the agent dir because the flag value is
3175
+ // read as a file when the path exists (resource-loader.ts:41-56), which
3176
+ // keeps literal-vs-path resolution deterministic.
3177
+ //
3178
+ // - `resources.skills` (inline refs) → `--skill <agentDir>/profile/skills`
3179
+ // (cli/args.ts:207-209 → main.ts:652 → agent-session-config.ts:67 →
3180
+ // additionalSkillPaths, resource-loader.ts:437-442). The loader recurses
3181
+ // into `<name>/SKILL.md` subdirs (skills.ts:328-372) and loaded skills are
3182
+ // rendered into the system prompt (agent-session.ts:4272-4291,
3183
+ // system-prompt.ts:87-89). The fork silently DROPS a SKILL.md without a
3184
+ // frontmatter description (skills.ts:416-419), so content is normalized
3185
+ // through {@link normalizeSkillMd}, which guarantees one.
3186
+ //
3187
+ // - `subagents` → `subagent` entries in the fork's continual-harness store at
3188
+ // `<agentDir>/harness/harness_state.json` (refinement.ts:269-279). The
3189
+ // store is re-read on every system-prompt build (agent-session.ts:4288,
3190
+ // 7548-7554) and subagent entries render as a task-shaped roster the model
3191
+ // spawns via `await rlm('<task>')` (refinement.ts:483-496); the kernel
3192
+ // reaches the same store through the host-set RLM_GLOBAL_HARNESS_STATE_DIR
3193
+ // (agent-session.ts:8805).
3194
+ //
3195
+ // Kept REJECTED with the fork evidence:
3196
+ //
3197
+ // - `mcp`: the fork's MCP config surface (`mcpServers` in
3198
+ // `<agentDir>/settings.json`, settings-manager.ts:98-120,219) IS read at
3199
+ // startup, but a user-declared server never becomes model-visible: the host
3200
+ // McpManager only registers OAuth providers and serves mcp.config/refresh
3201
+ // host-requests (core/mcp/mcp-manager.ts:62-124,156-181), skips stdio
3202
+ // servers entirely (mcp-manager.ts:73), and nothing generates a skill or
3203
+ // prompt section for it — model-visible MCP is exactly the built-in
3204
+ // linear/notion catalog skills (packages/coding-agent/skills/…). Wiring
3205
+ // `profile.mcp` into settings.json would materialize config the model can
3206
+ // never discover: a silently dropped control.
3207
+ //
3208
+ // - `prompt.instructions`: the fork's caller-instruction surface is the
3209
+ // workspace AGENTS.md/CLAUDE.md context file (resource-loader.ts:58-66),
3210
+ // i.e. caller workspace content this agent-dir lowering must not write
3211
+ // into. (`materializeProfile(profile, "prime")` lowers instructions to the
3212
+ // cwd AGENTS.md instead, which the fork walks from cwd:
3213
+ // resource-loader.ts:94-109.)
3214
+ /** Skill name rules enforced by the fork's Agent Skills loader (skills.ts:122-147). */
3215
+ const PRIME_SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3216
+ const PRIME_MAX_SKILL_NAME_LENGTH = 64;
3217
+ const PRIME_PROFILE_DIR_NAME = "profile";
3218
+ const PRIME_SKILLS_DIR_NAME = "skills";
3219
+ const PRIME_SYSTEM_PROMPT_FILE = "system-prompt.md";
3220
+ const PRIME_APPEND_SYSTEM_PROMPT_FILE = "append-system-prompt.md";
3221
+ /** Fork continual-harness store location under the agent dir (refinement.ts:269-279). */
3222
+ const PRIME_HARNESS_DIR_NAME = "harness";
3223
+ const PRIME_HARNESS_STATE_FILE = "harness_state.json";
3224
+ const PRIME_HARNESS_KINDS = ["prompt", "memory", "skill", "subagent"];
3225
+ /**
3226
+ * Provenance marker for entries pinned from the profile. Stale removal keys on
3227
+ * it, so agent-written (`source: "agent"`) and user-written entries are never
3228
+ * touched.
3229
+ */
3230
+ const PRIME_PROFILE_ENTRY_SOURCE = "profile";
3231
+ function rejectPrimeControl(control, reason) {
3232
+ throw new Error(`prime does not support AgentProfile ${control}: ${reason}`);
3233
+ }
3234
+ /**
3235
+ * Validate and extract the profile controls the prime agent-dir lowering
3236
+ * materializes. Throws on any control (or control facet) the fork has no
3237
+ * verified read path for — partial acceptance would silently drop the rest.
3238
+ */
3239
+ export function readPrimeProfileControls(profile) {
3240
+ const controls = { skills: [], subagents: [] };
3241
+ if (!profile)
3242
+ return controls;
3243
+ if (profile.harness !== undefined && profile.harness !== "prime") {
3244
+ throw new Error(`prime cannot run a profile pinned to harness "${profile.harness}" — ` +
3245
+ 'only "prime" (or an unset harness preference) is accepted.');
3246
+ }
3247
+ if (profile.prompt) {
3248
+ const { systemPrompt, appendSystemPrompt, instructions } = profile.prompt;
3249
+ const knownPromptFields = new Set([
3250
+ "systemPrompt",
3251
+ "appendSystemPrompt",
3252
+ "instructions",
3253
+ ]);
3254
+ const unknown = Object.entries(profile.prompt)
3255
+ .filter(([key, value]) => !knownPromptFields.has(key) && value !== undefined)
3256
+ .map(([key]) => key);
3257
+ if (unknown.length > 0) {
3258
+ rejectPrimeControl(`prompt fields: ${unknown.join(", ")}`, "no verified prime-agent read path exists for them.");
3259
+ }
3260
+ if (instructions !== undefined) {
3261
+ rejectPrimeControl("prompt.instructions", "prime-agent's caller-instruction surface is the workspace " +
3262
+ "AGENTS.md context file (fork resource-loader.ts:58-66), which is " +
3263
+ "caller workspace content this adapter must not write into. Use " +
3264
+ "prompt.appendSystemPrompt instead.");
3265
+ }
3266
+ if (systemPrompt !== undefined) {
3267
+ if (typeof systemPrompt !== "string" || systemPrompt.trim() === "") {
3268
+ rejectPrimeControl("prompt.systemPrompt", "an empty replacement would delete the built-in system prompt and " +
3269
+ "install nothing.");
3270
+ }
3271
+ controls.systemPrompt = systemPrompt;
3272
+ }
3273
+ if (appendSystemPrompt !== undefined) {
3274
+ if (typeof appendSystemPrompt !== "string" ||
3275
+ appendSystemPrompt.trim() === "") {
3276
+ rejectPrimeControl("prompt.appendSystemPrompt", "must be non-empty text.");
3277
+ }
3278
+ controls.appendSystemPrompt = appendSystemPrompt;
3279
+ }
3280
+ }
3281
+ if (profile.resources) {
3282
+ const { skills, failOnError, ...unsupported } = profile.resources;
3283
+ const rejectedKeys = Object.entries(unsupported)
3284
+ .filter(([, value]) => value !== undefined)
3285
+ .map(([key]) => key);
3286
+ if (rejectedKeys.length > 0) {
3287
+ rejectPrimeControl(`resources: ${rejectedKeys.join(", ")}`, "only resources.skills has a verified prime-agent read path " +
3288
+ "(--skill dirs, fork resource-loader.ts:437-442).");
3289
+ }
3290
+ if (failOnError === false) {
3291
+ rejectPrimeControl("resources.failOnError: false", "this adapter always fails loud on a resource it cannot " +
3292
+ "materialize; lenient materialization is not implemented.");
3293
+ }
3294
+ const seen = new Set();
3295
+ for (const ref of skills ?? []) {
3296
+ if (ref.kind !== "inline") {
3297
+ rejectPrimeControl(`resources.skills ref "${"path" in ref ? ref.path : ""}"`, "only inline skill refs are accepted — resolve GitHub refs " +
3298
+ "(resolveAgentProfileResources) before handing the profile to " +
3299
+ "this adapter.");
3300
+ }
3301
+ const name = ref.name;
3302
+ if (!PRIME_SKILL_NAME_RE.test(name) ||
3303
+ name.length > PRIME_MAX_SKILL_NAME_LENGTH) {
3304
+ rejectPrimeControl(`resources.skills name "${name}"`, "prime-agent skill names must be lowercase a-z0-9 with single " +
3305
+ `hyphens, at most ${PRIME_MAX_SKILL_NAME_LENGTH} chars (fork skills.ts:122-147).`);
3306
+ }
3307
+ if (seen.has(name)) {
3308
+ rejectPrimeControl(`resources.skills name "${name}"`, "duplicate skill names collide in the fork's skill registry.");
3309
+ }
3310
+ seen.add(name);
3311
+ if (ref.content.trim() === "") {
3312
+ rejectPrimeControl(`resources.skills "${name}"`, "skill content is empty.");
3313
+ }
3314
+ controls.skills.push({ name, content: ref.content });
3315
+ }
3316
+ }
3317
+ if (profile.subagents) {
3318
+ const usedIds = new Map();
3319
+ for (const [name, spec] of Object.entries(profile.subagents)) {
3320
+ if (name.trim() === "") {
3321
+ rejectPrimeControl("subagents", "a subagent name must be non-empty.");
3322
+ }
3323
+ const inexpressible = [
3324
+ ["model", spec.model],
3325
+ ["tools", spec.tools],
3326
+ ["permissions", spec.permissions],
3327
+ ["maxSteps", spec.maxSteps],
3328
+ ]
3329
+ .filter(([, value]) => value !== undefined)
3330
+ .map(([key]) => key);
3331
+ if (inexpressible.length > 0) {
3332
+ rejectPrimeControl(`subagents["${name}"] fields: ${inexpressible.join(", ")}`, "a prime continual-harness subagent spec is prompt-roster " +
3333
+ "guidance for `await rlm('<task>')` (fork refinement.ts:483-496) " +
3334
+ "and pins no model, tool set, permission, or step budget on the " +
3335
+ "spawned child.");
3336
+ }
3337
+ if (!spec.description?.trim() && !spec.prompt?.trim()) {
3338
+ rejectPrimeControl(`subagents["${name}"]`, "a spec needs a description and/or prompt — an empty entry gives " +
3339
+ "the model an empty roster line.");
3340
+ }
3341
+ const id = primeSubagentEntryId(name);
3342
+ const clash = usedIds.get(id);
3343
+ if (clash !== undefined) {
3344
+ rejectPrimeControl(`subagents["${name}"]`, `its id "${id}" collides with subagent "${clash}".`);
3345
+ }
3346
+ usedIds.set(id, name);
3347
+ controls.subagents.push({
3348
+ name,
3349
+ description: spec.description,
3350
+ prompt: spec.prompt,
3351
+ metadata: spec.metadata,
3352
+ });
3353
+ }
3354
+ }
3355
+ return controls;
3356
+ }
3357
+ /** Deterministic harness-state entry id for a profile subagent name. */
3358
+ export function primeSubagentEntryId(name) {
3359
+ const slug = name
3360
+ .toLowerCase()
3361
+ .replace(/[^a-z0-9]+/g, "-")
3362
+ .replace(/^-+|-+$/g, "");
3363
+ return `profile-${slug || "subagent"}`;
3364
+ }
3365
+ /**
3366
+ * Materialize the validated controls into `agentDir` and return the extra
3367
+ * spawn args that bind them. Runs before every spawn (same place models.json
3368
+ * is materialized), so a continual agent dir is re-pinned to the current
3369
+ * profile on each fresh prime process.
3370
+ */
3371
+ export function materializePrimeProfileControls(agentDir, controls, warn) {
3372
+ const args = [];
3373
+ const profileDir = join(agentDir, PRIME_PROFILE_DIR_NAME);
3374
+ if (controls.systemPrompt !== undefined) {
3375
+ mkdirSync(profileDir, { recursive: true });
3376
+ const promptPath = join(profileDir, PRIME_SYSTEM_PROMPT_FILE);
3377
+ writeFileSync(promptPath, controls.systemPrompt, "utf8");
3378
+ args.push("--system-prompt", promptPath);
3379
+ }
3380
+ if (controls.appendSystemPrompt !== undefined) {
3381
+ mkdirSync(profileDir, { recursive: true });
3382
+ const appendPath = join(profileDir, PRIME_APPEND_SYSTEM_PROMPT_FILE);
3383
+ writeFileSync(appendPath, controls.appendSystemPrompt, "utf8");
3384
+ args.push("--append-system-prompt", appendPath);
3385
+ }
3386
+ if (controls.skills.length > 0) {
3387
+ const skillsRoot = join(profileDir, PRIME_SKILLS_DIR_NAME);
3388
+ for (const skill of controls.skills) {
3389
+ const skillDir = join(skillsRoot, skill.name);
3390
+ mkdirSync(skillDir, { recursive: true });
3391
+ writeFileSync(join(skillDir, "SKILL.md"), normalizeSkillMd(skill.name, skill.content), "utf8");
3392
+ }
3393
+ args.push("--skill", skillsRoot);
3394
+ }
3395
+ seedPrimeProfileSubagents(agentDir, controls.subagents, warn);
3396
+ return args;
3397
+ }
3398
+ function emptyPrimeHarnessState() {
3399
+ const entries = {};
3400
+ for (const kind of PRIME_HARNESS_KINDS)
3401
+ entries[kind] = {};
3402
+ return { schema: 1, entries, refinements: [] };
3403
+ }
3404
+ /**
3405
+ * Upsert profile subagent specs into the fork's global continual-harness
3406
+ * store and remove profile-sourced entries the current profile no longer
3407
+ * declares. Agent-written entries (`source: "agent"`) are never touched.
3408
+ *
3409
+ * A pre-existing unparsable store degrades to empty exactly like the fork's
3410
+ * own loader (refinement.ts:290-300 — "a corrupt state file must degrade to
3411
+ * empty rather than throw"), with a warning so the reset is visible.
3412
+ */
3413
+ export function seedPrimeProfileSubagents(agentDir, subagents, warn) {
3414
+ const statePath = join(agentDir, PRIME_HARNESS_DIR_NAME, PRIME_HARNESS_STATE_FILE);
3415
+ let state = emptyPrimeHarnessState();
3416
+ if (existsSync(statePath)) {
3417
+ try {
3418
+ const raw = JSON.parse(readFileSync(statePath, "utf8"));
3419
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
3420
+ const parsed = raw;
3421
+ state.schema =
3422
+ typeof parsed.schema === "number" ? parsed.schema : state.schema;
3423
+ for (const kind of PRIME_HARNESS_KINDS) {
3424
+ const records = parsed.entries?.[kind];
3425
+ if (records && typeof records === "object") {
3426
+ state.entries[kind] = { ...records };
3427
+ }
3428
+ }
3429
+ if (Array.isArray(parsed.refinements)) {
3430
+ state.refinements = parsed.refinements;
3431
+ }
3432
+ }
3433
+ else {
3434
+ warn("prime: existing harness_state.json is not an object — reseeding", {
3435
+ statePath,
3436
+ });
3437
+ }
3438
+ }
3439
+ catch (err) {
3440
+ warn("prime: existing harness_state.json is unparsable — reseeding", {
3441
+ statePath,
3442
+ error: err instanceof Error ? err.message : String(err),
3443
+ });
3444
+ state = emptyPrimeHarnessState();
3445
+ }
3446
+ }
3447
+ else if (subagents.length === 0) {
3448
+ // Nothing to pin and nothing stale to remove.
3449
+ return;
3450
+ }
3451
+ const existing = state.entries.subagent ?? {};
3452
+ const desiredIds = new Set(subagents.map((s) => primeSubagentEntryId(s.name)));
3453
+ let changed = false;
3454
+ for (const [id, entry] of Object.entries(existing)) {
3455
+ if (entry?.source === PRIME_PROFILE_ENTRY_SOURCE && !desiredIds.has(id)) {
3456
+ delete existing[id];
3457
+ changed = true;
3458
+ }
3459
+ }
3460
+ const now = new Date().toISOString();
3461
+ for (const spec of subagents) {
3462
+ const id = primeSubagentEntryId(spec.name);
3463
+ const content = [spec.description?.trim(), spec.prompt?.trim()]
3464
+ .filter((part) => !!part)
3465
+ .join("\n\n");
3466
+ const previous = existing[id];
3467
+ if (previous &&
3468
+ previous.source === PRIME_PROFILE_ENTRY_SOURCE &&
3469
+ previous.title === spec.name &&
3470
+ previous.content === content) {
3471
+ continue;
3472
+ }
3473
+ existing[id] = {
3474
+ id,
3475
+ kind: "subagent",
3476
+ title: spec.name,
3477
+ content,
3478
+ path: "profile",
3479
+ scope: "global",
3480
+ reference: {},
3481
+ arguments: {},
3482
+ metadata: spec.metadata ?? {},
3483
+ source: PRIME_PROFILE_ENTRY_SOURCE,
3484
+ created_at: previous?.created_at ?? now,
3485
+ updated_at: now,
3486
+ version: previous && typeof previous.version === "number"
3487
+ ? previous.version + 1
3488
+ : 1,
3489
+ };
3490
+ changed = true;
3491
+ }
3492
+ state.entries.subagent = existing;
3493
+ if (!changed)
3494
+ return;
3495
+ mkdirSync(join(agentDir, PRIME_HARNESS_DIR_NAME), { recursive: true });
3496
+ writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
3497
+ }