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