@tangle-network/agent-profile-materialize 0.1.0 → 0.3.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 +70 -10
- package/dist/index.js +799 -132
- package/package.json +7 -4
package/dist/index.d.ts
CHANGED
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* `node:fs` IO consumer. It was previously copied byte-for-byte into both
|
|
22
22
|
* cli-bridge and agent-dev-container — this is the single source those re-export.
|
|
23
23
|
*/
|
|
24
|
-
import type { AgentProfile } from
|
|
25
|
-
export type HarnessId =
|
|
24
|
+
import type { AgentCandidateConfigValue, AgentCandidateProfile, AgentCandidateProfileApplication, AgentCandidateProfilePlanEvidence, AgentCandidateProfilePlanMaterialV1, AgentProfile, Sha256Digest } from "@tangle-network/agent-interface";
|
|
25
|
+
export type HarnessId = "claude-code" | "claude" | "claudish" | "nanoclaw" | "codex" | "opencode" | "kimi-code" | "kimi" | "pi" | "gemini" | "hermes" | "openclaw";
|
|
26
26
|
export interface PlanFile {
|
|
27
27
|
relPath: string;
|
|
28
28
|
content: string;
|
|
@@ -30,7 +30,7 @@ export interface PlanFile {
|
|
|
30
30
|
mode?: number;
|
|
31
31
|
}
|
|
32
32
|
export interface Unsupported {
|
|
33
|
-
dimension:
|
|
33
|
+
dimension: "files" | "skills" | "mcp" | "hooks" | "subagents" | "commands" | "instructions" | "systemPrompt" | "permissions" | "tools";
|
|
34
34
|
reason: string;
|
|
35
35
|
}
|
|
36
36
|
/** The plan a caller writes into the run cwd, then applies to the spawn. */
|
|
@@ -45,6 +45,40 @@ export interface WorkspacePlan {
|
|
|
45
45
|
/** Dimensions requested but with NO cwd path on this harness — fail-closed record. */
|
|
46
46
|
unsupported: Unsupported[];
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Proof of the profile workspace inputs applied before a harness is spawned.
|
|
50
|
+
* `workspacePlanDigest` covers only the {@link WorkspacePlan}; it is not an
|
|
51
|
+
* execution-plan digest and does not attest unrelated files in `workspaceDir`.
|
|
52
|
+
*/
|
|
53
|
+
export interface WorkspacePlanReceipt {
|
|
54
|
+
workspacePlanDigest: string;
|
|
55
|
+
env: Record<string, string>;
|
|
56
|
+
flags: string[];
|
|
57
|
+
unsupported: Unsupported[];
|
|
58
|
+
written: string[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Candidate-safe workspace plan. Spawn values remain typed until the runtime
|
|
62
|
+
* resolves them; the materializer never joins argv into a shell command.
|
|
63
|
+
*/
|
|
64
|
+
export interface AgentCandidateWorkspacePlan {
|
|
65
|
+
harness: HarnessId;
|
|
66
|
+
files: PlanFile[];
|
|
67
|
+
env: Record<string, AgentCandidateConfigValue>;
|
|
68
|
+
flags: AgentCandidateConfigValue[];
|
|
69
|
+
unsupported: Unsupported[];
|
|
70
|
+
}
|
|
71
|
+
export interface AgentCandidateWorkspacePlanReceipt {
|
|
72
|
+
profilePlan: AgentCandidateProfilePlanEvidence;
|
|
73
|
+
application: AgentCandidateProfileApplication;
|
|
74
|
+
env: Record<string, AgentCandidateConfigValue>;
|
|
75
|
+
flags: AgentCandidateConfigValue[];
|
|
76
|
+
unsupported: Unsupported[];
|
|
77
|
+
}
|
|
78
|
+
export interface MaterializeCandidateProfileOptions {
|
|
79
|
+
/** Exact text fetched from pinned resource refs, keyed by declared SHA-256. */
|
|
80
|
+
resolvedResources?: ReadonlyMap<Sha256Digest, string>;
|
|
81
|
+
}
|
|
48
82
|
/** Normalize any skill markdown (VB frontmatter `skill:`/`description:`, or raw) into a
|
|
49
83
|
* harness SKILL.md with `name:`/`description:` frontmatter the harnesses require. */
|
|
50
84
|
export declare function normalizeSkillMd(name: string, raw: string): string;
|
|
@@ -53,17 +87,43 @@ export declare function normalizeSkillMd(name: string, raw: string): string;
|
|
|
53
87
|
* writes plan.files into the run cwd and applies plan.env/plan.flags to the spawn.
|
|
54
88
|
*/
|
|
55
89
|
export declare function materializeProfile(profile: AgentProfile, harness: HarnessId, opts?: {
|
|
56
|
-
skip?: Unsupported[
|
|
90
|
+
skip?: Unsupported["dimension"][];
|
|
57
91
|
}): WorkspacePlan;
|
|
92
|
+
/**
|
|
93
|
+
* Materialize a sealed candidate profile without weakening its process values.
|
|
94
|
+
* Candidate hooks deliberately fail closed because the currently supported
|
|
95
|
+
* native hook formats accept a shell command string rather than executable +
|
|
96
|
+
* argv + env as separate fields.
|
|
97
|
+
*/
|
|
98
|
+
export declare function materializeCandidateProfile(profile: AgentCandidateProfile, harness: HarnessId, opts?: MaterializeCandidateProfileOptions): AgentCandidateWorkspacePlan;
|
|
99
|
+
/**
|
|
100
|
+
* Hash the exact files, modes, flags, environment, and unsupported dimensions
|
|
101
|
+
* represented by a plan. Array order is preserved only where it changes spawn
|
|
102
|
+
* behavior; unordered maps and file records are canonicalized first.
|
|
103
|
+
*/
|
|
104
|
+
export declare function hashWorkspacePlan(plan: WorkspacePlan): string;
|
|
105
|
+
/** Build the exact digest-free identity document required by agent-interface. */
|
|
106
|
+
export declare function agentCandidateProfilePlanMaterial(plan: AgentCandidateWorkspacePlan): AgentCandidateProfilePlanMaterialV1;
|
|
107
|
+
/**
|
|
108
|
+
* Return the RFC 8785 UTF-8 bytes and SHA-256 identity of a candidate profile
|
|
109
|
+
* plan. The returned bytes are the exact bytes embedded in receipt evidence.
|
|
110
|
+
*/
|
|
111
|
+
export declare function canonicalizeAgentCandidateProfilePlan(plan: AgentCandidateWorkspacePlan): {
|
|
112
|
+
material: AgentCandidateProfilePlanMaterialV1;
|
|
113
|
+
bytes: Uint8Array;
|
|
114
|
+
digest: Sha256Digest;
|
|
115
|
+
};
|
|
116
|
+
/** Capture portable evidence of the exact canonical profile-plan bytes. */
|
|
117
|
+
export declare function captureAgentCandidateProfilePlan(plan: AgentCandidateWorkspacePlan): AgentCandidateProfilePlanEvidence;
|
|
118
|
+
/**
|
|
119
|
+
* Apply a candidate plan and return its spawn values together with captured
|
|
120
|
+
* canonical evidence. No caller needs to re-serialize or reinterpret the plan.
|
|
121
|
+
*/
|
|
122
|
+
export declare function applyAgentCandidateWorkspacePlan(plan: AgentCandidateWorkspacePlan, workspaceDir: string, targetWorkspace: AgentCandidateProfileApplication["targetWorkspace"]): AgentCandidateWorkspacePlanReceipt;
|
|
58
123
|
/**
|
|
59
124
|
* Apply a WorkspacePlan to a real workspace dir: write every file (creating parent
|
|
60
125
|
* dirs), and return the env + flags the caller must hand the spawn. This is the IO
|
|
61
126
|
* consumer of the pure `materializeProfile` — used by the cli-bridge host spawn, the
|
|
62
127
|
* agent-dev-container box, and VB. Returns `unsupported` so the caller can log/fail.
|
|
63
128
|
*/
|
|
64
|
-
export declare function applyWorkspacePlan(plan: WorkspacePlan, workspaceDir: string):
|
|
65
|
-
env: Record<string, string>;
|
|
66
|
-
flags: string[];
|
|
67
|
-
unsupported: Unsupported[];
|
|
68
|
-
written: string[];
|
|
69
|
-
};
|
|
129
|
+
export declare function applyWorkspacePlan(plan: WorkspacePlan, workspaceDir: string): WorkspacePlanReceipt;
|
package/dist/index.js
CHANGED
|
@@ -21,73 +21,134 @@
|
|
|
21
21
|
* `node:fs` IO consumer. It was previously copied byte-for-byte into both
|
|
22
22
|
* cli-bridge and agent-dev-container — this is the single source those re-export.
|
|
23
23
|
*/
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
25
|
+
import { closeSync, fchmodSync, constants as fsConstants, lstatSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
26
|
+
import { dirname, isAbsolute, join, relative, resolve, sep, win32, } from "node:path";
|
|
27
|
+
import { agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, } from "@tangle-network/agent-interface";
|
|
26
28
|
/** Resolve an inline resource ref to {name, content}. github refs need an async fetch
|
|
27
29
|
* (the providers' resolveProfileResource does this); the sync materializer can't, so a
|
|
28
30
|
* github ref is reported unsupported and the caller pre-resolves it. A ref with
|
|
29
31
|
* `content` (or kind 'inline') is inline. */
|
|
30
32
|
function resolveInlineRef(ref) {
|
|
31
33
|
const r = ref;
|
|
32
|
-
if (r.kind ===
|
|
33
|
-
return {
|
|
34
|
+
if (r.kind === "inline" || typeof r.content === "string") {
|
|
35
|
+
return {
|
|
36
|
+
name: String(r.name ?? r.path ?? "resource"),
|
|
37
|
+
content: String(r.content ?? ""),
|
|
38
|
+
};
|
|
34
39
|
}
|
|
35
40
|
return null; // github / unresolved
|
|
36
41
|
}
|
|
37
42
|
// ─── Verified per-harness conventions (capability matrix) ───────────────────
|
|
38
43
|
/** Project context file each harness actually reads from cwd. NEVER a constant. */
|
|
39
44
|
const CONTEXT_FILE = {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
"claude-code": "CLAUDE.md",
|
|
46
|
+
claude: "CLAUDE.md",
|
|
47
|
+
claudish: "CLAUDE.md",
|
|
48
|
+
nanoclaw: "CLAUDE.md",
|
|
49
|
+
codex: "AGENTS.md",
|
|
50
|
+
opencode: "AGENTS.md",
|
|
51
|
+
"kimi-code": "AGENTS.md",
|
|
52
|
+
kimi: "AGENTS.md",
|
|
53
|
+
pi: "AGENTS.md",
|
|
54
|
+
gemini: "GEMINI.md", // current default; AGENTS.md only via .gemini/settings.json context.fileName
|
|
55
|
+
hermes: "HERMES.md", // highest-priority native name; only ONE context loads (first match)
|
|
56
|
+
openclaw: "AGENTS.md",
|
|
45
57
|
};
|
|
46
58
|
/** cwd-relative native skill dir, or null if the harness has no cwd skill primitive. */
|
|
47
59
|
const SKILL_DIR = {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
"claude-code": ".claude/skills",
|
|
61
|
+
claude: ".claude/skills",
|
|
62
|
+
claudish: ".claude/skills",
|
|
63
|
+
nanoclaw: ".claude/skills",
|
|
64
|
+
codex: ".codex/skills",
|
|
65
|
+
opencode: ".opencode/skills",
|
|
66
|
+
"kimi-code": ".kimi/skills",
|
|
67
|
+
kimi: ".kimi/skills",
|
|
68
|
+
pi: ".pi/skills",
|
|
69
|
+
gemini: ".gemini/skills",
|
|
51
70
|
hermes: null, // skills are ~/.hermes/skills (user) only — no cwd discovery
|
|
52
|
-
openclaw:
|
|
71
|
+
openclaw: "skills", // <workspace>/skills/<name>/SKILL.md
|
|
53
72
|
};
|
|
54
73
|
function canonicalHarness(h) {
|
|
55
|
-
if (h ===
|
|
56
|
-
return
|
|
57
|
-
if (h ===
|
|
58
|
-
return
|
|
74
|
+
if (h === "claude" || h === "claudish")
|
|
75
|
+
return "claude-code";
|
|
76
|
+
if (h === "kimi")
|
|
77
|
+
return "kimi-code";
|
|
59
78
|
return h;
|
|
60
79
|
}
|
|
61
80
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
62
81
|
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
|
|
63
|
-
const collapse = (s) => s.replace(/\s+/g,
|
|
82
|
+
const collapse = (s) => s.replace(/\s+/g, " ").trim();
|
|
83
|
+
const compareStrings = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
84
|
+
const DEFAULT_FILE_MODE = 0o644;
|
|
85
|
+
const EXECUTABLE_FILE_MODE = 0o755;
|
|
86
|
+
const SAFE_WRITE_FLAGS = fsConstants.O_WRONLY |
|
|
87
|
+
fsConstants.O_CREAT |
|
|
88
|
+
fsConstants.O_EXCL |
|
|
89
|
+
(typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0);
|
|
90
|
+
function normalizeWorkspaceRelativePath(pathValue) {
|
|
91
|
+
if (typeof pathValue !== "string" ||
|
|
92
|
+
!pathValue ||
|
|
93
|
+
hasControlCharacter(pathValue)) {
|
|
94
|
+
throw new Error("Workspace file path must be non-empty and contain no control characters");
|
|
95
|
+
}
|
|
96
|
+
if (isAbsolute(pathValue) ||
|
|
97
|
+
win32.isAbsolute(pathValue) ||
|
|
98
|
+
/^[A-Za-z]:/.test(pathValue)) {
|
|
99
|
+
throw new Error(`Workspace file path must be relative: ${pathValue}`);
|
|
100
|
+
}
|
|
101
|
+
const segments = pathValue.replace(/\\/g, "/").split("/");
|
|
102
|
+
if (segments.some((segment) => !segment ||
|
|
103
|
+
segment === "." ||
|
|
104
|
+
segment === ".." ||
|
|
105
|
+
segment === ".sidecar")) {
|
|
106
|
+
throw new Error(`Workspace file path must be canonical: ${pathValue}`);
|
|
107
|
+
}
|
|
108
|
+
return segments.join("/");
|
|
109
|
+
}
|
|
110
|
+
function hasControlCharacter(value) {
|
|
111
|
+
for (let index = 0; index < value.length; index++) {
|
|
112
|
+
const code = value.charCodeAt(index);
|
|
113
|
+
if (code < 0x20 || code === 0x7f)
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
function validateFileMode(mode) {
|
|
119
|
+
const normalized = mode ?? DEFAULT_FILE_MODE;
|
|
120
|
+
if (!Number.isInteger(normalized) || normalized < 0 || normalized > 0o777) {
|
|
121
|
+
throw new Error(`Workspace file mode must be between 0o000 and 0o777: ${mode}`);
|
|
122
|
+
}
|
|
123
|
+
return normalized;
|
|
124
|
+
}
|
|
64
125
|
/** Normalize any skill markdown (VB frontmatter `skill:`/`description:`, or raw) into a
|
|
65
126
|
* harness SKILL.md with `name:`/`description:` frontmatter the harnesses require. */
|
|
66
127
|
export function normalizeSkillMd(name, raw) {
|
|
67
128
|
const m = raw.match(FRONTMATTER_RE);
|
|
68
|
-
const fm = m?.[1] ??
|
|
129
|
+
const fm = m?.[1] ?? "";
|
|
69
130
|
const body = (m?.[2] ?? raw).trim();
|
|
70
131
|
let desc = `Skill ${name}. Use when the task matches its domain.`;
|
|
71
132
|
if (fm) {
|
|
72
|
-
const lines = fm.split(
|
|
133
|
+
const lines = fm.split("\n");
|
|
73
134
|
const i = lines.findIndex((l) => /^description:/.test(l));
|
|
74
135
|
if (i >= 0) {
|
|
75
|
-
const inline = (lines[i] ??
|
|
76
|
-
if (inline && ![
|
|
136
|
+
const inline = (lines[i] ?? "").replace(/^description:\s*/, "").trim();
|
|
137
|
+
if (inline && ![">", "|", ">-", "|-"].includes(inline))
|
|
77
138
|
desc = collapse(inline);
|
|
78
139
|
else {
|
|
79
140
|
const buf = [];
|
|
80
141
|
for (let j = i + 1; j < lines.length; j++) {
|
|
81
|
-
const ln = lines[j] ??
|
|
142
|
+
const ln = lines[j] ?? "";
|
|
82
143
|
if (/^\s+\S/.test(ln))
|
|
83
144
|
buf.push(ln.trim());
|
|
84
|
-
else if (ln.trim() ===
|
|
145
|
+
else if (ln.trim() === "")
|
|
85
146
|
continue;
|
|
86
147
|
else
|
|
87
148
|
break;
|
|
88
149
|
}
|
|
89
150
|
if (buf.length)
|
|
90
|
-
desc = collapse(buf.join(
|
|
151
|
+
desc = collapse(buf.join(" "));
|
|
91
152
|
}
|
|
92
153
|
}
|
|
93
154
|
}
|
|
@@ -102,66 +163,396 @@ const tomlStr = (s) => JSON.stringify(s); // valid TOML basic string
|
|
|
102
163
|
export function materializeProfile(profile, harness, opts = {}) {
|
|
103
164
|
const h = canonicalHarness(harness);
|
|
104
165
|
const skip = new Set(opts.skip ?? []);
|
|
105
|
-
const plan = {
|
|
106
|
-
|
|
166
|
+
const plan = {
|
|
167
|
+
harness: h,
|
|
168
|
+
files: [],
|
|
169
|
+
env: {},
|
|
170
|
+
flags: [],
|
|
171
|
+
unsupported: [],
|
|
172
|
+
};
|
|
173
|
+
const add = (relPath, content, mode) => {
|
|
174
|
+
const normalizedPath = normalizeWorkspaceRelativePath(relPath);
|
|
175
|
+
plan.files.push({
|
|
176
|
+
relPath: normalizedPath,
|
|
177
|
+
content,
|
|
178
|
+
...(mode === undefined ? {} : { mode: validateFileMode(mode) }),
|
|
179
|
+
});
|
|
180
|
+
};
|
|
107
181
|
const unsupported = (dimension, reason) => plan.unsupported.push({ dimension, reason });
|
|
108
|
-
|
|
182
|
+
const explicitResourcePaths = new Map();
|
|
183
|
+
const addExplicitResource = (dimension, relPath, content, mode) => {
|
|
184
|
+
const normalizedPath = normalizeWorkspaceRelativePath(relPath);
|
|
185
|
+
const previousDimension = explicitResourcePaths.get(normalizedPath);
|
|
186
|
+
if (previousDimension) {
|
|
187
|
+
throw new Error(`Duplicate profile resource path: ${normalizedPath} (${previousDimension}, ${dimension})`);
|
|
188
|
+
}
|
|
189
|
+
explicitResourcePaths.set(normalizedPath, dimension);
|
|
190
|
+
add(normalizedPath, content, mode);
|
|
191
|
+
};
|
|
192
|
+
// 1) GENERIC FILES → exact workspace-relative paths. Inline refs are
|
|
193
|
+
// deterministic; remote refs must be resolved by an async caller first.
|
|
194
|
+
const fileMountPaths = new Set();
|
|
195
|
+
for (const mount of profile.resources?.files ?? []) {
|
|
196
|
+
const relPath = normalizeWorkspaceRelativePath(mount.path);
|
|
197
|
+
if (fileMountPaths.has(relPath)) {
|
|
198
|
+
throw new Error(`Duplicate workspace file mount: ${relPath}`);
|
|
199
|
+
}
|
|
200
|
+
fileMountPaths.add(relPath);
|
|
201
|
+
if (mount.executable !== undefined &&
|
|
202
|
+
typeof mount.executable !== "boolean") {
|
|
203
|
+
throw new Error(`Workspace file executable must be a boolean: ${relPath}`);
|
|
204
|
+
}
|
|
205
|
+
const resource = resolveInlineRef(mount.resource);
|
|
206
|
+
if (!resource) {
|
|
207
|
+
unsupported("files", `file mount "${relPath}" is not inline (github refs need async pre-resolution)`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
addExplicitResource("files", relPath, resource.content, mount.executable ? EXECUTABLE_FILE_MODE : DEFAULT_FILE_MODE);
|
|
211
|
+
}
|
|
212
|
+
// 2) PROVIDER-NATIVE TOOLS → only OpenCode exposes a verified cwd path.
|
|
213
|
+
for (const ref of profile.resources?.tools ?? []) {
|
|
214
|
+
const resource = resolveInlineRef(ref);
|
|
215
|
+
if (!resource) {
|
|
216
|
+
unsupported("tools", "tool ref is not inline (github refs need async pre-resolution)");
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (h !== "opencode") {
|
|
220
|
+
unsupported("tools", `${h}: no verified cwd-native file tool path; use MCP or a native extension`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
addExplicitResource("tools", `.opencode/tools/${resource.name}`, resource.content);
|
|
224
|
+
}
|
|
225
|
+
// 3) PROVIDER-NATIVE AGENTS → raw native definitions at verified cwd paths.
|
|
226
|
+
for (const ref of profile.resources?.agents ?? []) {
|
|
227
|
+
const resource = resolveInlineRef(ref);
|
|
228
|
+
if (!resource) {
|
|
229
|
+
unsupported("subagents", "agent ref is not inline (github refs need async pre-resolution)");
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const directory = agentResourceDirectory(h);
|
|
233
|
+
if (!directory) {
|
|
234
|
+
unsupported("subagents", `${h}: agent definitions have no verified cwd-native resource path`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
addExplicitResource("subagents", `${directory}/${resource.name}`, resource.content);
|
|
238
|
+
}
|
|
239
|
+
// 4) CONTEXT (instructions + systemPrompt → the harness's native context file).
|
|
109
240
|
const instr = [];
|
|
110
241
|
if (profile.prompt?.systemPrompt)
|
|
111
242
|
instr.push(profile.prompt.systemPrompt);
|
|
112
243
|
if (profile.prompt?.instructions?.length)
|
|
113
244
|
instr.push(...profile.prompt.instructions);
|
|
114
245
|
const resInstr = profile.resources?.instructions;
|
|
115
|
-
if (typeof resInstr ===
|
|
246
|
+
if (typeof resInstr === "string")
|
|
116
247
|
instr.push(resInstr);
|
|
117
|
-
else if (resInstr && resInstr.kind ===
|
|
248
|
+
else if (resInstr && resInstr.kind === "inline")
|
|
118
249
|
instr.push(resInstr.content);
|
|
119
250
|
if (instr.length) {
|
|
120
|
-
add(CONTEXT_FILE[h], `${instr.join(
|
|
251
|
+
add(CONTEXT_FILE[h], `${instr.join("\n\n")}\n`);
|
|
121
252
|
// gemini reads AGENTS.md only via settings; we write GEMINI.md so no extra step.
|
|
122
253
|
}
|
|
123
|
-
//
|
|
254
|
+
// 5) SKILLS → native skill dir, from resources.skills refs (the box's shape), or
|
|
124
255
|
// fail-closed (hermes has no cwd skill dir; github refs need async pre-resolution).
|
|
125
256
|
for (const ref of profile.resources?.skills ?? []) {
|
|
126
257
|
const r = resolveInlineRef(ref);
|
|
127
258
|
if (!r) {
|
|
128
|
-
unsupported(
|
|
259
|
+
unsupported("skills", "skill ref is not inline (github refs need async pre-resolution)");
|
|
129
260
|
continue;
|
|
130
261
|
}
|
|
131
262
|
const dir = SKILL_DIR[h];
|
|
132
263
|
if (!dir) {
|
|
133
|
-
unsupported(
|
|
264
|
+
unsupported("skills", `${h}: no cwd skill dir (skills live in a user/global dir); skill "${r.name}" not mounted`);
|
|
134
265
|
continue;
|
|
135
266
|
}
|
|
136
|
-
|
|
267
|
+
addExplicitResource("skills", `${dir}/${r.name}/SKILL.md`, normalizeSkillMd(r.name, r.content));
|
|
137
268
|
}
|
|
138
|
-
//
|
|
269
|
+
// 6) MCP → per-harness format (the divergence the matrix names). Skippable so the
|
|
139
270
|
// additive host-wiring can leave MCP on cli-bridge's existing per-harness path.
|
|
140
271
|
const mcp = profile.mcp ?? {};
|
|
141
|
-
if (!skip.has(
|
|
272
|
+
if (!skip.has("mcp") && Object.keys(mcp).length)
|
|
142
273
|
materializeMcp(h, mcp, plan, add, unsupported);
|
|
143
|
-
//
|
|
274
|
+
// 7) HOOKS → per-harness format; cwd-native only for claude/codex/gemini/opencode(plugin).
|
|
144
275
|
const hooks = profile.hooks ?? {};
|
|
145
276
|
if (Object.keys(hooks).length)
|
|
146
277
|
materializeHooks(h, hooks, add, unsupported, plan);
|
|
147
|
-
//
|
|
278
|
+
// 8) SUBAGENTS → cwd-native for claude/codex/opencode/gemini; flag/global elsewhere.
|
|
148
279
|
const subagents = profile.subagents ?? {};
|
|
149
280
|
for (const [name, sa] of Object.entries(subagents))
|
|
150
281
|
materializeSubagent(h, name, sa, add, unsupported);
|
|
151
|
-
//
|
|
282
|
+
// 9) COMMANDS → cwd-native (resources.commands refs); "commands are skills" on kimi.
|
|
152
283
|
for (const ref of profile.resources?.commands ?? []) {
|
|
153
284
|
const r = resolveInlineRef(ref);
|
|
154
285
|
if (!r) {
|
|
155
|
-
unsupported(
|
|
286
|
+
unsupported("commands", "command ref is not inline (github needs pre-resolution)");
|
|
156
287
|
continue;
|
|
157
288
|
}
|
|
158
|
-
materializeCommand(h, r.name, r.content,
|
|
289
|
+
materializeCommand(h, r.name, r.content, (path, content, mode) => addExplicitResource("commands", path, content, mode), unsupported);
|
|
290
|
+
}
|
|
291
|
+
for (const [relPath, dimension] of explicitResourcePaths) {
|
|
292
|
+
const writers = plan.files.filter((file) => file.relPath === relPath).length;
|
|
293
|
+
if (writers > 1) {
|
|
294
|
+
throw new Error(`Profile ${dimension} resource conflicts with generated harness file: ${relPath}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Merge native multi-writer JSON files (e.g. .claude/settings.json from both
|
|
298
|
+
// mcp and hooks) so the second writer cannot clobber the first.
|
|
299
|
+
plan.files = mergeJsonFiles(plan.files).sort((a, b) => compareStrings(a.relPath, b.relPath));
|
|
300
|
+
if (profile.resources?.failOnError && plan.unsupported.length) {
|
|
301
|
+
throw new Error(formatUnsupported(plan.unsupported));
|
|
159
302
|
}
|
|
160
|
-
// Merge multi-writer JSON files (e.g. .claude/settings.json from both mcp and hooks)
|
|
161
|
-
// so the second write can't clobber the first.
|
|
162
|
-
plan.files = mergeJsonFiles(plan.files);
|
|
163
303
|
return plan;
|
|
164
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Materialize a sealed candidate profile without weakening its process values.
|
|
307
|
+
* Candidate hooks deliberately fail closed because the currently supported
|
|
308
|
+
* native hook formats accept a shell command string rather than executable +
|
|
309
|
+
* argv + env as separate fields.
|
|
310
|
+
*/
|
|
311
|
+
export function materializeCandidateProfile(profile, harness, opts = {}) {
|
|
312
|
+
const candidate = agentCandidateProfileSchema.parse(profile);
|
|
313
|
+
verifyCandidateInlineResources(candidate);
|
|
314
|
+
const hookLowering = lowerCandidateHooks(candidate.hooks, harness);
|
|
315
|
+
const candidateResources = candidate.resources
|
|
316
|
+
? candidateResourcesForClassicMaterializer(candidate.resources, opts.resolvedResources)
|
|
317
|
+
: {};
|
|
318
|
+
candidateResources.files = [
|
|
319
|
+
...(candidateResources.files ?? []),
|
|
320
|
+
...hookLowering.wrapperFiles,
|
|
321
|
+
];
|
|
322
|
+
const classicProfile = {
|
|
323
|
+
...candidate,
|
|
324
|
+
resources: candidateResources,
|
|
325
|
+
mcp: candidateMcpRecordForClassicMaterializer(candidate.mcp),
|
|
326
|
+
hooks: hookLowering.hooks,
|
|
327
|
+
};
|
|
328
|
+
const classicPlan = materializeProfile(classicProfile, harness);
|
|
329
|
+
if (canonicalHarness(harness) === "opencode" &&
|
|
330
|
+
Object.values(candidate.mcp ?? {}).some((server) => server.enabled !== false && server.cwd !== undefined)) {
|
|
331
|
+
classicPlan.unsupported.push({
|
|
332
|
+
dimension: "mcp",
|
|
333
|
+
reason: "opencode local MCP configuration does not support a cwd field",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (hookLowering.unsupportedReason) {
|
|
337
|
+
classicPlan.unsupported.push({
|
|
338
|
+
dimension: "hooks",
|
|
339
|
+
reason: hookLowering.unsupportedReason,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
classicPlan.unsupported.sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`));
|
|
343
|
+
if (classicPlan.unsupported.length > 0) {
|
|
344
|
+
throw new Error(formatUnsupported(classicPlan.unsupported));
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
harness: classicPlan.harness,
|
|
348
|
+
files: classicPlan.files,
|
|
349
|
+
env: candidatePublicRecord(classicPlan.env),
|
|
350
|
+
flags: classicPlan.flags.map(candidatePublicValue),
|
|
351
|
+
unsupported: classicPlan.unsupported,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function lowerCandidateHooks(hooks, harness) {
|
|
355
|
+
if (!hooks || Object.keys(hooks).length === 0)
|
|
356
|
+
return { wrapperFiles: [] };
|
|
357
|
+
if (canonicalHarness(harness) !== "codex") {
|
|
358
|
+
return {
|
|
359
|
+
wrapperFiles: [],
|
|
360
|
+
unsupportedReason: `${canonicalHarness(harness)} hook configuration requires an unverified command-string mapping; ` +
|
|
361
|
+
"sealed candidate hooks preserve executable, argv, and env separately",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const wrapperFiles = [];
|
|
365
|
+
const lowered = {};
|
|
366
|
+
for (const [event, commands] of Object.entries(hooks)) {
|
|
367
|
+
lowered[event] = commands.map((command, index) => {
|
|
368
|
+
const wrapper = candidateHookWrapper(event, index, command);
|
|
369
|
+
wrapperFiles.push({
|
|
370
|
+
path: wrapper.path,
|
|
371
|
+
resource: {
|
|
372
|
+
kind: "inline",
|
|
373
|
+
name: wrapper.path,
|
|
374
|
+
content: wrapper.content,
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
return {
|
|
378
|
+
command: `node ${wrapper.path}`,
|
|
379
|
+
timeoutMs: command.timeoutMs,
|
|
380
|
+
blocking: command.blocking,
|
|
381
|
+
matcher: command.matcher,
|
|
382
|
+
};
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return { hooks: lowered, wrapperFiles };
|
|
386
|
+
}
|
|
387
|
+
function candidateHookWrapper(event, index, command) {
|
|
388
|
+
const args = (command.args ?? []).map(candidatePublicString);
|
|
389
|
+
const env = Object.fromEntries(Object.entries(command.env ?? {})
|
|
390
|
+
.sort(([left], [right]) => compareStrings(left, right))
|
|
391
|
+
.map(([name, value]) => [name, candidatePublicString(value)]));
|
|
392
|
+
const identity = rfc8785Serialize({
|
|
393
|
+
event,
|
|
394
|
+
index,
|
|
395
|
+
executable: command.executable,
|
|
396
|
+
args,
|
|
397
|
+
env,
|
|
398
|
+
timeoutMs: command.timeoutMs ?? null,
|
|
399
|
+
blocking: command.blocking ?? null,
|
|
400
|
+
matcher: command.matcher ?? null,
|
|
401
|
+
});
|
|
402
|
+
const wrapperId = sha256(identity);
|
|
403
|
+
const path = `.tangle/profile-hooks/${wrapperId}.mjs`;
|
|
404
|
+
const content = [
|
|
405
|
+
'import { spawnSync } from "node:child_process"',
|
|
406
|
+
`const executable = ${JSON.stringify(command.executable)}`,
|
|
407
|
+
`const args = ${JSON.stringify(args)}`,
|
|
408
|
+
`const extraEnv = ${JSON.stringify(env)}`,
|
|
409
|
+
"const result = spawnSync(executable, args, {",
|
|
410
|
+
' stdio: "inherit",',
|
|
411
|
+
" env: { ...process.env, ...extraEnv },",
|
|
412
|
+
" shell: false,",
|
|
413
|
+
"})",
|
|
414
|
+
"if (result.error) {",
|
|
415
|
+
" console.error(result.error.message)",
|
|
416
|
+
" process.exit(127)",
|
|
417
|
+
"}",
|
|
418
|
+
"if (result.signal) {",
|
|
419
|
+
' console.error("hook terminated by " + result.signal)',
|
|
420
|
+
" process.exit(1)",
|
|
421
|
+
"}",
|
|
422
|
+
"process.exit(result.status ?? 1)",
|
|
423
|
+
"",
|
|
424
|
+
].join("\n");
|
|
425
|
+
return { path, content };
|
|
426
|
+
}
|
|
427
|
+
function agentResourceDirectory(harness) {
|
|
428
|
+
switch (harness) {
|
|
429
|
+
case "claude-code":
|
|
430
|
+
case "nanoclaw":
|
|
431
|
+
return ".claude/agents";
|
|
432
|
+
case "codex":
|
|
433
|
+
return ".codex/agents";
|
|
434
|
+
case "opencode":
|
|
435
|
+
return ".opencode/agents";
|
|
436
|
+
case "gemini":
|
|
437
|
+
return ".gemini/agents";
|
|
438
|
+
default:
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function candidateMcpRecordForClassicMaterializer(servers) {
|
|
443
|
+
if (!servers)
|
|
444
|
+
return undefined;
|
|
445
|
+
return Object.fromEntries(Object.entries(servers)
|
|
446
|
+
.filter(([, server]) => server.enabled !== false)
|
|
447
|
+
.map(([name, server]) => [
|
|
448
|
+
name,
|
|
449
|
+
candidateMcpForClassicMaterializer(server),
|
|
450
|
+
]));
|
|
451
|
+
}
|
|
452
|
+
function candidateMcpForClassicMaterializer(server) {
|
|
453
|
+
return {
|
|
454
|
+
transport: server.transport,
|
|
455
|
+
command: server.command,
|
|
456
|
+
args: server.args?.map(candidatePublicString),
|
|
457
|
+
env: server.env
|
|
458
|
+
? Object.fromEntries(Object.entries(server.env).map(([name, value]) => [
|
|
459
|
+
name,
|
|
460
|
+
candidatePublicString(value),
|
|
461
|
+
]))
|
|
462
|
+
: undefined,
|
|
463
|
+
cwd: server.cwd,
|
|
464
|
+
enabled: server.enabled,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function candidateResourcesForClassicMaterializer(resources, resolvedResources) {
|
|
468
|
+
const convert = (ref) => {
|
|
469
|
+
if (ref.kind === "inline") {
|
|
470
|
+
return { kind: "inline", name: ref.name, content: ref.content };
|
|
471
|
+
}
|
|
472
|
+
const content = resolvedResources?.get(ref.sha256);
|
|
473
|
+
if (content !== undefined) {
|
|
474
|
+
verifyCandidateResourceBytes(ref, content, ref.name ?? ref.path);
|
|
475
|
+
return {
|
|
476
|
+
kind: "inline",
|
|
477
|
+
name: ref.name ?? ref.path.split("/").at(-1) ?? "resource",
|
|
478
|
+
content,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
kind: "github",
|
|
483
|
+
repository: `${ref.repository.owner}/${ref.repository.repo}`,
|
|
484
|
+
path: ref.path,
|
|
485
|
+
ref: ref.commit,
|
|
486
|
+
name: ref.name,
|
|
487
|
+
};
|
|
488
|
+
};
|
|
489
|
+
return {
|
|
490
|
+
files: resources.files?.map((mount) => ({
|
|
491
|
+
path: mount.path,
|
|
492
|
+
resource: convert(mount.resource),
|
|
493
|
+
executable: mount.executable,
|
|
494
|
+
})),
|
|
495
|
+
tools: resources.tools?.map(convert),
|
|
496
|
+
skills: resources.skills?.map(convert),
|
|
497
|
+
agents: resources.agents?.map(convert),
|
|
498
|
+
commands: resources.commands?.map(convert),
|
|
499
|
+
instructions: typeof resources.instructions === "string"
|
|
500
|
+
? resources.instructions
|
|
501
|
+
: resources.instructions
|
|
502
|
+
? convert(resources.instructions)
|
|
503
|
+
: undefined,
|
|
504
|
+
// The candidate wrapper owns the complete unsupported check after it adds
|
|
505
|
+
// dimensions that the classic materializer cannot represent.
|
|
506
|
+
failOnError: false,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function candidatePublicString(value) {
|
|
510
|
+
if (value.kind !== "public") {
|
|
511
|
+
throw new Error("Candidate materialization accepts public config values only");
|
|
512
|
+
}
|
|
513
|
+
return value.value;
|
|
514
|
+
}
|
|
515
|
+
function candidatePublicValue(value) {
|
|
516
|
+
return { kind: "public", value };
|
|
517
|
+
}
|
|
518
|
+
function candidatePublicRecord(values) {
|
|
519
|
+
return Object.fromEntries(Object.entries(values).map(([name, value]) => [
|
|
520
|
+
name,
|
|
521
|
+
candidatePublicValue(value),
|
|
522
|
+
]));
|
|
523
|
+
}
|
|
524
|
+
function verifyCandidateInlineResources(profile) {
|
|
525
|
+
const resources = profile.resources;
|
|
526
|
+
if (!resources)
|
|
527
|
+
return;
|
|
528
|
+
const refs = [
|
|
529
|
+
...(resources.files?.map((mount) => mount.resource) ?? []),
|
|
530
|
+
...(resources.tools ?? []),
|
|
531
|
+
...(resources.skills ?? []),
|
|
532
|
+
...(resources.agents ?? []),
|
|
533
|
+
...(resources.commands ?? []),
|
|
534
|
+
...(typeof resources.instructions === "object"
|
|
535
|
+
? [resources.instructions]
|
|
536
|
+
: []),
|
|
537
|
+
];
|
|
538
|
+
for (const ref of refs) {
|
|
539
|
+
if (ref.kind !== "inline")
|
|
540
|
+
continue;
|
|
541
|
+
verifyCandidateResourceBytes(ref, ref.content, ref.name);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
function verifyCandidateResourceBytes(ref, content, label) {
|
|
545
|
+
const bytes = Buffer.from(content, "utf8");
|
|
546
|
+
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
547
|
+
if (bytes.byteLength !== ref.byteLength || digest !== ref.sha256) {
|
|
548
|
+
throw new Error(`Candidate resource integrity mismatch: ${label}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function formatUnsupported(unsupported) {
|
|
552
|
+
return `Unable to materialize ${unsupported.length} profile resource(s):\n${unsupported
|
|
553
|
+
.map(({ dimension, reason }) => `- ${dimension}: ${reason}`)
|
|
554
|
+
.join("\n")}`;
|
|
555
|
+
}
|
|
165
556
|
/** Collapse multiple PlanFiles with the same relPath: top-level-merge JSON, last-wins
|
|
166
557
|
* for non-JSON. settings.json contributors use disjoint top-level keys, so this unions them. */
|
|
167
558
|
function mergeJsonFiles(files) {
|
|
@@ -172,18 +563,169 @@ function mergeJsonFiles(files) {
|
|
|
172
563
|
byPath.set(f.relPath, f);
|
|
173
564
|
continue;
|
|
174
565
|
}
|
|
175
|
-
if (f.relPath.endsWith(
|
|
566
|
+
if (f.relPath.endsWith(".json")) {
|
|
176
567
|
try {
|
|
177
|
-
const merged = {
|
|
178
|
-
|
|
568
|
+
const merged = {
|
|
569
|
+
...JSON.parse(prev.content),
|
|
570
|
+
...JSON.parse(f.content),
|
|
571
|
+
};
|
|
572
|
+
byPath.set(f.relPath, {
|
|
573
|
+
...prev,
|
|
574
|
+
content: JSON.stringify(merged, null, 2),
|
|
575
|
+
});
|
|
179
576
|
continue;
|
|
180
577
|
}
|
|
181
|
-
catch {
|
|
578
|
+
catch {
|
|
579
|
+
/* fall through to last-wins */
|
|
580
|
+
}
|
|
182
581
|
}
|
|
183
582
|
byPath.set(f.relPath, f); // non-JSON or parse-fail: last wins
|
|
184
583
|
}
|
|
185
584
|
return [...byPath.values()];
|
|
186
585
|
}
|
|
586
|
+
function normalizePlanFiles(files) {
|
|
587
|
+
const seen = new Set();
|
|
588
|
+
return files
|
|
589
|
+
.map((file) => {
|
|
590
|
+
const relPath = normalizeWorkspaceRelativePath(file.relPath);
|
|
591
|
+
if (seen.has(relPath)) {
|
|
592
|
+
throw new Error(`Duplicate workspace plan file: ${relPath}`);
|
|
593
|
+
}
|
|
594
|
+
seen.add(relPath);
|
|
595
|
+
if (typeof file.content !== "string") {
|
|
596
|
+
throw new Error(`Workspace file content must be a string: ${relPath}`);
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
relPath,
|
|
600
|
+
content: file.content,
|
|
601
|
+
mode: validateFileMode(file.mode),
|
|
602
|
+
};
|
|
603
|
+
})
|
|
604
|
+
.sort((a, b) => compareStrings(a.relPath, b.relPath));
|
|
605
|
+
}
|
|
606
|
+
const sha256 = (value) => createHash("sha256").update(value, "utf8").digest("hex");
|
|
607
|
+
/**
|
|
608
|
+
* Hash the exact files, modes, flags, environment, and unsupported dimensions
|
|
609
|
+
* represented by a plan. Array order is preserved only where it changes spawn
|
|
610
|
+
* behavior; unordered maps and file records are canonicalized first.
|
|
611
|
+
*/
|
|
612
|
+
export function hashWorkspacePlan(plan) {
|
|
613
|
+
const files = normalizePlanFiles(plan.files).map((file) => ({
|
|
614
|
+
relPath: file.relPath,
|
|
615
|
+
mode: file.mode,
|
|
616
|
+
contentSha256: sha256(file.content),
|
|
617
|
+
}));
|
|
618
|
+
const env = Object.fromEntries(Object.entries(plan.env).sort(([a], [b]) => compareStrings(a, b)));
|
|
619
|
+
const unsupported = [...plan.unsupported].sort((a, b) => compareStrings(`${a.dimension}\0${a.reason}`, `${b.dimension}\0${b.reason}`));
|
|
620
|
+
const canonical = JSON.stringify({
|
|
621
|
+
version: 1,
|
|
622
|
+
harness: canonicalHarness(plan.harness),
|
|
623
|
+
files,
|
|
624
|
+
env,
|
|
625
|
+
flags: [...plan.flags],
|
|
626
|
+
unsupported,
|
|
627
|
+
});
|
|
628
|
+
return `sha256:${sha256(canonical)}`;
|
|
629
|
+
}
|
|
630
|
+
/** Build the exact digest-free identity document required by agent-interface. */
|
|
631
|
+
export function agentCandidateProfilePlanMaterial(plan) {
|
|
632
|
+
const material = {
|
|
633
|
+
version: 1,
|
|
634
|
+
harness: canonicalHarness(plan.harness),
|
|
635
|
+
files: normalizePlanFiles(plan.files).map((file) => ({
|
|
636
|
+
relPath: file.relPath,
|
|
637
|
+
mode: file.mode,
|
|
638
|
+
contentSha256: `sha256:${sha256(file.content)}`,
|
|
639
|
+
})),
|
|
640
|
+
env: Object.fromEntries(Object.entries(plan.env).sort(([left], [right]) => compareStrings(left, right))),
|
|
641
|
+
flags: [...plan.flags],
|
|
642
|
+
unsupported: [...plan.unsupported].sort((left, right) => compareStrings(`${left.dimension}\0${left.reason}`, `${right.dimension}\0${right.reason}`)),
|
|
643
|
+
};
|
|
644
|
+
return agentCandidateProfilePlanMaterialSchema.parse(material);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Return the RFC 8785 UTF-8 bytes and SHA-256 identity of a candidate profile
|
|
648
|
+
* plan. The returned bytes are the exact bytes embedded in receipt evidence.
|
|
649
|
+
*/
|
|
650
|
+
export function canonicalizeAgentCandidateProfilePlan(plan) {
|
|
651
|
+
const material = agentCandidateProfilePlanMaterial(plan);
|
|
652
|
+
const bytes = Uint8Array.from(Buffer.from(rfc8785Serialize(material), "utf8"));
|
|
653
|
+
const digest = `sha256:${createHash("sha256")
|
|
654
|
+
.update(bytes)
|
|
655
|
+
.digest("hex")}`;
|
|
656
|
+
return { material, bytes, digest };
|
|
657
|
+
}
|
|
658
|
+
/** Capture portable evidence of the exact canonical profile-plan bytes. */
|
|
659
|
+
export function captureAgentCandidateProfilePlan(plan) {
|
|
660
|
+
const canonical = canonicalizeAgentCandidateProfilePlan(plan);
|
|
661
|
+
const artifact = {
|
|
662
|
+
encoding: "base64",
|
|
663
|
+
content: Buffer.from(canonical.bytes).toString("base64"),
|
|
664
|
+
sha256: canonical.digest,
|
|
665
|
+
byteLength: canonical.bytes.byteLength,
|
|
666
|
+
};
|
|
667
|
+
return agentCandidateProfilePlanEvidenceSchema.parse({
|
|
668
|
+
schemaVersion: 1,
|
|
669
|
+
kind: "agent-profile-workspace-plan",
|
|
670
|
+
digest: canonical.digest,
|
|
671
|
+
material: canonical.material,
|
|
672
|
+
artifact,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Apply a candidate plan and return its spawn values together with captured
|
|
677
|
+
* canonical evidence. No caller needs to re-serialize or reinterpret the plan.
|
|
678
|
+
*/
|
|
679
|
+
export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorkspace) {
|
|
680
|
+
if (plan.unsupported.length > 0) {
|
|
681
|
+
throw new Error(formatUnsupported(plan.unsupported));
|
|
682
|
+
}
|
|
683
|
+
const profilePlan = captureAgentCandidateProfilePlan(plan);
|
|
684
|
+
const applied = applyWorkspacePlan({
|
|
685
|
+
harness: plan.harness,
|
|
686
|
+
files: plan.files,
|
|
687
|
+
env: Object.fromEntries(Object.entries(plan.env).map(([name, value]) => [
|
|
688
|
+
name,
|
|
689
|
+
candidatePublicString(value),
|
|
690
|
+
])),
|
|
691
|
+
flags: plan.flags.map(candidatePublicString),
|
|
692
|
+
unsupported: plan.unsupported,
|
|
693
|
+
}, workspaceDir);
|
|
694
|
+
return {
|
|
695
|
+
profilePlan,
|
|
696
|
+
application: {
|
|
697
|
+
planDigest: profilePlan.digest,
|
|
698
|
+
targetWorkspace,
|
|
699
|
+
mountPaths: applied.written,
|
|
700
|
+
},
|
|
701
|
+
env: { ...plan.env },
|
|
702
|
+
flags: [...plan.flags],
|
|
703
|
+
unsupported: [...plan.unsupported],
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function rfc8785Serialize(value) {
|
|
707
|
+
if (value === null || typeof value === "boolean")
|
|
708
|
+
return JSON.stringify(value);
|
|
709
|
+
if (typeof value === "string")
|
|
710
|
+
return JSON.stringify(value);
|
|
711
|
+
if (typeof value === "number") {
|
|
712
|
+
if (!Number.isFinite(value)) {
|
|
713
|
+
throw new Error("RFC 8785 cannot serialize a non-finite number");
|
|
714
|
+
}
|
|
715
|
+
return JSON.stringify(value);
|
|
716
|
+
}
|
|
717
|
+
if (Array.isArray(value)) {
|
|
718
|
+
return `[${value.map(rfc8785Serialize).join(",")}]`;
|
|
719
|
+
}
|
|
720
|
+
if (typeof value === "object") {
|
|
721
|
+
const record = value;
|
|
722
|
+
return `{${Object.keys(record)
|
|
723
|
+
.sort(compareStrings)
|
|
724
|
+
.map((key) => `${JSON.stringify(key)}:${rfc8785Serialize(record[key])}`)
|
|
725
|
+
.join(",")}}`;
|
|
726
|
+
}
|
|
727
|
+
throw new Error(`RFC 8785 cannot serialize ${typeof value}`);
|
|
728
|
+
}
|
|
187
729
|
/**
|
|
188
730
|
* Apply a WorkspacePlan to a real workspace dir: write every file (creating parent
|
|
189
731
|
* dirs), and return the env + flags the caller must hand the spawn. This is the IO
|
|
@@ -191,67 +733,162 @@ function mergeJsonFiles(files) {
|
|
|
191
733
|
* agent-dev-container box, and VB. Returns `unsupported` so the caller can log/fail.
|
|
192
734
|
*/
|
|
193
735
|
export function applyWorkspacePlan(plan, workspaceDir) {
|
|
736
|
+
const workspaceRoot = resolve(workspaceDir);
|
|
737
|
+
const files = normalizePlanFiles(plan.files).map((file) => ({
|
|
738
|
+
...file,
|
|
739
|
+
absPath: resolveWorkspaceFilePath(workspaceRoot, file.relPath),
|
|
740
|
+
}));
|
|
741
|
+
// Validate the complete plan before the first write so a late traversal or
|
|
742
|
+
// existing symlink cannot leave a partially materialized workspace.
|
|
743
|
+
for (const file of files) {
|
|
744
|
+
assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
|
|
745
|
+
}
|
|
746
|
+
for (const file of files) {
|
|
747
|
+
mkdirSync(dirname(file.absPath), { recursive: true });
|
|
748
|
+
}
|
|
749
|
+
for (const file of files) {
|
|
750
|
+
assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
|
|
751
|
+
}
|
|
194
752
|
const written = [];
|
|
195
|
-
for (const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
753
|
+
for (const file of files) {
|
|
754
|
+
writeWorkspaceFile(workspaceRoot, file);
|
|
755
|
+
written.push(file.relPath);
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
workspacePlanDigest: hashWorkspacePlan(plan),
|
|
759
|
+
env: plan.env,
|
|
760
|
+
flags: plan.flags,
|
|
761
|
+
unsupported: plan.unsupported,
|
|
762
|
+
written,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
function resolveWorkspaceFilePath(workspaceRoot, relativePath) {
|
|
766
|
+
const targetPath = resolve(workspaceRoot, ...relativePath.split("/"));
|
|
767
|
+
const targetRelativePath = relative(workspaceRoot, targetPath);
|
|
768
|
+
if (!targetRelativePath ||
|
|
769
|
+
targetRelativePath === ".." ||
|
|
770
|
+
targetRelativePath.startsWith(`..${sep}`) ||
|
|
771
|
+
isAbsolute(targetRelativePath)) {
|
|
772
|
+
throw new Error(`Refusing to materialize profile file outside workspace: ${relativePath}`);
|
|
773
|
+
}
|
|
774
|
+
return targetPath;
|
|
775
|
+
}
|
|
776
|
+
function assertNoSymlinkPathComponents(workspaceRoot, targetPath) {
|
|
777
|
+
const targetRelativePath = relative(workspaceRoot, targetPath);
|
|
778
|
+
let currentPath = workspaceRoot;
|
|
779
|
+
const components = ["", ...targetRelativePath.split(sep)];
|
|
780
|
+
for (const [index, component] of components.entries()) {
|
|
781
|
+
if (component)
|
|
782
|
+
currentPath = join(currentPath, component);
|
|
783
|
+
let stats;
|
|
784
|
+
try {
|
|
785
|
+
stats = lstatSync(currentPath);
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
if (isFsError(error, "ENOENT"))
|
|
789
|
+
return;
|
|
790
|
+
if (isFsError(error, "ELOOP")) {
|
|
791
|
+
throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
|
|
792
|
+
}
|
|
793
|
+
throw error;
|
|
794
|
+
}
|
|
795
|
+
if (stats.isSymbolicLink()) {
|
|
796
|
+
throw new Error(`Refusing to materialize profile file through symlink path: ${targetRelativePath}`);
|
|
797
|
+
}
|
|
798
|
+
const isTarget = index === components.length - 1;
|
|
799
|
+
if ((!isTarget && !stats.isDirectory()) || (isTarget && !stats.isFile())) {
|
|
800
|
+
throw new Error(`Refusing to materialize profile file through non-file path: ${targetRelativePath}`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
function writeWorkspaceFile(workspaceRoot, file) {
|
|
805
|
+
assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
|
|
806
|
+
const temporaryPath = join(dirname(file.absPath), `.agent-profile-${randomUUID()}.tmp`);
|
|
807
|
+
let descriptor;
|
|
808
|
+
try {
|
|
809
|
+
descriptor = openSync(temporaryPath, SAFE_WRITE_FLAGS, 0o600);
|
|
810
|
+
writeFileSync(descriptor, file.content, "utf8");
|
|
811
|
+
fchmodSync(descriptor, file.mode);
|
|
812
|
+
closeSync(descriptor);
|
|
813
|
+
descriptor = undefined;
|
|
814
|
+
assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
|
|
815
|
+
renameSync(temporaryPath, file.absPath);
|
|
816
|
+
}
|
|
817
|
+
catch (error) {
|
|
818
|
+
if (descriptor !== undefined)
|
|
819
|
+
closeSync(descriptor);
|
|
820
|
+
try {
|
|
821
|
+
unlinkSync(temporaryPath);
|
|
822
|
+
}
|
|
823
|
+
catch (cleanupError) {
|
|
824
|
+
if (!isFsError(cleanupError, "ENOENT"))
|
|
825
|
+
throw cleanupError;
|
|
826
|
+
}
|
|
827
|
+
throw error;
|
|
200
828
|
}
|
|
201
|
-
|
|
829
|
+
}
|
|
830
|
+
function isFsError(error, code) {
|
|
831
|
+
return (typeof error === "object" &&
|
|
832
|
+
error !== null &&
|
|
833
|
+
"code" in error &&
|
|
834
|
+
error.code === code);
|
|
202
835
|
}
|
|
203
836
|
// ─── Per-dimension routing (verified matrix) ────────────────────────────────
|
|
204
837
|
function materializeMcp(h, mcp, plan, add, unsupported) {
|
|
205
838
|
switch (h) {
|
|
206
|
-
case
|
|
207
|
-
case
|
|
839
|
+
case "claude-code":
|
|
840
|
+
case "nanoclaw":
|
|
208
841
|
// claude reads ./.mcp.json but each server needs per-project APPROVAL; we also
|
|
209
842
|
// enable them via .claude/settings.json enabledMcpjsonServers to skip the prompt.
|
|
210
|
-
add(
|
|
211
|
-
add(
|
|
212
|
-
if (h ===
|
|
213
|
-
unsupported(
|
|
843
|
+
add(".mcp.json", JSON.stringify({ mcpServers: mcp }, null, 2));
|
|
844
|
+
add(".claude/settings.json", JSON.stringify({ enabledMcpjsonServers: Object.keys(mcp) }, null, 2));
|
|
845
|
+
if (h === "nanoclaw")
|
|
846
|
+
unsupported("mcp", "nanoclaw MCP is SDK-injected (options.mcpServers); .mcp.json may be ignored — inject via the SDK adapter");
|
|
214
847
|
break;
|
|
215
|
-
case
|
|
848
|
+
case "codex": {
|
|
216
849
|
// codex reads [mcp_servers.<id>] in .codex/config.toml; trusted projects only.
|
|
217
|
-
const lines = ['[projects."."]', 'trust_level = "trusted"',
|
|
850
|
+
const lines = ['[projects."."]', 'trust_level = "trusted"', ""];
|
|
218
851
|
for (const [name, s] of Object.entries(mcp)) {
|
|
219
852
|
lines.push(`[mcp_servers.${name}]`);
|
|
220
853
|
if (s.command)
|
|
221
854
|
lines.push(`command = ${tomlStr(s.command)}`);
|
|
222
855
|
if (s.args?.length)
|
|
223
|
-
lines.push(`args = [${s.args.map(tomlStr).join(
|
|
856
|
+
lines.push(`args = [${s.args.map(tomlStr).join(", ")}]`);
|
|
857
|
+
if (s.cwd)
|
|
858
|
+
lines.push(`cwd = ${tomlStr(s.cwd)}`);
|
|
224
859
|
if (s.url)
|
|
225
860
|
lines.push(`url = ${tomlStr(s.url)}`);
|
|
226
861
|
if (s.env)
|
|
227
|
-
lines.push(`env = { ${Object.entries(s.env)
|
|
228
|
-
|
|
862
|
+
lines.push(`env = { ${Object.entries(s.env)
|
|
863
|
+
.map(([k, v]) => `${k} = ${tomlStr(v)}`)
|
|
864
|
+
.join(", ")} }`);
|
|
865
|
+
lines.push("");
|
|
229
866
|
}
|
|
230
|
-
add(
|
|
867
|
+
add(".codex/config.toml", lines.join("\n"));
|
|
231
868
|
break;
|
|
232
869
|
}
|
|
233
|
-
case
|
|
870
|
+
case "opencode":
|
|
234
871
|
// opencode.json "mcp" block (local|remote).
|
|
235
|
-
add(
|
|
872
|
+
add("opencode.json", JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: opencodeMcp(mcp) }, null, 2));
|
|
236
873
|
break;
|
|
237
|
-
case
|
|
238
|
-
add(
|
|
874
|
+
case "gemini":
|
|
875
|
+
add(".gemini/settings.json", JSON.stringify({ mcpServers: mcp }, null, 2));
|
|
239
876
|
break;
|
|
240
|
-
case
|
|
877
|
+
case "kimi-code": {
|
|
241
878
|
// NO cwd auto-discovery — kimi needs an explicit --mcp-config-file FLAG.
|
|
242
|
-
const rel =
|
|
879
|
+
const rel = ".kimi/mcp.json";
|
|
243
880
|
add(rel, JSON.stringify({ mcpServers: mcp }, null, 2));
|
|
244
|
-
plan.flags.push(
|
|
881
|
+
plan.flags.push("--mcp-config-file", rel);
|
|
245
882
|
break;
|
|
246
883
|
}
|
|
247
|
-
case
|
|
248
|
-
unsupported(
|
|
884
|
+
case "pi":
|
|
885
|
+
unsupported("mcp", "pi MCP is wired via a TS extension (.pi/extensions/*.ts), not a declarative cwd file — build an extension");
|
|
249
886
|
break;
|
|
250
|
-
case
|
|
251
|
-
unsupported(
|
|
887
|
+
case "hermes":
|
|
888
|
+
unsupported("mcp", "hermes MCP is global config.yaml (mcp_servers), no cwd discovery — write to the hermes global config or pass a config flag");
|
|
252
889
|
break;
|
|
253
|
-
case
|
|
254
|
-
unsupported(
|
|
890
|
+
case "openclaw":
|
|
891
|
+
unsupported("mcp", "openclaw MCP is central ~/.openclaw/openclaw.json (mcp.servers), no cwd discovery");
|
|
255
892
|
break;
|
|
256
893
|
}
|
|
257
894
|
}
|
|
@@ -259,88 +896,117 @@ function opencodeMcp(mcp) {
|
|
|
259
896
|
const out = {};
|
|
260
897
|
for (const [name, s] of Object.entries(mcp)) {
|
|
261
898
|
out[name] = s.url
|
|
262
|
-
? {
|
|
263
|
-
|
|
899
|
+
? {
|
|
900
|
+
type: "remote",
|
|
901
|
+
url: s.url,
|
|
902
|
+
enabled: s.enabled !== false,
|
|
903
|
+
...(s.headers ? { headers: s.headers } : {}),
|
|
904
|
+
}
|
|
905
|
+
: {
|
|
906
|
+
type: "local",
|
|
907
|
+
command: [s.command, ...(s.args ?? [])].filter(Boolean),
|
|
908
|
+
enabled: s.enabled !== false,
|
|
909
|
+
...(s.env ? { environment: s.env } : {}),
|
|
910
|
+
};
|
|
264
911
|
}
|
|
265
912
|
return out;
|
|
266
913
|
}
|
|
267
914
|
function materializeHooks(h, hooks, add, unsupported, plan) {
|
|
268
915
|
switch (h) {
|
|
269
|
-
case
|
|
270
|
-
case
|
|
916
|
+
case "claude-code":
|
|
917
|
+
case "nanoclaw": {
|
|
271
918
|
// .claude/settings.json hooks{} — each event → [{matcher, hooks:[{type:command,command}]}].
|
|
272
919
|
const settingsHooks = {};
|
|
273
920
|
for (const [event, cmds] of Object.entries(hooks)) {
|
|
274
|
-
settingsHooks[event] = cmds.map((c) => ({
|
|
921
|
+
settingsHooks[event] = cmds.map((c) => ({
|
|
922
|
+
matcher: c.matcher ?? "*",
|
|
923
|
+
hooks: [
|
|
924
|
+
{
|
|
925
|
+
type: "command",
|
|
926
|
+
command: c.command,
|
|
927
|
+
...(c.timeoutMs
|
|
928
|
+
? { timeout: Math.round(c.timeoutMs / 1000) }
|
|
929
|
+
: {}),
|
|
930
|
+
},
|
|
931
|
+
],
|
|
932
|
+
}));
|
|
275
933
|
}
|
|
276
|
-
add(
|
|
934
|
+
add(".claude/settings.json", JSON.stringify({ hooks: settingsHooks }, null, 2));
|
|
277
935
|
break;
|
|
278
936
|
}
|
|
279
|
-
case
|
|
937
|
+
case "gemini": {
|
|
280
938
|
const settingsHooks = {};
|
|
281
939
|
for (const [event, cmds] of Object.entries(hooks))
|
|
282
|
-
settingsHooks[event] = cmds.map((c) => ({
|
|
283
|
-
|
|
940
|
+
settingsHooks[event] = cmds.map((c) => ({
|
|
941
|
+
matcher: c.matcher ?? "*",
|
|
942
|
+
hooks: [{ type: "command", command: c.command }],
|
|
943
|
+
}));
|
|
944
|
+
add(".gemini/settings.json", JSON.stringify({ hooks: settingsHooks }, null, 2));
|
|
284
945
|
break;
|
|
285
946
|
}
|
|
286
|
-
case
|
|
947
|
+
case "codex": {
|
|
287
948
|
// codex hooks.json (event → commands).
|
|
288
|
-
add(
|
|
949
|
+
add(".codex/hooks.json", JSON.stringify({ hooks }, null, 2));
|
|
289
950
|
break;
|
|
290
951
|
}
|
|
291
|
-
case
|
|
952
|
+
case "opencode":
|
|
292
953
|
// opencode hooks are PLUGINS (JS/TS), not a settings block — emit a plugin stub.
|
|
293
954
|
for (const [event, cmds] of Object.entries(hooks)) {
|
|
294
|
-
const body = cmds.map((c) => ` // ${event}: ${c.command}`).join(
|
|
295
|
-
add(`.opencode/
|
|
955
|
+
const body = cmds.map((c) => ` // ${event}: ${c.command}`).join("\n");
|
|
956
|
+
add(`.opencode/plugins/vb-hooks-${event}.js`, `// auto-generated hook plugin for ${event}\nexport default async function () {\n${body}\n}\n`);
|
|
296
957
|
}
|
|
297
|
-
unsupported(
|
|
958
|
+
unsupported("hooks", "opencode hooks are JS/TS plugins — generated a stub; a real handler must run the command (format differs from settings.json)");
|
|
298
959
|
break;
|
|
299
|
-
case
|
|
300
|
-
unsupported(
|
|
960
|
+
case "kimi-code":
|
|
961
|
+
unsupported("hooks", "kimi hooks are [[hooks]] in the config TOML loaded via --config-file, not cwd — pass --config-file");
|
|
301
962
|
break;
|
|
302
|
-
case
|
|
303
|
-
unsupported(
|
|
963
|
+
case "pi":
|
|
964
|
+
unsupported("hooks", "pi hooks are TS extensions (.pi/extensions/*.ts) — build an extension");
|
|
304
965
|
break;
|
|
305
|
-
case
|
|
966
|
+
case "hermes": {
|
|
306
967
|
// opt-in project plugins; requires HERMES_ENABLE_PROJECT_PLUGINS=1.
|
|
307
968
|
for (const [event, cmds] of Object.entries(hooks))
|
|
308
|
-
add(`.hermes/plugins/vb-${event}/plugin.md`, cmds.map((c) => c.command).join(
|
|
309
|
-
plan.env.HERMES_ENABLE_PROJECT_PLUGINS =
|
|
969
|
+
add(`.hermes/plugins/vb-${event}/plugin.md`, cmds.map((c) => c.command).join("\n"));
|
|
970
|
+
plan.env.HERMES_ENABLE_PROJECT_PLUGINS = "1";
|
|
310
971
|
break;
|
|
311
972
|
}
|
|
312
|
-
case
|
|
313
|
-
unsupported(
|
|
973
|
+
case "openclaw":
|
|
974
|
+
unsupported("hooks", "openclaw hooks are DISABLED by default — enable in ~/.openclaw/openclaw.json; cwd hooks/ alone is a no-op");
|
|
314
975
|
break;
|
|
315
976
|
}
|
|
316
977
|
}
|
|
317
978
|
function materializeSubagent(h, name, sa, add, unsupported) {
|
|
318
979
|
switch (h) {
|
|
319
|
-
case
|
|
320
|
-
case
|
|
321
|
-
const fm = [
|
|
980
|
+
case "claude-code":
|
|
981
|
+
case "nanoclaw": {
|
|
982
|
+
const fm = ["---", `name: ${name}`];
|
|
322
983
|
if (sa.description)
|
|
323
984
|
fm.push(`description: ${JSON.stringify(sa.description)}`);
|
|
324
985
|
if (sa.model)
|
|
325
986
|
fm.push(`model: ${sa.model}`);
|
|
326
987
|
if (sa.tools)
|
|
327
|
-
fm.push(`tools: ${Object.keys(sa.tools)
|
|
328
|
-
|
|
329
|
-
|
|
988
|
+
fm.push(`tools: ${Object.keys(sa.tools)
|
|
989
|
+
.filter((t) => sa.tools?.[t])
|
|
990
|
+
.join(", ")}`);
|
|
991
|
+
fm.push("---", "", sa.prompt ?? sa.description ?? name);
|
|
992
|
+
add(`.claude/agents/${name}.md`, `${fm.join("\n")}\n`);
|
|
330
993
|
break;
|
|
331
994
|
}
|
|
332
|
-
case
|
|
333
|
-
const fm = [
|
|
995
|
+
case "opencode": {
|
|
996
|
+
const fm = [
|
|
997
|
+
"---",
|
|
998
|
+
`description: ${JSON.stringify(sa.description ?? name)}`,
|
|
999
|
+
];
|
|
334
1000
|
if (sa.model)
|
|
335
1001
|
fm.push(`model: ${sa.model}`);
|
|
336
|
-
fm.push(
|
|
337
|
-
add(`.opencode/
|
|
1002
|
+
fm.push("---", "", sa.prompt ?? "");
|
|
1003
|
+
add(`.opencode/agents/${name}.md`, `${fm.join("\n")}\n`);
|
|
338
1004
|
break;
|
|
339
1005
|
}
|
|
340
|
-
case
|
|
341
|
-
add(`.gemini/agents/${name}.md`, `---\nname: ${name}\ndescription: ${JSON.stringify(sa.description ?? name)}\n---\n\n${sa.prompt ??
|
|
1006
|
+
case "gemini":
|
|
1007
|
+
add(`.gemini/agents/${name}.md`, `---\nname: ${name}\ndescription: ${JSON.stringify(sa.description ?? name)}\n---\n\n${sa.prompt ?? ""}\n`);
|
|
342
1008
|
break;
|
|
343
|
-
case
|
|
1009
|
+
case "codex": {
|
|
344
1010
|
const lines = [`name = ${tomlStr(name)}`];
|
|
345
1011
|
if (sa.description)
|
|
346
1012
|
lines.push(`description = ${tomlStr(sa.description)}`);
|
|
@@ -348,34 +1014,35 @@ function materializeSubagent(h, name, sa, add, unsupported) {
|
|
|
348
1014
|
lines.push(`model = ${tomlStr(sa.model)}`);
|
|
349
1015
|
if (sa.prompt)
|
|
350
1016
|
lines.push(`prompt = ${tomlStr(sa.prompt)}`);
|
|
351
|
-
add(`.codex/agents/${name}.toml`, lines.join(
|
|
1017
|
+
add(`.codex/agents/${name}.toml`, `${lines.join("\n")}\n`);
|
|
352
1018
|
break;
|
|
353
1019
|
}
|
|
354
1020
|
default:
|
|
355
|
-
unsupported(
|
|
1021
|
+
unsupported("subagents", `${h}: subagents have no cwd file (config/flag/global only); "${name}" not mounted`);
|
|
356
1022
|
}
|
|
357
1023
|
}
|
|
358
1024
|
function materializeCommand(h, name, body, add, unsupported) {
|
|
359
1025
|
switch (h) {
|
|
360
|
-
case
|
|
361
|
-
case
|
|
362
|
-
add(`.claude/commands/${name}.md`, body.endsWith(
|
|
1026
|
+
case "claude-code":
|
|
1027
|
+
case "nanoclaw":
|
|
1028
|
+
add(`.claude/commands/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
|
|
363
1029
|
break;
|
|
364
|
-
case
|
|
365
|
-
add(`.opencode/
|
|
1030
|
+
case "opencode":
|
|
1031
|
+
add(`.opencode/commands/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
|
|
366
1032
|
break;
|
|
367
|
-
case
|
|
1033
|
+
case "gemini":
|
|
368
1034
|
add(`.gemini/commands/${name}.toml`, `prompt = ${tomlStr(body)}\n`);
|
|
369
1035
|
break;
|
|
370
|
-
case
|
|
371
|
-
add(`.pi/prompts/${name}.md`, body.endsWith(
|
|
1036
|
+
case "pi":
|
|
1037
|
+
add(`.pi/prompts/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
|
|
372
1038
|
break;
|
|
373
|
-
case
|
|
374
|
-
unsupported(
|
|
1039
|
+
case "kimi-code":
|
|
1040
|
+
unsupported("commands", "kimi commands ARE skills (/skill:<name>) — provide as a skill instead");
|
|
375
1041
|
break;
|
|
376
|
-
case
|
|
377
|
-
unsupported(
|
|
1042
|
+
case "codex":
|
|
1043
|
+
unsupported("commands", "codex project commands unsupported (~/.codex/prompts is user-global only)");
|
|
378
1044
|
break;
|
|
379
|
-
default:
|
|
1045
|
+
default:
|
|
1046
|
+
unsupported("commands", `${h}: no cwd command path`);
|
|
380
1047
|
}
|
|
381
1048
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/agent-profile-materialize",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Materialize an AgentProfile into the native per-harness workspace files (the one shared materializer for cli-bridge, agent-dev-container, and VB).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,14 +16,17 @@
|
|
|
16
16
|
"dist"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@tangle-network/agent-interface": "0.
|
|
19
|
+
"@tangle-network/agent-interface": "^0.21.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "25.6.0",
|
|
23
|
-
"typescript": "^6.0.3"
|
|
23
|
+
"typescript": "^6.0.3",
|
|
24
|
+
"vitest": "^4.1.5"
|
|
24
25
|
},
|
|
25
26
|
"scripts": {
|
|
26
27
|
"build": "tsc -p tsconfig.json",
|
|
27
|
-
"clean": "rm -rf dist"
|
|
28
|
+
"clean": "rm -rf dist",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest"
|
|
28
31
|
}
|
|
29
32
|
}
|