@tangle-network/agent-profile-materialize 0.15.2 → 0.17.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/candidate-types.d.ts +32 -0
- package/dist/candidate-types.js +1 -0
- package/dist/candidate.d.ts +9 -0
- package/dist/candidate.js +279 -0
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +32 -0
- package/dist/control-maps.d.ts +34 -0
- package/dist/control-maps.js +185 -0
- package/dist/execution-controls.d.ts +5 -0
- package/dist/execution-controls.js +186 -0
- package/dist/harness.d.ts +81 -0
- package/dist/harness.js +167 -0
- package/dist/hooks.d.ts +6 -0
- package/dist/hooks.js +261 -0
- package/dist/index.d.ts +24 -623
- package/dist/index.js +15 -3733
- package/dist/mcp.d.ts +22 -0
- package/dist/mcp.js +244 -0
- package/dist/pi-models-config.d.ts +30 -0
- package/dist/pi-models-config.js +46 -0
- package/dist/plan-application.d.ts +10 -0
- package/dist/plan-application.js +152 -0
- package/dist/plan-digest.d.ts +18 -0
- package/dist/plan-digest.js +90 -0
- package/dist/plan-json.d.ts +18 -0
- package/dist/plan-json.js +91 -0
- package/dist/plan-normalize.d.ts +13 -0
- package/dist/plan-normalize.js +156 -0
- package/dist/plan-prompt.d.ts +38 -0
- package/dist/plan-prompt.js +49 -0
- package/dist/plan-support.d.ts +9 -0
- package/dist/plan-support.js +22 -0
- package/dist/plan-types.d.ts +294 -0
- package/dist/plan-types.js +106 -0
- package/dist/prime-agent-dir.d.ts +47 -0
- package/dist/prime-agent-dir.js +164 -0
- package/dist/prime-profile.d.ts +88 -0
- package/dist/prime-profile.js +284 -0
- package/dist/profile-materializer.d.ts +12 -0
- package/dist/profile-materializer.js +59 -0
- package/dist/profile-plan.d.ts +14 -0
- package/dist/profile-plan.js +86 -0
- package/dist/profile-prompt.d.ts +4 -0
- package/dist/profile-prompt.js +105 -0
- package/dist/profile-support.d.ts +8 -0
- package/dist/profile-support.js +6 -0
- package/dist/resource-lowering.d.ts +7 -0
- package/dist/resource-lowering.js +103 -0
- package/dist/resource-resolution.d.ts +27 -0
- package/dist/resource-resolution.js +241 -0
- package/dist/subagents.d.ts +6 -0
- package/dist/subagents.js +133 -0
- package/dist/workspace-apply.d.ts +12 -0
- package/dist/workspace-apply.js +334 -0
- package/dist/workspace-files.d.ts +8 -0
- package/dist/workspace-files.js +46 -0
- package/package.json +3 -3
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export const tomlStr = (s) => JSON.stringify(s); // valid TOML basic string
|
|
2
|
+
export function isProfilePublicConfigValue(value) {
|
|
3
|
+
return (typeof value === "object" &&
|
|
4
|
+
value !== null &&
|
|
5
|
+
!Array.isArray(value) &&
|
|
6
|
+
value.kind === "public" &&
|
|
7
|
+
typeof value.value === "string");
|
|
8
|
+
}
|
|
9
|
+
export function isProfileSecretRef(value) {
|
|
10
|
+
return (typeof value === "object" &&
|
|
11
|
+
value !== null &&
|
|
12
|
+
!Array.isArray(value) &&
|
|
13
|
+
value.kind === "secret-ref" &&
|
|
14
|
+
typeof value.key === "string");
|
|
15
|
+
}
|
|
16
|
+
export function workspacePlanConfigValue(value) {
|
|
17
|
+
return isProfilePublicConfigValue(value) ? value.value : { ...value };
|
|
18
|
+
}
|
|
19
|
+
export function serializeWorkspacePlanJson(value) {
|
|
20
|
+
const secretSlots = [];
|
|
21
|
+
const lower = (current, path, arrayEntry = false) => {
|
|
22
|
+
if (isProfilePublicConfigValue(current))
|
|
23
|
+
return current.value;
|
|
24
|
+
if (isProfileSecretRef(current)) {
|
|
25
|
+
secretSlots.push({ path: [...path], ref: { ...current } });
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(current)) {
|
|
29
|
+
return current.map((entry, index) => lower(entry, [...path, index], true));
|
|
30
|
+
}
|
|
31
|
+
if (current && typeof current === "object") {
|
|
32
|
+
const output = Object.create(null);
|
|
33
|
+
for (const [key, entry] of Object.entries(current)) {
|
|
34
|
+
const lowered = lower(entry, [...path, key]);
|
|
35
|
+
if (lowered !== undefined) {
|
|
36
|
+
Object.defineProperty(output, key, {
|
|
37
|
+
value: lowered,
|
|
38
|
+
enumerable: true,
|
|
39
|
+
configurable: true,
|
|
40
|
+
writable: true,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return output;
|
|
45
|
+
}
|
|
46
|
+
return current === undefined && arrayEntry ? null : current;
|
|
47
|
+
};
|
|
48
|
+
const content = JSON.stringify(lower(value, []), null, 2);
|
|
49
|
+
if (content === undefined) {
|
|
50
|
+
throw new Error("Generated profile JSON must have a serializable root");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
content,
|
|
54
|
+
...(secretSlots.length === 0 ? {} : { secretSlots }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function workspacePlanArgumentSegments(value) {
|
|
58
|
+
return typeof value === "string" ? [value] : [...value.segments];
|
|
59
|
+
}
|
|
60
|
+
export function concatWorkspacePlanArguments(...values) {
|
|
61
|
+
const segments = values.flatMap(workspacePlanArgumentSegments);
|
|
62
|
+
if (segments.every((segment) => typeof segment === "string")) {
|
|
63
|
+
return segments.join("");
|
|
64
|
+
}
|
|
65
|
+
return { kind: "workspace-plan-argument-template", segments };
|
|
66
|
+
}
|
|
67
|
+
export function joinWorkspacePlanArguments(values, separator) {
|
|
68
|
+
if (values.length === 0)
|
|
69
|
+
return "";
|
|
70
|
+
const joined = [];
|
|
71
|
+
for (const [index, value] of values.entries()) {
|
|
72
|
+
if (index > 0)
|
|
73
|
+
joined.push(separator);
|
|
74
|
+
joined.push(value);
|
|
75
|
+
}
|
|
76
|
+
return concatWorkspacePlanArguments(...joined);
|
|
77
|
+
}
|
|
78
|
+
export function tomlProfileConfigValue(value) {
|
|
79
|
+
if (isProfilePublicConfigValue(value))
|
|
80
|
+
return tomlStr(value.value);
|
|
81
|
+
return {
|
|
82
|
+
kind: "workspace-plan-argument-template",
|
|
83
|
+
segments: [
|
|
84
|
+
{
|
|
85
|
+
kind: "secret",
|
|
86
|
+
ref: { ...value },
|
|
87
|
+
encoding: "json-string",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare function mergeJsonFiles(files: PlanFile[]): PlanFile[];
|
|
2
|
+
export interface NormalizedPlanFile {
|
|
3
|
+
relPath: string;
|
|
4
|
+
content: string;
|
|
5
|
+
mode: number;
|
|
6
|
+
ownership: NonNullable<PlanFile["ownership"]>;
|
|
7
|
+
secretSlots: PlanFileSecretSlot[];
|
|
8
|
+
}
|
|
9
|
+
export declare function valueAtJsonPath(root: unknown, path: readonly (string | number)[]): unknown;
|
|
10
|
+
export declare function normalizePlanFileSecretSlots(file: PlanFile): PlanFileSecretSlot[];
|
|
11
|
+
export declare function normalizePlanFiles(files: PlanFile[]): NormalizedPlanFile[];
|
|
12
|
+
export declare function normalizeWorkspacePlanArgument(value: WorkspacePlanArgument): WorkspacePlanArgument;
|
|
13
|
+
import type { PlanFile, PlanFileSecretSlot, WorkspacePlanArgument } from "./plan-types.js";
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
export function mergeJsonFiles(files) {
|
|
2
|
+
const byPath = new Map();
|
|
3
|
+
for (const f of files) {
|
|
4
|
+
const prev = byPath.get(f.relPath);
|
|
5
|
+
if (!prev) {
|
|
6
|
+
byPath.set(f.relPath, f);
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
9
|
+
if (f.relPath.endsWith(".json")) {
|
|
10
|
+
try {
|
|
11
|
+
const next = JSON.parse(f.content);
|
|
12
|
+
const merged = {
|
|
13
|
+
...JSON.parse(prev.content),
|
|
14
|
+
...next,
|
|
15
|
+
};
|
|
16
|
+
const replacedRoots = new Set(Object.keys(next));
|
|
17
|
+
const secretSlots = [
|
|
18
|
+
...(prev.secretSlots ?? []).filter((slot) => typeof slot.path[0] === "string" &&
|
|
19
|
+
!replacedRoots.has(slot.path[0])),
|
|
20
|
+
...(f.secretSlots ?? []),
|
|
21
|
+
];
|
|
22
|
+
const { secretSlots: _previousSecretSlots, ...previousFile } = prev;
|
|
23
|
+
byPath.set(f.relPath, {
|
|
24
|
+
...previousFile,
|
|
25
|
+
content: JSON.stringify(merged, null, 2),
|
|
26
|
+
...(secretSlots.length > 0 ? { mode: PRIVATE_FILE_MODE } : {}),
|
|
27
|
+
...(secretSlots.length === 0 ? {} : { secretSlots }),
|
|
28
|
+
});
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* fall through to last-wins */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
byPath.set(f.relPath, f); // non-JSON or parse-fail: last wins
|
|
36
|
+
}
|
|
37
|
+
return [...byPath.values()];
|
|
38
|
+
}
|
|
39
|
+
export function valueAtJsonPath(root, path) {
|
|
40
|
+
let current = root;
|
|
41
|
+
for (const segment of path) {
|
|
42
|
+
if (current === null ||
|
|
43
|
+
typeof current !== "object" ||
|
|
44
|
+
!Object.hasOwn(current, segment)) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
current = current[segment];
|
|
48
|
+
}
|
|
49
|
+
return current;
|
|
50
|
+
}
|
|
51
|
+
export function normalizePlanFileSecretSlots(file) {
|
|
52
|
+
if (file.secretSlots === undefined)
|
|
53
|
+
return [];
|
|
54
|
+
if (file.source !== "generated" ||
|
|
55
|
+
!file.relPath.endsWith(".json") ||
|
|
56
|
+
file.secretSlots.length === 0) {
|
|
57
|
+
throw new Error(`Workspace secret slots require a generated JSON file: ${file.relPath}`);
|
|
58
|
+
}
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(file.content);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error(`Workspace secret slots require valid JSON content: ${file.relPath}`);
|
|
65
|
+
}
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
return file.secretSlots.map((slot) => {
|
|
68
|
+
if (!Array.isArray(slot.path) || slot.path.length === 0) {
|
|
69
|
+
throw new Error(`Workspace secret slot path must be non-empty: ${file.relPath}`);
|
|
70
|
+
}
|
|
71
|
+
const path = slot.path.map((segment) => {
|
|
72
|
+
if ((typeof segment !== "string" && typeof segment !== "number") ||
|
|
73
|
+
(typeof segment === "number" &&
|
|
74
|
+
(!Number.isSafeInteger(segment) || segment < 0))) {
|
|
75
|
+
throw new Error(`Workspace secret slot path is invalid: ${file.relPath}`);
|
|
76
|
+
}
|
|
77
|
+
return segment;
|
|
78
|
+
});
|
|
79
|
+
const identity = JSON.stringify(path);
|
|
80
|
+
if (seen.has(identity)) {
|
|
81
|
+
throw new Error(`Duplicate workspace secret slot path: ${file.relPath} ${identity}`);
|
|
82
|
+
}
|
|
83
|
+
seen.add(identity);
|
|
84
|
+
if (valueAtJsonPath(parsed, path) !== null) {
|
|
85
|
+
throw new Error(`Workspace secret slot must point at a null placeholder: ${file.relPath} ${identity}`);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
path,
|
|
89
|
+
ref: agentProfileSecretRefSchema.parse(slot.ref),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export function normalizePlanFiles(files) {
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
return files
|
|
96
|
+
.map((file) => {
|
|
97
|
+
const relPath = normalizeWorkspaceRelativePath(file.relPath);
|
|
98
|
+
if (seen.has(relPath)) {
|
|
99
|
+
throw new Error(`Duplicate workspace plan file: ${relPath}`);
|
|
100
|
+
}
|
|
101
|
+
seen.add(relPath);
|
|
102
|
+
if (typeof file.content !== "string") {
|
|
103
|
+
throw new Error(`Workspace file content must be a string: ${relPath}`);
|
|
104
|
+
}
|
|
105
|
+
const mode = validateFileMode(file.mode);
|
|
106
|
+
const secretSlots = normalizePlanFileSecretSlots(file);
|
|
107
|
+
if (secretSlots.length > 0 && (mode & 0o077) !== 0) {
|
|
108
|
+
throw new Error(`Workspace secret-bearing file must not grant group or other access: ${relPath}`);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
relPath,
|
|
112
|
+
content: file.content,
|
|
113
|
+
mode,
|
|
114
|
+
ownership: file.ownership ?? "shared",
|
|
115
|
+
secretSlots,
|
|
116
|
+
};
|
|
117
|
+
})
|
|
118
|
+
.sort((a, b) => compareStrings(a.relPath, b.relPath));
|
|
119
|
+
}
|
|
120
|
+
export function normalizeWorkspacePlanArgument(value) {
|
|
121
|
+
if (typeof value === "string")
|
|
122
|
+
return value;
|
|
123
|
+
if (value.kind !== "workspace-plan-argument-template" ||
|
|
124
|
+
!Array.isArray(value.segments) ||
|
|
125
|
+
value.segments.length === 0) {
|
|
126
|
+
throw new Error("Workspace plan argument template is malformed");
|
|
127
|
+
}
|
|
128
|
+
const segments = [];
|
|
129
|
+
for (const segment of value.segments) {
|
|
130
|
+
if (typeof segment === "string") {
|
|
131
|
+
const previous = segments.at(-1);
|
|
132
|
+
if (typeof previous === "string") {
|
|
133
|
+
segments[segments.length - 1] = previous + segment;
|
|
134
|
+
}
|
|
135
|
+
else if (segment.length > 0) {
|
|
136
|
+
segments.push(segment);
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (segment.kind !== "secret" ||
|
|
141
|
+
(segment.encoding !== "raw" && segment.encoding !== "json-string")) {
|
|
142
|
+
throw new Error("Workspace plan secret argument segment is malformed");
|
|
143
|
+
}
|
|
144
|
+
segments.push({
|
|
145
|
+
kind: "secret",
|
|
146
|
+
ref: agentProfileSecretRefSchema.parse(segment.ref),
|
|
147
|
+
encoding: segment.encoding,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (segments.every((segment) => typeof segment === "string")) {
|
|
151
|
+
return segments.join("");
|
|
152
|
+
}
|
|
153
|
+
return { kind: "workspace-plan-argument-template", segments };
|
|
154
|
+
}
|
|
155
|
+
import { agentProfileSecretRefSchema } from "@tangle-network/agent-interface";
|
|
156
|
+
import { compareStrings, normalizeWorkspaceRelativePath, PRIVATE_FILE_MODE, validateFileMode, } from "./workspace-files.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare function promptIntentFields<In, Out>(source: {
|
|
2
|
+
systemPrompt?: In;
|
|
3
|
+
appendSystemPrompt?: In;
|
|
4
|
+
}, project: (value: In) => Out): {
|
|
5
|
+
systemPrompt?: Out;
|
|
6
|
+
appendSystemPrompt?: Out;
|
|
7
|
+
};
|
|
8
|
+
/** Identity projection for boundaries that pass the prompt intents through. */
|
|
9
|
+
export declare const samePromptIntentValue: <T>(value: T) => T;
|
|
10
|
+
/**
|
|
11
|
+
* The launcher-bound intent, projected onto an outgoing object and omitted when
|
|
12
|
+
* the source does not carry one.
|
|
13
|
+
*
|
|
14
|
+
* Beside {@link promptIntentFields} rather than inside it, because the two
|
|
15
|
+
* disagree on both ends: the candidate boundaries that helper also serves have
|
|
16
|
+
* no field for a binding — an {@link AgentCandidateWorkspacePlan} is built by a
|
|
17
|
+
* caller that declares none, so it can never hold one — and a value projection
|
|
18
|
+
* such as `candidatePublicString` has nothing to do to a structured binding.
|
|
19
|
+
* Every boundary that carries the plan's own prompt intents calls both.
|
|
20
|
+
*/
|
|
21
|
+
export declare function systemPromptBindingField(source: {
|
|
22
|
+
systemPromptBinding?: WorkspacePlanSystemPromptBinding;
|
|
23
|
+
}): {
|
|
24
|
+
systemPromptBinding?: WorkspacePlanSystemPromptBinding;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The replacement text this launcher must bind to `control`, or `undefined`
|
|
28
|
+
* when the plan carries no launcher-bound intent.
|
|
29
|
+
*
|
|
30
|
+
* Throws for a binding addressed to a DIFFERENT control: the reader cannot
|
|
31
|
+
* execute it, and answering `undefined` would start the agent with the
|
|
32
|
+
* harness's own prompt still in force while nothing reported a problem — the
|
|
33
|
+
* silent downgrade the binding exists to prevent. This is the seam where an
|
|
34
|
+
* executor that cannot bind an intent says so, by name, instead of running a
|
|
35
|
+
* profile whose prompt never took effect.
|
|
36
|
+
*/
|
|
37
|
+
export declare function takeSystemPromptBinding(spawn: Pick<WorkspacePlanSpawnInputs, "systemPromptBinding">, control: ExecutorSystemPromptBinding): string | undefined;
|
|
38
|
+
import type { ExecutorSystemPromptBinding, WorkspacePlanSpawnInputs, WorkspacePlanSystemPromptBinding } from "./plan-types.js";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export function promptIntentFields(source, project) {
|
|
2
|
+
return {
|
|
3
|
+
...(source.systemPrompt === undefined
|
|
4
|
+
? {}
|
|
5
|
+
: { systemPrompt: project(source.systemPrompt) }),
|
|
6
|
+
...(source.appendSystemPrompt === undefined
|
|
7
|
+
? {}
|
|
8
|
+
: { appendSystemPrompt: project(source.appendSystemPrompt) }),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** Identity projection for boundaries that pass the prompt intents through. */
|
|
12
|
+
export const samePromptIntentValue = (value) => value;
|
|
13
|
+
/**
|
|
14
|
+
* The launcher-bound intent, projected onto an outgoing object and omitted when
|
|
15
|
+
* the source does not carry one.
|
|
16
|
+
*
|
|
17
|
+
* Beside {@link promptIntentFields} rather than inside it, because the two
|
|
18
|
+
* disagree on both ends: the candidate boundaries that helper also serves have
|
|
19
|
+
* no field for a binding — an {@link AgentCandidateWorkspacePlan} is built by a
|
|
20
|
+
* caller that declares none, so it can never hold one — and a value projection
|
|
21
|
+
* such as `candidatePublicString` has nothing to do to a structured binding.
|
|
22
|
+
* Every boundary that carries the plan's own prompt intents calls both.
|
|
23
|
+
*/
|
|
24
|
+
export function systemPromptBindingField(source) {
|
|
25
|
+
return source.systemPromptBinding === undefined
|
|
26
|
+
? {}
|
|
27
|
+
: { systemPromptBinding: { ...source.systemPromptBinding } };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The replacement text this launcher must bind to `control`, or `undefined`
|
|
31
|
+
* when the plan carries no launcher-bound intent.
|
|
32
|
+
*
|
|
33
|
+
* Throws for a binding addressed to a DIFFERENT control: the reader cannot
|
|
34
|
+
* execute it, and answering `undefined` would start the agent with the
|
|
35
|
+
* harness's own prompt still in force while nothing reported a problem — the
|
|
36
|
+
* silent downgrade the binding exists to prevent. This is the seam where an
|
|
37
|
+
* executor that cannot bind an intent says so, by name, instead of running a
|
|
38
|
+
* profile whose prompt never took effect.
|
|
39
|
+
*/
|
|
40
|
+
export function takeSystemPromptBinding(spawn, control) {
|
|
41
|
+
const binding = spawn.systemPromptBinding;
|
|
42
|
+
if (!binding)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (binding.control !== control) {
|
|
45
|
+
throw new Error(`Workspace plan carries a system-prompt ${binding.intent} bound to ` +
|
|
46
|
+
`${binding.control}, which this executor cannot apply; it binds ${control}`);
|
|
47
|
+
}
|
|
48
|
+
return binding.systemPrompt;
|
|
49
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Unsupported, WorkspacePlan } from "./plan-types.js";
|
|
2
|
+
export declare function formatUnsupported(unsupported: Unsupported[]): string;
|
|
3
|
+
/** Fail when a profile asks for controls the chosen harness cannot execute. */
|
|
4
|
+
export declare function assertWorkspacePlanSupported(plan: Pick<WorkspacePlan, "unsupported">): void;
|
|
5
|
+
/**
|
|
6
|
+
* Enforce supportability under the profile's failOnError policy.
|
|
7
|
+
* Strict mode throws; best-effort mode returns visible warnings.
|
|
8
|
+
*/
|
|
9
|
+
export declare function enforceWorkspacePlanSupport(plan: Pick<WorkspacePlan, "unsupported">, failOnError: boolean | undefined): string[];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function formatUnsupported(unsupported) {
|
|
2
|
+
return `Unable to materialize ${unsupported.length} profile resource(s):\n${unsupported
|
|
3
|
+
.map(({ dimension, reason }) => `- ${dimension}: ${reason}`)
|
|
4
|
+
.join("\n")}`;
|
|
5
|
+
}
|
|
6
|
+
/** Fail when a profile asks for controls the chosen harness cannot execute. */
|
|
7
|
+
export function assertWorkspacePlanSupported(plan) {
|
|
8
|
+
if (plan.unsupported.length > 0) {
|
|
9
|
+
throw new Error(formatUnsupported(plan.unsupported));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Enforce supportability under the profile's failOnError policy.
|
|
14
|
+
* Strict mode throws; best-effort mode returns visible warnings.
|
|
15
|
+
*/
|
|
16
|
+
export function enforceWorkspacePlanSupport(plan, failOnError) {
|
|
17
|
+
if (failOnError !== false) {
|
|
18
|
+
assertWorkspacePlanSupported(plan);
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
return plan.unsupported.map(({ dimension, reason }) => `${dimension}: ${reason}`);
|
|
22
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
export type { HarnessId } from "./harness.js";
|
|
2
|
+
export interface PlanFile {
|
|
3
|
+
relPath: string;
|
|
4
|
+
content: string;
|
|
5
|
+
/** chmod mode (e.g. 0o755 for hook scripts); default 0o644. */
|
|
6
|
+
mode?: number;
|
|
7
|
+
/**
|
|
8
|
+
* Provenance of this entry. `"generated"` = the materializer synthesized the
|
|
9
|
+
* content itself (native harness config, merged settings, lowered prompt/MCP/
|
|
10
|
+
* hooks/subagents). `"user"` = the exact bytes of a profile-declared resource
|
|
11
|
+
* ref (`resources.files`/`tools`/`agents`/`skills`/`commands`), passed through
|
|
12
|
+
* verbatim at the path the profile author chose.
|
|
13
|
+
*
|
|
14
|
+
* A consumer that needs to reinterpret a SPECIFIC generated file (e.g. parse
|
|
15
|
+
* `opencode.json` as the synthetic native config) MUST gate on
|
|
16
|
+
* `source === "generated"` at that relPath — matching by relPath alone cannot
|
|
17
|
+
* tell a harness-authored config apart from a user resource mount that
|
|
18
|
+
* happens to share the same conventional path, and treating arbitrary user
|
|
19
|
+
* content as trusted generated config is a fail-open bug, not a fallback.
|
|
20
|
+
*
|
|
21
|
+
* Optional: callers that hand-construct a {@link WorkspacePlan} directly for
|
|
22
|
+
* {@link applyWorkspacePlan} (tests, ad hoc writes) are not materializer
|
|
23
|
+
* output and have no provenance to report. `materializeProfile` always sets
|
|
24
|
+
* this on every file it emits.
|
|
25
|
+
*/
|
|
26
|
+
source?: "user" | "generated";
|
|
27
|
+
/**
|
|
28
|
+
* Who owns the PATH — which decides whether an existing file there may be
|
|
29
|
+
* replaced.
|
|
30
|
+
*
|
|
31
|
+
* `"materializer"` = a path this package invented. No harness convention
|
|
32
|
+
* names it and no user authors it, so a file already there can only be this
|
|
33
|
+
* materializer's own output from an earlier application into the same
|
|
34
|
+
* workspace, and replacing it is how turn two of a resumed session gets the
|
|
35
|
+
* current profile instead of the previous one.
|
|
36
|
+
*
|
|
37
|
+
* `"shared"` (the default, and every other file) = a path the user or the
|
|
38
|
+
* harness also owns — `CLAUDE.md`, `AGENTS.md`, `opencode.json`,
|
|
39
|
+
* `.gemini/system.md` are all documented, user-authorable names. An existing
|
|
40
|
+
* file there may be the caller's own work, so it is never replaced.
|
|
41
|
+
*
|
|
42
|
+
* Optional because hand-constructed plans have no path provenance to report;
|
|
43
|
+
* absent reads as `"shared"`, the safe answer.
|
|
44
|
+
*/
|
|
45
|
+
ownership?: "materializer" | "shared";
|
|
46
|
+
/**
|
|
47
|
+
* Secret leaves in a generated JSON document. `content` contains `null` at
|
|
48
|
+
* each path until the private executor resolves the named reference. The
|
|
49
|
+
* public plan, its digest, and its receipt retain only these references.
|
|
50
|
+
*/
|
|
51
|
+
secretSlots?: PlanFileSecretSlot[];
|
|
52
|
+
}
|
|
53
|
+
export interface PlanFileSecretSlot {
|
|
54
|
+
path: Array<string | number>;
|
|
55
|
+
ref: AgentProfileSecretRef;
|
|
56
|
+
}
|
|
57
|
+
export interface WorkspacePlanSecretSegment {
|
|
58
|
+
kind: "secret";
|
|
59
|
+
ref: AgentProfileSecretRef;
|
|
60
|
+
/** Encoding applied after private resolution. */
|
|
61
|
+
encoding: "raw" | "json-string";
|
|
62
|
+
}
|
|
63
|
+
/** A launch argument whose private leaves are resolved immediately before spawn. */
|
|
64
|
+
export interface WorkspacePlanArgumentTemplate {
|
|
65
|
+
kind: "workspace-plan-argument-template";
|
|
66
|
+
segments: Array<string | WorkspacePlanSecretSegment>;
|
|
67
|
+
}
|
|
68
|
+
export type WorkspacePlanArgument = string | WorkspacePlanArgumentTemplate;
|
|
69
|
+
/** Public bytes stay strings; secret references stay opaque until application. */
|
|
70
|
+
export type WorkspacePlanConfigValue = string | AgentProfileSecretRef;
|
|
71
|
+
export interface Unsupported {
|
|
72
|
+
dimension: "files" | "skills" | "mcp" | "hooks" | "subagents" | "commands" | "instructions" | "systemPrompt" | "appendSystemPrompt" | "connections" | "confidential" | "permissions" | "tools" | "modes" | "extensions";
|
|
73
|
+
reason: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The subset of {@link BINDABLE_LAUNCH_CONTROLS} the plan HANDS OVER rather than
|
|
77
|
+
* executes: controls a caller reaches only by composing the harness's own
|
|
78
|
+
* configuration, so no file, flag or env var the plan writes can touch them.
|
|
79
|
+
*
|
|
80
|
+
* - `opencode.agent-prompt` — opencode's `agent.<name>.prompt` config field,
|
|
81
|
+
* the one control that replaces its built-in provider prompt. It binds to a
|
|
82
|
+
* SINGLE agent, and the agent is chosen at launch. A caller that builds
|
|
83
|
+
* opencode's config itself and picks the agent there reads neither
|
|
84
|
+
* `plan.files` nor `plan.flags`, so for that one the plan names the control
|
|
85
|
+
* and hands over the text in {@link WorkspacePlan.systemPromptBinding}.
|
|
86
|
+
*
|
|
87
|
+
* Every other bindable control names a capability the plan still lowers itself
|
|
88
|
+
* once the caller has declared the plan's launch inputs really reach the spawn.
|
|
89
|
+
*/
|
|
90
|
+
export declare const EXECUTOR_SYSTEM_PROMPT_BINDINGS: readonly ["opencode.agent-prompt"];
|
|
91
|
+
export type ExecutorSystemPromptBinding = (typeof EXECUTOR_SYSTEM_PROMPT_BINDINGS)[number];
|
|
92
|
+
export declare function isExecutorSystemPromptBinding(value: string): value is ExecutorSystemPromptBinding;
|
|
93
|
+
/**
|
|
94
|
+
* A system-prompt intent handed to the launcher that declared it owns the
|
|
95
|
+
* control, instead of being lowered into the workspace.
|
|
96
|
+
*
|
|
97
|
+
* Distinct from {@link WorkspacePlan.systemPrompt} — a launch ARGUMENT the
|
|
98
|
+
* executor hands to a harness flag — and from the file/flag/env lowerings the
|
|
99
|
+
* plan performs on its own. This names the exact control that must receive the
|
|
100
|
+
* text and which of the two intents it is, so the launcher cannot apply it to
|
|
101
|
+
* the wrong channel. Never written into an additive channel and never dropped:
|
|
102
|
+
* a caller that has not declared it owns `control` gets the plan's own lowering
|
|
103
|
+
* (or a refusal where the harness has no control at all) instead of this field.
|
|
104
|
+
*/
|
|
105
|
+
export interface WorkspacePlanSystemPromptBinding {
|
|
106
|
+
/** Which of the two prompt intents this is — never inferred from presence. */
|
|
107
|
+
intent: "replace";
|
|
108
|
+
/** The exact launcher control that must receive `systemPrompt`. */
|
|
109
|
+
control: ExecutorSystemPromptBinding;
|
|
110
|
+
/** Exact text the control receives. */
|
|
111
|
+
systemPrompt: string;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The dimensions `materializeProfile`'s `skip` option honors.
|
|
115
|
+
*
|
|
116
|
+
* Every other dimension is materialized unconditionally: generated plan files are pushed through
|
|
117
|
+
* a single site that carries no dimension tag, so there is nothing to gate them on. Naming the
|
|
118
|
+
* honored set keeps `skip: ["hooks"]` a loud rejection instead of a silent no-op.
|
|
119
|
+
*/
|
|
120
|
+
export declare const SKIPPABLE_DIMENSIONS: readonly ["mcp", "extensions"];
|
|
121
|
+
export type SkippableDimension = (typeof SKIPPABLE_DIMENSIONS)[number];
|
|
122
|
+
export declare function isSkippableDimension(value: string): value is SkippableDimension;
|
|
123
|
+
/** Reject a dimension `materializeProfile` would not actually skip, rather than ignoring it. */
|
|
124
|
+
export declare function assertSkippableDimensions(skip: readonly string[] | undefined): readonly SkippableDimension[];
|
|
125
|
+
/**
|
|
126
|
+
* Launcher controls an executor can declare it OWNS via `materializeProfile`'s
|
|
127
|
+
* `binds` option.
|
|
128
|
+
*
|
|
129
|
+
* Some harness controls live outside any workspace: opencode's exact
|
|
130
|
+
* system-prompt replacement (`agent.<name>.prompt`) binds to the ONE agent
|
|
131
|
+
* whoever starts the process selects, which a plan-forwarding executor cannot
|
|
132
|
+
* guarantee — so the plan refuses the intent (harnessSystemPromptIntents in
|
|
133
|
+
* @tangle-network/agent-interface answers `replace: false` for opencode).
|
|
134
|
+
*
|
|
135
|
+
* The two declarations describe the two executor shapes that CAN reach it, and
|
|
136
|
+
* each gets the lowering it can actually apply:
|
|
137
|
+
*
|
|
138
|
+
* - `systemPrompt` — the executor applies the plan's launch flags to the
|
|
139
|
+
* spawn it owns, so the plan writes the generated `mode: primary` agent and
|
|
140
|
+
* selects it with `--agent`.
|
|
141
|
+
* - `opencode.agent-prompt` — the executor composes opencode's own config and
|
|
142
|
+
* picks the agent there, reading neither plan files nor plan flags, so the
|
|
143
|
+
* plan hands the text over in {@link WorkspacePlan.systemPromptBinding}
|
|
144
|
+
* instead of writing a workspace agent that would never be selected.
|
|
145
|
+
*
|
|
146
|
+
* They are mutually exclusive claims about the same caller: declaring both is a
|
|
147
|
+
* caller bug, not a broader capability.
|
|
148
|
+
*/
|
|
149
|
+
export declare const BINDABLE_LAUNCH_CONTROLS: readonly ["systemPrompt", "opencode.agent-prompt"];
|
|
150
|
+
export type BindableLaunchControl = (typeof BINDABLE_LAUNCH_CONTROLS)[number];
|
|
151
|
+
/** Reject a binding declaration this materializer has no lowering for, rather than ignoring it. */
|
|
152
|
+
export declare function assertBindableLaunchControls(binds: readonly string[] | undefined): ReadonlySet<BindableLaunchControl>;
|
|
153
|
+
/** The plan a caller writes into the run cwd, then applies to the spawn. */
|
|
154
|
+
export interface WorkspacePlan {
|
|
155
|
+
harness: HarnessId;
|
|
156
|
+
/** Files to write relative to the run workspace (cwd). */
|
|
157
|
+
files: PlanFile[];
|
|
158
|
+
/** Env vars to set on the spawned process (for harnesses needing them). */
|
|
159
|
+
env: Record<string, WorkspacePlanConfigValue>;
|
|
160
|
+
/** Extra CLI flags to pass (e.g. kimi --mcp-config-file <path>). */
|
|
161
|
+
flags: WorkspacePlanArgument[];
|
|
162
|
+
/**
|
|
163
|
+
* Exact text that REPLACES the harness's own system prompt, for harnesses
|
|
164
|
+
* whose replacement control takes it as a launch argument the executor
|
|
165
|
+
* supplies (claude-code `--system-prompt`, pi `--system-prompt`).
|
|
166
|
+
*
|
|
167
|
+
* Absent does NOT mean "no replacement was requested": a harness whose
|
|
168
|
+
* control reads a file (codex, gemini) carries the replacement in
|
|
169
|
+
* {@link WorkspacePlan.files} plus a flag or env var instead. Present means
|
|
170
|
+
* the executor MUST bind it to that harness's replacement control — never to
|
|
171
|
+
* an additive one, which would leave the built-in prompt in force.
|
|
172
|
+
*/
|
|
173
|
+
systemPrompt?: string;
|
|
174
|
+
/**
|
|
175
|
+
* Exact text ADDED to the harness's own system prompt, which stays intact.
|
|
176
|
+
*
|
|
177
|
+
* Separate from {@link WorkspacePlan.systemPrompt} because the two are
|
|
178
|
+
* different intents against the same channel, and every harness that has one
|
|
179
|
+
* control may lack the other. An executor binds this ONLY to an additive
|
|
180
|
+
* control (claude-code `--append-system-prompt`, pi
|
|
181
|
+
* `--append-system-prompt`); there is no harness for which folding it into
|
|
182
|
+
* the replacement is correct, because that would delete the built-in prompt
|
|
183
|
+
* the caller asked to keep.
|
|
184
|
+
*/
|
|
185
|
+
appendSystemPrompt?: string;
|
|
186
|
+
/**
|
|
187
|
+
* A prompt intent no workspace file, flag, or env var can execute, addressed
|
|
188
|
+
* to the launcher control that can — see
|
|
189
|
+
* {@link WorkspacePlanSystemPromptBinding}.
|
|
190
|
+
*
|
|
191
|
+
* Present ONLY when the caller declared it owns the named control, so a plan
|
|
192
|
+
* built by a caller that forwards it to someone else's launcher never carries
|
|
193
|
+
* one; that caller gets the intent in {@link WorkspacePlan.unsupported}
|
|
194
|
+
* instead. Whoever reads it must bind it or throw — {@link
|
|
195
|
+
* takeSystemPromptBinding} does both.
|
|
196
|
+
*/
|
|
197
|
+
systemPromptBinding?: WorkspacePlanSystemPromptBinding;
|
|
198
|
+
/** Dimensions requested but with NO cwd path on this harness — fail-closed record. */
|
|
199
|
+
unsupported: Unsupported[];
|
|
200
|
+
/**
|
|
201
|
+
* Prerequisites the workspace files CANNOT establish on their own.
|
|
202
|
+
*
|
|
203
|
+
* A plan is a set of files, an env map, and flags. Some harness capabilities need more than
|
|
204
|
+
* that to actually function: pi's MCP support is not built in, it arrives from an extension
|
|
205
|
+
* that must be installed into the harness's own package directory and permitted to load. No
|
|
206
|
+
* file written into a run workspace can make either true.
|
|
207
|
+
*
|
|
208
|
+
* Writing the config anyway and hoping is the failure this field exists to prevent: the
|
|
209
|
+
* harness starts, silently has no MCP, and the agent runs without the tools it was configured
|
|
210
|
+
* with while nothing reports a problem. So the plan states the requirement and the executor —
|
|
211
|
+
* which knows where its harness is installed and how it spawns — verifies or refuses it.
|
|
212
|
+
*
|
|
213
|
+
* Optional because this is an additive field on a published type: a plan built before it
|
|
214
|
+
* existed, or by a caller that does not construct one, is still a valid plan with no
|
|
215
|
+
* prerequisites. Every plan this package builds sets it.
|
|
216
|
+
*/
|
|
217
|
+
requires?: PlanRequirement[];
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* One prerequisite a {@link WorkspacePlan} depends on but cannot create.
|
|
221
|
+
*
|
|
222
|
+
* Declarative on purpose: this package builds plans and never probes a filesystem, spawns a
|
|
223
|
+
* process, or reaches the network. The executor holds that context and does the checking.
|
|
224
|
+
*/
|
|
225
|
+
export interface PlanRequirement {
|
|
226
|
+
/** The only kind so far: a harness extension/plugin that must be loaded for a dimension to work. */
|
|
227
|
+
kind: "harness-extension";
|
|
228
|
+
/** Dimension that produced the requirement, so a refusal can name what will not work. */
|
|
229
|
+
dimension: Unsupported["dimension"];
|
|
230
|
+
/** Package spec in the harness's own vocabulary, e.g. `npm:pi-mcp-adapter`. */
|
|
231
|
+
id: string;
|
|
232
|
+
/** Why the plan needs it — goes verbatim into the executor's refusal message. */
|
|
233
|
+
reason: string;
|
|
234
|
+
/** The exact command that satisfies it, so a failure is actionable without a doc lookup. */
|
|
235
|
+
remedy: string;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Proof of the profile workspace inputs applied before a harness is spawned.
|
|
239
|
+
* `workspacePlanDigest` covers only the {@link WorkspacePlan}; it is not an
|
|
240
|
+
* execution-plan digest and does not attest unrelated files in `workspaceDir`.
|
|
241
|
+
*/
|
|
242
|
+
export interface WorkspacePlanReceipt {
|
|
243
|
+
workspacePlanDigest: string;
|
|
244
|
+
/** Public launch identity only; secret values never enter this receipt. */
|
|
245
|
+
env: Record<string, WorkspacePlanConfigValue>;
|
|
246
|
+
/** Public launch identity only; templates retain opaque secret references. */
|
|
247
|
+
flags: WorkspacePlanArgument[];
|
|
248
|
+
systemPrompt?: string;
|
|
249
|
+
appendSystemPrompt?: string;
|
|
250
|
+
/** The launcher-bound intent, so a receipt says which control owed the work. */
|
|
251
|
+
systemPromptBinding?: WorkspacePlanSystemPromptBinding;
|
|
252
|
+
unsupported: Unsupported[];
|
|
253
|
+
written: string[];
|
|
254
|
+
}
|
|
255
|
+
/** Private values handed directly to the provider spawn, never to receipts. */
|
|
256
|
+
export interface WorkspacePlanSpawnInputs {
|
|
257
|
+
env: Record<string, string>;
|
|
258
|
+
flags: string[];
|
|
259
|
+
systemPrompt?: string;
|
|
260
|
+
appendSystemPrompt?: string;
|
|
261
|
+
/** Read it with {@link takeSystemPromptBinding}, which refuses a foreign control. */
|
|
262
|
+
systemPromptBinding?: WorkspacePlanSystemPromptBinding;
|
|
263
|
+
}
|
|
264
|
+
/** Result of applying a public plan at the private executor boundary. */
|
|
265
|
+
export interface WorkspacePlanApplication {
|
|
266
|
+
receipt: WorkspacePlanReceipt;
|
|
267
|
+
spawn: WorkspacePlanSpawnInputs;
|
|
268
|
+
/** Resolved generated files intentionally consumed in memory by the runtime. */
|
|
269
|
+
runtimeOnlyFiles: Record<string, string>;
|
|
270
|
+
}
|
|
271
|
+
/** The plan was applied, but one or more private staging files remain. */
|
|
272
|
+
export declare class WorkspacePlanCleanupError extends AggregateError {
|
|
273
|
+
readonly receipt: WorkspacePlanReceipt;
|
|
274
|
+
readonly temporaryPaths: readonly string[];
|
|
275
|
+
constructor(errors: readonly unknown[], receipt: WorkspacePlanReceipt, temporaryPaths: readonly string[]);
|
|
276
|
+
}
|
|
277
|
+
export interface ApplyWorkspacePlanOptions {
|
|
278
|
+
/** Plan files consumed in memory by the runtime and never persisted to cwd. */
|
|
279
|
+
runtimeOnlyFiles?: readonly string[];
|
|
280
|
+
/** Existing workspace paths are either exact idempotent inputs or forbidden. */
|
|
281
|
+
existingFiles?: "allow-identical" | "reject";
|
|
282
|
+
/**
|
|
283
|
+
* `workspacePlanDigest` of a plan a prior call already applied into this
|
|
284
|
+
* workspace. When it equals this plan's digest, application writes no files:
|
|
285
|
+
* the workspace holds the running agent's live state, and a file the agent
|
|
286
|
+
* edited or removed since the prior application stays exactly as the agent
|
|
287
|
+
* left it. Env, flags, prompt intents, and the receipt are still produced.
|
|
288
|
+
* A different digest applies normally, so a plan that changed mid-session
|
|
289
|
+
* still meets the existing-file refusal.
|
|
290
|
+
*/
|
|
291
|
+
appliedPlanDigest?: string;
|
|
292
|
+
}
|
|
293
|
+
import type { AgentProfileSecretRef } from "@tangle-network/agent-interface";
|
|
294
|
+
import type { HarnessId } from "./harness.js";
|