@tangle-network/agent-profile-materialize 0.10.2 → 0.11.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.d.ts CHANGED
@@ -21,6 +21,7 @@
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 type { AgentCandidateConfigValue, AgentCandidateProfile, AgentCandidateProfileActivation, AgentCandidateProfileApplication, AgentCandidateProfilePlanEvidence, AgentCandidateProfilePlanMaterial, AgentProfile, AgentProfileConfigValue, AgentProfileMcpServer, AgentProfileSecretProvider, AgentProfileSecretRef, Sha256Digest } from "@tangle-network/agent-interface";
25
26
  export declare const UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER: AgentProfileSecretProvider;
26
27
  /**
@@ -28,7 +29,7 @@ export declare const UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER: AgentProfileSecr
28
29
  * the canonical harness enum, so a rename upstream is a compile error here rather than an
29
30
  * `isMaterializerHarness` that silently returns false for a harness that still exists.
30
31
  */
31
- export declare const MATERIALIZER_HARNESSES: readonly ["claude-code", "nanoclaw", "codex", "opencode", "kimi-code", "pi", "gemini", "hermes", "openclaw"];
32
+ export declare const MATERIALIZER_HARNESSES: readonly ["claude-code", "nanoclaw", "codex", "opencode", "kimi-code", "pi", "prime", "gemini", "hermes", "openclaw"];
32
33
  export type HarnessId = (typeof MATERIALIZER_HARNESSES)[number];
33
34
  export declare function isMaterializerHarness(harness: string): harness is HarnessId;
34
35
  export interface PlanFile {
@@ -95,6 +96,22 @@ export interface Unsupported {
95
96
  export declare const SKIPPABLE_DIMENSIONS: readonly ["mcp", "extensions"];
96
97
  export type SkippableDimension = (typeof SKIPPABLE_DIMENSIONS)[number];
97
98
  export declare function isSkippableDimension(value: string): value is SkippableDimension;
99
+ /**
100
+ * Launcher controls an executor can declare it OWNS via `materializeProfile`'s
101
+ * `binds` option.
102
+ *
103
+ * Some harness controls live outside any workspace: opencode's exact
104
+ * system-prompt replacement (`agent.<name>.prompt`) binds to the ONE agent
105
+ * whoever starts the process selects, which a plan-forwarding executor cannot
106
+ * guarantee — so the plan refuses the intent (harnessSystemPromptIntents in
107
+ * @tangle-network/agent-interface answers `replace: false` for opencode). An
108
+ * executor that really applies the plan's launch flags to the spawn it owns
109
+ * (selecting the generated primary agent via the plan's `--agent` flag) states
110
+ * so with `binds: ["systemPrompt"]`, which turns that refusal into the
111
+ * primary-agent lowering the executor must then apply.
112
+ */
113
+ export declare const BINDABLE_LAUNCH_CONTROLS: readonly ["systemPrompt"];
114
+ export type BindableLaunchControl = (typeof BINDABLE_LAUNCH_CONTROLS)[number];
98
115
  /** The plan a caller writes into the run cwd, then applies to the spawn. */
99
116
  export interface WorkspacePlan {
100
117
  harness: HarnessId;
@@ -106,6 +123,16 @@ export interface WorkspacePlan {
106
123
  flags: WorkspacePlanArgument[];
107
124
  /** Exact system-prompt replacement supplied to the harness. */
108
125
  systemPrompt?: string;
126
+ /**
127
+ * Additive system-prompt text: the harness's own prompt stays in force and
128
+ * this text is added after it. The OPPOSITE intent of {@link systemPrompt} on
129
+ * the same channel — an executor must never bind one to the other's control
130
+ * (an addition on the replacement flag deletes the prompt the caller asked to
131
+ * keep; a replacement on the additive flag leaves the harness's prompt
132
+ * running). Set only for harnesses whose own control executes the addition
133
+ * (harnessSystemPromptIntents in @tangle-network/agent-interface).
134
+ */
135
+ appendSystemPrompt?: string;
109
136
  /** Dimensions requested but with NO cwd path on this harness — fail-closed record. */
110
137
  unsupported: Unsupported[];
111
138
  /**
@@ -157,6 +184,7 @@ export interface WorkspacePlanReceipt {
157
184
  /** Public launch identity only; templates retain opaque secret references. */
158
185
  flags: WorkspacePlanArgument[];
159
186
  systemPrompt?: string;
187
+ appendSystemPrompt?: string;
160
188
  unsupported: Unsupported[];
161
189
  written: string[];
162
190
  }
@@ -165,6 +193,7 @@ export interface WorkspacePlanSpawnInputs {
165
193
  env: Record<string, string>;
166
194
  flags: string[];
167
195
  systemPrompt?: string;
196
+ appendSystemPrompt?: string;
168
197
  }
169
198
  /** Result of applying a public plan at the private executor boundary. */
170
199
  export interface WorkspacePlanApplication {
@@ -207,6 +236,7 @@ export interface AgentCandidateWorkspacePlan {
207
236
  env: Record<string, AgentCandidateConfigValue>;
208
237
  flags: AgentCandidateConfigValue[];
209
238
  systemPrompt?: AgentCandidateConfigValue;
239
+ appendSystemPrompt?: AgentCandidateConfigValue;
210
240
  unsupported: Unsupported[];
211
241
  }
212
242
  export interface AgentCandidateWorkspacePlanReceipt {
@@ -215,6 +245,7 @@ export interface AgentCandidateWorkspacePlanReceipt {
215
245
  env: Record<string, AgentCandidateConfigValue>;
216
246
  flags: AgentCandidateConfigValue[];
217
247
  systemPrompt?: AgentCandidateConfigValue;
248
+ appendSystemPrompt?: AgentCandidateConfigValue;
218
249
  unsupported: Unsupported[];
219
250
  }
220
251
  export interface MaterializeCandidateProfileOptions {
@@ -277,6 +308,8 @@ export declare function normalizeSkillMd(name: string, raw: string): string;
277
308
  */
278
309
  export declare function materializeProfile(inputProfile: AgentProfile, harness: HarnessId, opts?: {
279
310
  skip?: SkippableDimension[];
311
+ /** Launcher controls the calling executor owns — see {@link BINDABLE_LAUNCH_CONTROLS}. */
312
+ binds?: readonly BindableLaunchControl[];
280
313
  }): WorkspacePlan;
281
314
  /**
282
315
  * Materialize a sealed candidate profile without weakening its process values.
@@ -339,3 +372,45 @@ export type OpenCodeMcpConfig = Record<string, {
339
372
  }>;
340
373
  /** Lower canonical MCP definitions to OpenCode's native startup configuration. */
341
374
  export declare function openCodeMcpConfig(mcp: Record<string, AgentProfileMcpServer>): OpenCodeMcpConfig;
375
+ export interface PrimeProfileSkill {
376
+ name: string;
377
+ content: string;
378
+ }
379
+ export interface PrimeProfileSubagent {
380
+ name: string;
381
+ description?: string;
382
+ prompt?: string;
383
+ metadata?: Record<string, unknown>;
384
+ }
385
+ /** Validated, materialization-ready view of the profile controls prime applies. */
386
+ export interface PrimeProfileControls {
387
+ systemPrompt?: string;
388
+ appendSystemPrompt?: string;
389
+ skills: PrimeProfileSkill[];
390
+ subagents: PrimeProfileSubagent[];
391
+ }
392
+ /**
393
+ * Validate and extract the profile controls the prime agent-dir lowering
394
+ * materializes. Throws on any control (or control facet) the fork has no
395
+ * verified read path for — partial acceptance would silently drop the rest.
396
+ */
397
+ export declare function readPrimeProfileControls(profile: AgentProfile | undefined): PrimeProfileControls;
398
+ /** Deterministic harness-state entry id for a profile subagent name. */
399
+ export declare function primeSubagentEntryId(name: string): string;
400
+ /**
401
+ * Materialize the validated controls into `agentDir` and return the extra
402
+ * spawn args that bind them. Runs before every spawn (same place models.json
403
+ * is materialized), so a continual agent dir is re-pinned to the current
404
+ * profile on each fresh prime process.
405
+ */
406
+ export declare function materializePrimeProfileControls(agentDir: string, controls: PrimeProfileControls, warn: (msg: string, meta?: Record<string, unknown>) => void): string[];
407
+ /**
408
+ * Upsert profile subagent specs into the fork's global continual-harness
409
+ * store and remove profile-sourced entries the current profile no longer
410
+ * declares. Agent-written entries (`source: "agent"`) are never touched.
411
+ *
412
+ * A pre-existing unparsable store degrades to empty exactly like the fork's
413
+ * own loader (refinement.ts:290-300 — "a corrupt state file must degrade to
414
+ * empty rather than throw"), with a warning so the reset is visible.
415
+ */
416
+ export declare function seedPrimeProfileSubagents(agentDir: string, subagents: PrimeProfileSubagent[], warn: (msg: string, meta?: Record<string, unknown>) => void): void;
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, 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",
@@ -110,6 +112,33 @@ function assertSkippableDimensions(skip) {
110
112
  }
111
113
  return skip;
112
114
  }
115
+ /**
116
+ * Launcher controls an executor can declare it OWNS via `materializeProfile`'s
117
+ * `binds` option.
118
+ *
119
+ * Some harness controls live outside any workspace: opencode's exact
120
+ * system-prompt replacement (`agent.<name>.prompt`) binds to the ONE agent
121
+ * whoever starts the process selects, which a plan-forwarding executor cannot
122
+ * 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.
128
+ */
129
+ export const BINDABLE_LAUNCH_CONTROLS = ["systemPrompt"];
130
+ const BINDABLE_LAUNCH_CONTROL_SET = new Set(BINDABLE_LAUNCH_CONTROLS);
131
+ /** Reject a binding declaration this materializer has no lowering for, rather than ignoring it. */
132
+ function assertBindableLaunchControls(binds) {
133
+ if (!binds)
134
+ return new Set();
135
+ const unknown = binds.filter((control) => !BINDABLE_LAUNCH_CONTROL_SET.has(control));
136
+ if (unknown.length > 0) {
137
+ throw new Error(`materializeProfile: cannot bind ${unknown.join(", ")}. ` +
138
+ `Bindable launch controls are: ${BINDABLE_LAUNCH_CONTROLS.join(", ")}.`);
139
+ }
140
+ return new Set(binds);
141
+ }
113
142
  /** The plan was applied, but one or more private staging files remain. */
114
143
  export class WorkspacePlanCleanupError extends AggregateError {
115
144
  receipt;
@@ -183,6 +212,10 @@ const CONTEXT_FILE = {
183
212
  opencode: ".opencode/profile-instructions.md",
184
213
  "kimi-code": "AGENTS.md",
185
214
  pi: "AGENTS.md",
215
+ // prime (PrimeIntellect-ai/prime-agent v0.7.0, be9e2fa0) walks cwd and its
216
+ // ancestors for AGENTS.md/CLAUDE.md, cwd first (fork resource-loader.ts:59
217
+ // candidates, :94-109 walk from cwd inside loadProjectContextFiles).
218
+ prime: "AGENTS.md",
186
219
  gemini: "GEMINI.md", // current default; AGENTS.md only via .gemini/settings.json context.fileName
187
220
  hermes: "HERMES.md", // highest-priority native name; only ONE context loads (first match)
188
221
  openclaw: "AGENTS.md",
@@ -202,6 +235,12 @@ export const HARNESS_SKILL_DIRS = {
202
235
  opencode: ".opencode/skills",
203
236
  "kimi-code": ".kimi/skills",
204
237
  pi: ".pi/skills",
238
+ // prime auto-discovers project skills from <cwd>/<configDir>/skills where the
239
+ // fork ships configDir ".prime/agent" (fork package.json piConfig, config.ts:494;
240
+ // package-manager.ts:885 projectBaseDir, :2175-2180 projectDirs.skills,
241
+ // :2209-2213 collectAutoSkillEntries). The loader recurses into
242
+ // <name>/SKILL.md subdirs (skills.ts:328-372).
243
+ prime: ".prime/agent/skills",
205
244
  gemini: ".gemini/skills",
206
245
  hermes: null, // skills are ~/.hermes/skills (user) only — no cwd discovery
207
246
  openclaw: "skills", // <workspace>/skills/<name>/SKILL.md
@@ -226,11 +265,14 @@ const DEFAULT_FILE_MODE = 0o644;
226
265
  const PRIVATE_FILE_MODE = 0o600;
227
266
  const EXECUTABLE_FILE_MODE = 0o755;
228
267
  const DEFAULT_RESOURCE_FETCH_TIMEOUT_MS = 30_000;
268
+ const OPENCODE_PRIMARY_AGENT_PREFIX = "tangle-profile-";
229
269
  const MAX_RESOURCE_BYTES = 10 * 1024 * 1024;
230
270
  const MAX_PROFILE_RESOURCE_BYTES = 32 * 1024 * 1024;
231
271
  const MAX_PROFILE_RESOURCE_REFS = 128;
232
272
  const MAX_CONCURRENT_RESOURCE_FETCHES = 8;
233
273
  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";
234
276
  const SAFE_WRITE_FLAGS = fsConstants.O_WRONLY |
235
277
  fsConstants.O_CREAT |
236
278
  fsConstants.O_EXCL |
@@ -575,6 +617,7 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
575
617
  const profile = agentProfileSchema.parse(inputProfile);
576
618
  const h = harness;
577
619
  const skip = new Set(assertSkippableDimensions(opts.skip));
620
+ const binds = assertBindableLaunchControls(opts.binds);
578
621
  const plan = {
579
622
  harness: h,
580
623
  files: [],
@@ -682,15 +725,60 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
682
725
  }
683
726
  }
684
727
  }
685
- // 4) PROMPT. A system prompt is a replacement, while instructions are
686
- // additive workspace context. Never lower one into the other.
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.
734
+ //
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 = [];
687
740
  if (profile.prompt?.systemPrompt !== undefined) {
688
- if (h === "claude-code" ||
689
- h === "codex" ||
690
- h === "opencode" ||
691
- h === "pi") {
741
+ if (h === "opencode") {
742
+ const systemPrompt = profile.prompt.systemPrompt;
743
+ if (systemPrompt.trim().length === 0) {
744
+ throw new Error("opencode cannot express an empty exact system-prompt replacement");
745
+ }
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"]');
756
+ }
757
+ else {
758
+ const primaryAgentName = `${OPENCODE_PRIMARY_AGENT_PREFIX}${sha256(systemPrompt).slice(0, 16)}`;
759
+ const primaryAgentPath = `.opencode/agents/${primaryAgentName}.md`;
760
+ if (profile.subagents?.[primaryAgentName] !== undefined) {
761
+ throw new Error(`opencode: subagent name "${primaryAgentName}" is reserved for the profile primary agent`);
762
+ }
763
+ plan.systemPrompt = systemPrompt;
764
+ add(primaryAgentPath, ["---", "mode: primary", "---", systemPrompt, ""].join("\n"));
765
+ plan.flags.push("--agent", primaryAgentName);
766
+ }
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).
692
773
  plan.systemPrompt = profile.prompt.systemPrompt;
693
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
+ }
694
782
  else if (h === "gemini") {
695
783
  add(".gemini/system.md", profile.prompt.systemPrompt);
696
784
  plan.env.GEMINI_SYSTEM_MD = "1";
@@ -699,6 +787,40 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
699
787
  unsupported("systemPrompt", `${h}: no verified exact system-prompt replacement control`);
700
788
  }
701
789
  }
790
+ if (profile.prompt?.appendSystemPrompt !== undefined) {
791
+ const appendSystemPrompt = profile.prompt.appendSystemPrompt;
792
+ if (appendSystemPrompt.trim().length === 0) {
793
+ throw new Error("prompt.appendSystemPrompt must be non-empty text");
794
+ }
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;
801
+ }
802
+ 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")
808
+ ? appendSystemPrompt
809
+ : `${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");
815
+ }
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");
819
+ }
820
+ else {
821
+ unsupported("systemPrompt", `${h}: no verified additive system-prompt control`);
822
+ }
823
+ }
702
824
  const instr = [];
703
825
  if (profile.prompt?.instructions?.length)
704
826
  instr.push(...profile.prompt.instructions);
@@ -713,14 +835,16 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
713
835
  if (instr.length) {
714
836
  const instructionPath = CONTEXT_FILE[h];
715
837
  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
- }));
721
- }
838
+ if (h === "opencode")
839
+ openCodeInstructionFiles.push(instructionPath);
722
840
  // gemini reads AGENTS.md only via settings; we write GEMINI.md so no extra step.
723
841
  }
842
+ if (openCodeInstructionFiles.length > 0) {
843
+ add("opencode.json", serializeWorkspacePlanJson({
844
+ $schema: "https://opencode.ai/config.json",
845
+ instructions: openCodeInstructionFiles,
846
+ }));
847
+ }
724
848
  // 5) ACTIVE EXECUTION CONTROLS. These alter the tools visible to the primary
725
849
  // process and its permission policy, so they must be lowered into native
726
850
  // launch/config inputs rather than merely retained in profile metadata.
@@ -836,6 +960,11 @@ export function materializeCandidateProfile(profile, harness, opts = {}) {
836
960
  ...(classicPlan.systemPrompt === undefined
837
961
  ? {}
838
962
  : { systemPrompt: candidatePublicValue(classicPlan.systemPrompt) }),
963
+ ...(classicPlan.appendSystemPrompt === undefined
964
+ ? {}
965
+ : {
966
+ appendSystemPrompt: candidatePublicValue(classicPlan.appendSystemPrompt),
967
+ }),
839
968
  unsupported: classicPlan.unsupported,
840
969
  };
841
970
  }
@@ -1229,6 +1358,9 @@ export function hashWorkspacePlan(plan) {
1229
1358
  ...(plan.systemPrompt === undefined
1230
1359
  ? {}
1231
1360
  : { systemPrompt: plan.systemPrompt }),
1361
+ ...(plan.appendSystemPrompt === undefined
1362
+ ? {}
1363
+ : { appendSystemPrompt: plan.appendSystemPrompt }),
1232
1364
  unsupported,
1233
1365
  ...(requires.length === 0 ? {} : { requires }),
1234
1366
  });
@@ -1249,6 +1381,9 @@ export function agentCandidateProfilePlanMaterial(plan) {
1249
1381
  ...(plan.systemPrompt === undefined
1250
1382
  ? {}
1251
1383
  : { systemPrompt: plan.systemPrompt }),
1384
+ ...(plan.appendSystemPrompt === undefined
1385
+ ? {}
1386
+ : { appendSystemPrompt: plan.appendSystemPrompt }),
1252
1387
  unsupported: [...plan.unsupported].sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`)),
1253
1388
  };
1254
1389
  return agentCandidateProfilePlanMaterialSchema.parse(material);
@@ -1311,6 +1446,11 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
1311
1446
  ...(plan.systemPrompt === undefined
1312
1447
  ? {}
1313
1448
  : { systemPrompt: candidatePublicString(plan.systemPrompt) }),
1449
+ ...(plan.appendSystemPrompt === undefined
1450
+ ? {}
1451
+ : {
1452
+ appendSystemPrompt: candidatePublicString(plan.appendSystemPrompt),
1453
+ }),
1314
1454
  unsupported: plan.unsupported,
1315
1455
  }, workspaceDir);
1316
1456
  return {
@@ -1325,6 +1465,9 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
1325
1465
  ...(plan.systemPrompt === undefined
1326
1466
  ? {}
1327
1467
  : { systemPrompt: plan.systemPrompt }),
1468
+ ...(plan.appendSystemPrompt === undefined
1469
+ ? {}
1470
+ : { appendSystemPrompt: plan.appendSystemPrompt }),
1328
1471
  unsupported: [...plan.unsupported],
1329
1472
  };
1330
1473
  }
@@ -1412,6 +1555,9 @@ function resolvePublicWorkspacePlan(plan) {
1412
1555
  ...(plan.systemPrompt === undefined
1413
1556
  ? {}
1414
1557
  : { systemPrompt: plan.systemPrompt }),
1558
+ ...(plan.appendSystemPrompt === undefined
1559
+ ? {}
1560
+ : { appendSystemPrompt: plan.appendSystemPrompt }),
1415
1561
  };
1416
1562
  }
1417
1563
  async function resolvePrivateWorkspacePlan(plan, secretProvider) {
@@ -1480,6 +1626,9 @@ async function resolvePrivateWorkspacePlan(plan, secretProvider) {
1480
1626
  ...(plan.systemPrompt === undefined
1481
1627
  ? {}
1482
1628
  : { systemPrompt: plan.systemPrompt }),
1629
+ ...(plan.appendSystemPrompt === undefined
1630
+ ? {}
1631
+ : { appendSystemPrompt: plan.appendSystemPrompt }),
1483
1632
  };
1484
1633
  }
1485
1634
  function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorkspacePlan(plan)) {
@@ -1493,6 +1642,9 @@ function safeWorkspacePlanReceipt(plan, written, workspacePlanDigest = hashWorks
1493
1642
  ...(plan.systemPrompt === undefined
1494
1643
  ? {}
1495
1644
  : { systemPrompt: plan.systemPrompt }),
1645
+ ...(plan.appendSystemPrompt === undefined
1646
+ ? {}
1647
+ : { appendSystemPrompt: plan.appendSystemPrompt }),
1496
1648
  unsupported: plan.unsupported.map((entry) => ({ ...entry })),
1497
1649
  written: [...written],
1498
1650
  };
@@ -1613,6 +1765,9 @@ function applyResolvedWorkspacePlan(publicPlan, resolvedPlan, workspaceDir, opti
1613
1765
  ...(resolvedPlan.systemPrompt === undefined
1614
1766
  ? {}
1615
1767
  : { systemPrompt: resolvedPlan.systemPrompt }),
1768
+ ...(resolvedPlan.appendSystemPrompt === undefined
1769
+ ? {}
1770
+ : { appendSystemPrompt: resolvedPlan.appendSystemPrompt }),
1616
1771
  },
1617
1772
  runtimeOnlyFiles: privateRuntimeFiles,
1618
1773
  };
@@ -2214,6 +2369,19 @@ function materializeMcp(h, mcp, plan, add, unsupported) {
2214
2369
  });
2215
2370
  break;
2216
2371
  }
2372
+ case "prime":
2373
+ // The fork's MCP config surface (`mcpServers` in <agentDir>/settings.json,
2374
+ // fork settings-manager.ts:98-120,219) IS read at startup, but a
2375
+ // user-declared server never becomes model-visible: the host McpManager
2376
+ // only registers OAuth providers and serves mcp.config/refresh
2377
+ // host-requests (fork core/mcp/mcp-manager.ts:62-124,156-181), skips
2378
+ // stdio servers entirely (mcp-manager.ts:73), and nothing generates a
2379
+ // skill or prompt section for it — materializing profile.mcp would be a
2380
+ // silently dropped control.
2381
+ unsupported("mcp", "prime: settings.json mcpServers never becomes model-visible (fork mcp-manager.ts:62-124 " +
2382
+ "registers OAuth providers only and skips stdio servers) — model-visible MCP is exactly " +
2383
+ "the fork's built-in catalog skills");
2384
+ break;
2217
2385
  case "hermes":
2218
2386
  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
2387
  break;
@@ -2418,6 +2586,9 @@ function materializeHooks(h, hooks, add, unsupported, plan) {
2418
2586
  case "pi":
2419
2587
  unsupported("hooks", "pi hooks are TS extensions (.pi/extensions/*.ts) — build an extension");
2420
2588
  break;
2589
+ case "prime":
2590
+ unsupported("hooks", "prime hooks are TS extensions (fork core/extensions), not command hooks — build an extension");
2591
+ break;
2421
2592
  case "hermes":
2422
2593
  unsupported("hooks", "hermes project plugins do not expose an exact command-hook mapping");
2423
2594
  break;
@@ -2678,6 +2849,16 @@ function materializeSubagent(h, name, sa, plan, add, unsupported) {
2678
2849
  registerCodexAgent(plan, name, relPath, sa.description ?? name);
2679
2850
  break;
2680
2851
  }
2852
+ case "prime":
2853
+ // Prime subagents ARE expressible — as entries in the continual-harness
2854
+ // store at <agentDir>/harness/harness_state.json (fork refinement.ts:269-279,
2855
+ // re-read on every system-prompt build: agent-session.ts:7548-7554) — but
2856
+ // that store lives under the executor-owned agent dir, which no
2857
+ // workspace-relative plan file can reach. Executors that own the agent
2858
+ // dir seed it with seedPrimeProfileSubagents; the plan stays fail-closed
2859
+ // so a plan-forwarding caller can never believe the roster is in force.
2860
+ 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`);
2861
+ break;
2681
2862
  default:
2682
2863
  unsupported("subagents", `${h}: subagents have no cwd file (config/flag/global only); "${name}" not mounted`);
2683
2864
  }
@@ -2721,6 +2902,12 @@ function materializeCommand(h, name, body, add, unsupported) {
2721
2902
  case "pi":
2722
2903
  add(`.pi/prompts/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
2723
2904
  break;
2905
+ case "prime":
2906
+ // Auto-discovered project prompt templates: <cwd>/.prime/agent/prompts
2907
+ // (fork prompt-templates.ts:218 resolves CONFIG_DIR_NAME "prompts";
2908
+ // package-manager.ts:2176-2180 auto-adds the same projectDirs.prompts).
2909
+ add(`.prime/agent/prompts/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
2910
+ break;
2724
2911
  case "kimi-code":
2725
2912
  unsupported("commands", "kimi commands ARE skills (/skill:<name>) — provide as a skill instead");
2726
2913
  break;
@@ -2731,3 +2918,347 @@ function materializeCommand(h, name, body, add, unsupported) {
2731
2918
  unsupported("commands", `${h}: no cwd command path`);
2732
2919
  }
2733
2920
  }
2921
+ // ─── Prime agent-dir lowering ────────────────────────────────────────────────
2922
+ //
2923
+ // Prime (PrimeIntellect-ai/prime-agent v0.7.0, be9e2fa0) reads several profile
2924
+ // dimensions from its AGENT DIR — a per-process config root the EXECUTOR owns
2925
+ // (env PRIME_AGENT_CODING_AGENT_DIR, fork config.ts:498,521-527), which no
2926
+ // workspace-relative plan file can reach. The helpers below are that lowering,
2927
+ // shared so cli-bridge's prime backend and sdk-provider-prime consume one
2928
+ // implementation instead of hand-rolling byte-divergent copies. Every control
2929
+ // accepted here is backed by a verified read path in the pinned fork; every
2930
+ // other control is rejected loudly so a caller can never believe a
2931
+ // behavior-relevant setting is in force when it is not. The verified paths:
2932
+ //
2933
+ // - `prompt.systemPrompt` / `prompt.appendSystemPrompt` → `--system-prompt` /
2934
+ // `--append-system-prompt` (cli/args.ts:139-143). Both ride
2935
+ // `AgentSessionRuntimeConfig` (main.ts:643-644) through BOTH acp spawn
2936
+ // paths — the daemon-client create request (main.ts:1512-1520 →
2937
+ // daemon-mode.ts:1310/1478) and the in-process fallback — into
2938
+ // `resourceLoaderOptions` (main.ts:745-746), where the replacement DELETES
2939
+ // the built-in prompt (resource-loader.ts:482-485, system-prompt.ts:72-73)
2940
+ // and the addition appends after it (system-prompt.ts:61,109-110). Values
2941
+ // are materialized as FILES under the agent dir because the flag value is
2942
+ // read as a file when the path exists (resource-loader.ts:41-56), which
2943
+ // keeps literal-vs-path resolution deterministic.
2944
+ //
2945
+ // - `resources.skills` (inline refs) → `--skill <agentDir>/profile/skills`
2946
+ // (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
2949
+ // rendered into the system prompt (agent-session.ts:4272-4291,
2950
+ // system-prompt.ts:87-89). The fork silently DROPS a SKILL.md without a
2951
+ // frontmatter description (skills.ts:416-419), so content is normalized
2952
+ // through {@link normalizeSkillMd}, which guarantees one.
2953
+ //
2954
+ // - `subagents` → `subagent` entries in the fork's continual-harness store at
2955
+ // `<agentDir>/harness/harness_state.json` (refinement.ts:269-279). The
2956
+ // store is re-read on every system-prompt build (agent-session.ts:4288,
2957
+ // 7548-7554) and subagent entries render as a task-shaped roster the model
2958
+ // spawns via `await rlm('<task>')` (refinement.ts:483-496); the kernel
2959
+ // reaches the same store through the host-set RLM_GLOBAL_HARNESS_STATE_DIR
2960
+ // (agent-session.ts:8805).
2961
+ //
2962
+ // Kept REJECTED with the fork evidence:
2963
+ //
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.)
2981
+ /** Skill name rules enforced by the fork's Agent Skills loader (skills.ts:122-147). */
2982
+ const PRIME_SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2983
+ const PRIME_MAX_SKILL_NAME_LENGTH = 64;
2984
+ const PRIME_PROFILE_DIR_NAME = "profile";
2985
+ const PRIME_SKILLS_DIR_NAME = "skills";
2986
+ const PRIME_SYSTEM_PROMPT_FILE = "system-prompt.md";
2987
+ const PRIME_APPEND_SYSTEM_PROMPT_FILE = "append-system-prompt.md";
2988
+ /** Fork continual-harness store location under the agent dir (refinement.ts:269-279). */
2989
+ const PRIME_HARNESS_DIR_NAME = "harness";
2990
+ const PRIME_HARNESS_STATE_FILE = "harness_state.json";
2991
+ const PRIME_HARNESS_KINDS = ["prompt", "memory", "skill", "subagent"];
2992
+ /**
2993
+ * Provenance marker for entries pinned from the profile. Stale removal keys on
2994
+ * it, so agent-written (`source: "agent"`) and user-written entries are never
2995
+ * touched.
2996
+ */
2997
+ const PRIME_PROFILE_ENTRY_SOURCE = "profile";
2998
+ function rejectPrimeControl(control, reason) {
2999
+ throw new Error(`prime does not support AgentProfile ${control}: ${reason}`);
3000
+ }
3001
+ /**
3002
+ * 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.
3005
+ */
3006
+ export function readPrimeProfileControls(profile) {
3007
+ const controls = { skills: [], subagents: [] };
3008
+ if (!profile)
3009
+ return controls;
3010
+ if (profile.harness !== undefined && profile.harness !== "prime") {
3011
+ throw new Error(`prime cannot run a profile pinned to harness "${profile.harness}" — ` +
3012
+ 'only "prime" (or an unset harness preference) is accepted.');
3013
+ }
3014
+ if (profile.prompt) {
3015
+ const { systemPrompt, appendSystemPrompt, instructions } = profile.prompt;
3016
+ const knownPromptFields = new Set([
3017
+ "systemPrompt",
3018
+ "appendSystemPrompt",
3019
+ "instructions",
3020
+ ]);
3021
+ const unknown = Object.entries(profile.prompt)
3022
+ .filter(([key, value]) => !knownPromptFields.has(key) && value !== undefined)
3023
+ .map(([key]) => key);
3024
+ if (unknown.length > 0) {
3025
+ rejectPrimeControl(`prompt fields: ${unknown.join(", ")}`, "no verified prime-agent read path exists for them.");
3026
+ }
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
+ if (systemPrompt !== undefined) {
3034
+ if (typeof systemPrompt !== "string" || systemPrompt.trim() === "") {
3035
+ rejectPrimeControl("prompt.systemPrompt", "an empty replacement would delete the built-in system prompt and " +
3036
+ "install nothing.");
3037
+ }
3038
+ controls.systemPrompt = systemPrompt;
3039
+ }
3040
+ if (appendSystemPrompt !== undefined) {
3041
+ if (typeof appendSystemPrompt !== "string" ||
3042
+ appendSystemPrompt.trim() === "") {
3043
+ rejectPrimeControl("prompt.appendSystemPrompt", "must be non-empty text.");
3044
+ }
3045
+ controls.appendSystemPrompt = appendSystemPrompt;
3046
+ }
3047
+ }
3048
+ if (profile.resources) {
3049
+ const { skills, failOnError, ...unsupported } = profile.resources;
3050
+ const rejectedKeys = Object.entries(unsupported)
3051
+ .filter(([, value]) => value !== undefined)
3052
+ .map(([key]) => key);
3053
+ 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).");
3056
+ }
3057
+ if (failOnError === false) {
3058
+ rejectPrimeControl("resources.failOnError: false", "this adapter always fails loud on a resource it cannot " +
3059
+ "materialize; lenient materialization is not implemented.");
3060
+ }
3061
+ const seen = new Set();
3062
+ for (const ref of skills ?? []) {
3063
+ if (ref.kind !== "inline") {
3064
+ rejectPrimeControl(`resources.skills ref "${"path" in ref ? ref.path : ""}"`, "only inline skill refs are accepted — resolve GitHub refs " +
3065
+ "(resolveAgentProfileResources) before handing the profile to " +
3066
+ "this adapter.");
3067
+ }
3068
+ const name = ref.name;
3069
+ if (!PRIME_SKILL_NAME_RE.test(name) ||
3070
+ 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).`);
3073
+ }
3074
+ if (seen.has(name)) {
3075
+ rejectPrimeControl(`resources.skills name "${name}"`, "duplicate skill names collide in the fork's skill registry.");
3076
+ }
3077
+ seen.add(name);
3078
+ if (ref.content.trim() === "") {
3079
+ rejectPrimeControl(`resources.skills "${name}"`, "skill content is empty.");
3080
+ }
3081
+ controls.skills.push({ name, content: ref.content });
3082
+ }
3083
+ }
3084
+ if (profile.subagents) {
3085
+ const usedIds = new Map();
3086
+ for (const [name, spec] of Object.entries(profile.subagents)) {
3087
+ if (name.trim() === "") {
3088
+ rejectPrimeControl("subagents", "a subagent name must be non-empty.");
3089
+ }
3090
+ const inexpressible = [
3091
+ ["model", spec.model],
3092
+ ["tools", spec.tools],
3093
+ ["permissions", spec.permissions],
3094
+ ["maxSteps", spec.maxSteps],
3095
+ ]
3096
+ .filter(([, value]) => value !== undefined)
3097
+ .map(([key]) => key);
3098
+ if (inexpressible.length > 0) {
3099
+ rejectPrimeControl(`subagents["${name}"] fields: ${inexpressible.join(", ")}`, "a prime continual-harness subagent spec is prompt-roster " +
3100
+ "guidance for `await rlm('<task>')` (fork refinement.ts:483-496) " +
3101
+ "and pins no model, tool set, permission, or step budget on the " +
3102
+ "spawned child.");
3103
+ }
3104
+ 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.");
3107
+ }
3108
+ const id = primeSubagentEntryId(name);
3109
+ const clash = usedIds.get(id);
3110
+ if (clash !== undefined) {
3111
+ rejectPrimeControl(`subagents["${name}"]`, `its id "${id}" collides with subagent "${clash}".`);
3112
+ }
3113
+ usedIds.set(id, name);
3114
+ controls.subagents.push({
3115
+ name,
3116
+ description: spec.description,
3117
+ prompt: spec.prompt,
3118
+ metadata: spec.metadata,
3119
+ });
3120
+ }
3121
+ }
3122
+ return controls;
3123
+ }
3124
+ /** Deterministic harness-state entry id for a profile subagent name. */
3125
+ export function primeSubagentEntryId(name) {
3126
+ const slug = name
3127
+ .toLowerCase()
3128
+ .replace(/[^a-z0-9]+/g, "-")
3129
+ .replace(/^-+|-+$/g, "");
3130
+ return `profile-${slug || "subagent"}`;
3131
+ }
3132
+ /**
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.
3137
+ */
3138
+ export function materializePrimeProfileControls(agentDir, controls, warn) {
3139
+ const args = [];
3140
+ const profileDir = join(agentDir, PRIME_PROFILE_DIR_NAME);
3141
+ if (controls.systemPrompt !== undefined) {
3142
+ mkdirSync(profileDir, { recursive: true });
3143
+ const promptPath = join(profileDir, PRIME_SYSTEM_PROMPT_FILE);
3144
+ writeFileSync(promptPath, controls.systemPrompt, "utf8");
3145
+ args.push("--system-prompt", promptPath);
3146
+ }
3147
+ if (controls.appendSystemPrompt !== undefined) {
3148
+ mkdirSync(profileDir, { recursive: true });
3149
+ const appendPath = join(profileDir, PRIME_APPEND_SYSTEM_PROMPT_FILE);
3150
+ writeFileSync(appendPath, controls.appendSystemPrompt, "utf8");
3151
+ args.push("--append-system-prompt", appendPath);
3152
+ }
3153
+ if (controls.skills.length > 0) {
3154
+ const skillsRoot = join(profileDir, PRIME_SKILLS_DIR_NAME);
3155
+ 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");
3159
+ }
3160
+ args.push("--skill", skillsRoot);
3161
+ }
3162
+ seedPrimeProfileSubagents(agentDir, controls.subagents, warn);
3163
+ return args;
3164
+ }
3165
+ function emptyPrimeHarnessState() {
3166
+ const entries = {};
3167
+ for (const kind of PRIME_HARNESS_KINDS)
3168
+ entries[kind] = {};
3169
+ return { schema: 1, entries, refinements: [] };
3170
+ }
3171
+ /**
3172
+ * Upsert profile subagent specs into the fork's global continual-harness
3173
+ * store and remove profile-sourced entries the current profile no longer
3174
+ * declares. Agent-written entries (`source: "agent"`) are never touched.
3175
+ *
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.
3179
+ */
3180
+ export function seedPrimeProfileSubagents(agentDir, subagents, warn) {
3181
+ const statePath = join(agentDir, PRIME_HARNESS_DIR_NAME, PRIME_HARNESS_STATE_FILE);
3182
+ let state = emptyPrimeHarnessState();
3183
+ if (existsSync(statePath)) {
3184
+ 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
+ }
3205
+ }
3206
+ 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();
3212
+ }
3213
+ }
3214
+ else if (subagents.length === 0) {
3215
+ // Nothing to pin and nothing stale to remove.
3216
+ return;
3217
+ }
3218
+ const existing = state.entries.subagent ?? {};
3219
+ const desiredIds = new Set(subagents.map((s) => primeSubagentEntryId(s.name)));
3220
+ let changed = false;
3221
+ for (const [id, entry] of Object.entries(existing)) {
3222
+ if (entry?.source === PRIME_PROFILE_ENTRY_SOURCE && !desiredIds.has(id)) {
3223
+ delete existing[id];
3224
+ changed = true;
3225
+ }
3226
+ }
3227
+ const now = new Date().toISOString();
3228
+ for (const spec of subagents) {
3229
+ const id = primeSubagentEntryId(spec.name);
3230
+ const content = [spec.description?.trim(), spec.prompt?.trim()]
3231
+ .filter((part) => !!part)
3232
+ .join("\n\n");
3233
+ const previous = existing[id];
3234
+ if (previous &&
3235
+ previous.source === PRIME_PROFILE_ENTRY_SOURCE &&
3236
+ previous.title === spec.name &&
3237
+ previous.content === content) {
3238
+ continue;
3239
+ }
3240
+ existing[id] = {
3241
+ id,
3242
+ kind: "subagent",
3243
+ title: spec.name,
3244
+ content,
3245
+ path: "profile",
3246
+ scope: "global",
3247
+ reference: {},
3248
+ arguments: {},
3249
+ metadata: spec.metadata ?? {},
3250
+ source: PRIME_PROFILE_ENTRY_SOURCE,
3251
+ created_at: previous?.created_at ?? now,
3252
+ updated_at: now,
3253
+ version: previous && typeof previous.version === "number"
3254
+ ? previous.version + 1
3255
+ : 1,
3256
+ };
3257
+ changed = true;
3258
+ }
3259
+ state.entries.subagent = existing;
3260
+ if (!changed)
3261
+ return;
3262
+ mkdirSync(join(agentDir, PRIME_HARNESS_DIR_NAME), { recursive: true });
3263
+ writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
3264
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The one canonicalization of a model id, shared by everything that writes a
3
+ * model id into an {@link AgentProfile} and everything that later compares one
4
+ * against it.
5
+ *
6
+ * A model id is qualified by prepending provider segments, and the same model
7
+ * is legitimately spelled at more than one qualification level as it travels:
8
+ * the caller sends `{ provider: "openai-compat", model: "deepseek/deepseek-v4-flash" }`,
9
+ * the profile records the qualified `openai-compat/deepseek/deepseek-v4-flash`,
10
+ * and the CLI is spawned with that qualified form. Two spellings, one model.
11
+ *
12
+ * Every writer and every reader must run both sides through here, so a
13
+ * comparison cannot mistake two spellings of one model for a conflict. Two
14
+ * independent copies of this rule are exactly how that bug arises.
15
+ */
16
+ /**
17
+ * Qualify `modelId` with `provider` — the canonical form of a model id.
18
+ *
19
+ * The already-qualified check is prefix-SPECIFIC rather than "already contains
20
+ * a slash": a router slug legitimately carries an upstream segment of its own
21
+ * (`deepseek/deepseek-v4-flash`, `zai/glm-4.7`), so a generic slash test would
22
+ * leave those unqualified, while an unconditional prefix would double-qualify
23
+ * an id that has already been through here.
24
+ *
25
+ * Idempotent by construction:
26
+ * `qualifyModelId(p, qualifyModelId(p, m)) === qualifyModelId(p, m)`.
27
+ */
28
+ export declare function qualifyModelId<T extends string | undefined>(provider: string | undefined, modelId: T): T;
29
+ /**
30
+ * Do two model ids name the same model?
31
+ *
32
+ * Both operands are qualified by `provider` before comparison, so the
33
+ * qualification level a caller happened to use is not a difference. Two
34
+ * different model names are still different — this narrows a comparison to the
35
+ * model's identity, it does not make the comparison permissive.
36
+ *
37
+ * `provider` is optional because a profile may carry a `model.default` that was
38
+ * qualified by a writer which did not also record `model.provider`. With no
39
+ * provider to qualify by, a bare id and its single-segment-qualified spelling
40
+ * are still the same model, which is the only tolerance the undeclared case gets.
41
+ */
42
+ export declare function modelIdsMatch(a: string | undefined, b: string | undefined, provider?: string): boolean;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The one canonicalization of a model id, shared by everything that writes a
3
+ * model id into an {@link AgentProfile} and everything that later compares one
4
+ * against it.
5
+ *
6
+ * A model id is qualified by prepending provider segments, and the same model
7
+ * is legitimately spelled at more than one qualification level as it travels:
8
+ * the caller sends `{ provider: "openai-compat", model: "deepseek/deepseek-v4-flash" }`,
9
+ * the profile records the qualified `openai-compat/deepseek/deepseek-v4-flash`,
10
+ * and the CLI is spawned with that qualified form. Two spellings, one model.
11
+ *
12
+ * Every writer and every reader must run both sides through here, so a
13
+ * comparison cannot mistake two spellings of one model for a conflict. Two
14
+ * independent copies of this rule are exactly how that bug arises.
15
+ */
16
+ /**
17
+ * Qualify `modelId` with `provider` — the canonical form of a model id.
18
+ *
19
+ * The already-qualified check is prefix-SPECIFIC rather than "already contains
20
+ * a slash": a router slug legitimately carries an upstream segment of its own
21
+ * (`deepseek/deepseek-v4-flash`, `zai/glm-4.7`), so a generic slash test would
22
+ * leave those unqualified, while an unconditional prefix would double-qualify
23
+ * an id that has already been through here.
24
+ *
25
+ * Idempotent by construction:
26
+ * `qualifyModelId(p, qualifyModelId(p, m)) === qualifyModelId(p, m)`.
27
+ */
28
+ export function qualifyModelId(provider, modelId) {
29
+ if (!provider || !modelId)
30
+ return modelId;
31
+ if (modelId === provider || modelId.startsWith(`${provider}/`))
32
+ return modelId;
33
+ return `${provider}/${modelId}`;
34
+ }
35
+ /**
36
+ * Do two model ids name the same model?
37
+ *
38
+ * Both operands are qualified by `provider` before comparison, so the
39
+ * qualification level a caller happened to use is not a difference. Two
40
+ * different model names are still different — this narrows a comparison to the
41
+ * model's identity, it does not make the comparison permissive.
42
+ *
43
+ * `provider` is optional because a profile may carry a `model.default` that was
44
+ * qualified by a writer which did not also record `model.provider`. With no
45
+ * provider to qualify by, a bare id and its single-segment-qualified spelling
46
+ * are still the same model, which is the only tolerance the undeclared case gets.
47
+ */
48
+ export function modelIdsMatch(a, b, provider) {
49
+ if (a === b)
50
+ return true;
51
+ if (a === undefined || b === undefined)
52
+ return false;
53
+ if (qualifyModelId(provider, a) === qualifyModelId(provider, b))
54
+ return true;
55
+ if (provider)
56
+ return false;
57
+ return ((!a.includes("/") && b.endsWith(`/${a}`)) ||
58
+ (!b.includes("/") && a.endsWith(`/${b}`)));
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-profile-materialize",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "Materialize an AgentProfile into the native per-harness workspace files (the one shared materializer for cli-bridge, agent-dev-container, and VB).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,13 +20,13 @@
20
20
  "dist"
21
21
  ],
22
22
  "devDependencies": {
23
- "@tangle-network/agent-interface": "0.43.0",
23
+ "@tangle-network/agent-interface": "0.45.0",
24
24
  "@types/node": "25.6.0",
25
25
  "typescript": "^6.0.3",
26
26
  "vitest": "^4.1.10"
27
27
  },
28
28
  "peerDependencies": {
29
- "@tangle-network/agent-interface": ">=0.38.0 <0.44.0"
29
+ "@tangle-network/agent-interface": ">=0.45.0 <0.46.0"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsc -p tsconfig.json",