@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.d.ts +195 -3
- package/dist/index.js +807 -43
- package/dist/model-id.d.ts +42 -0
- package/dist/model-id.js +59 -0
- package/package.json +3 -3
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.
|
|
686
|
-
//
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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",
|
|
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
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|