@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.
Files changed (3) hide show
  1. package/dist/index.d.ts +70 -10
  2. package/dist/index.js +799 -132
  3. 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 '@tangle-network/agent-interface';
25
- export type HarnessId = 'claude-code' | 'claude' | 'claudish' | 'nanoclaw' | 'codex' | 'opencode' | 'kimi-code' | 'kimi' | 'pi' | 'gemini' | 'hermes' | 'openclaw';
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: 'skills' | 'mcp' | 'hooks' | 'subagents' | 'commands' | 'instructions' | 'systemPrompt' | 'permissions' | 'tools';
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['dimension'][];
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 { mkdirSync, writeFileSync } from 'node:fs';
25
- import { dirname, join } from 'node:path';
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 === 'inline' || typeof r.content === 'string') {
33
- return { name: String(r.name ?? r.path ?? 'resource'), content: String(r.content ?? '') };
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
- 'claude-code': 'CLAUDE.md', claude: 'CLAUDE.md', claudish: 'CLAUDE.md', nanoclaw: 'CLAUDE.md',
41
- codex: 'AGENTS.md', opencode: 'AGENTS.md', 'kimi-code': 'AGENTS.md', kimi: 'AGENTS.md', pi: 'AGENTS.md',
42
- gemini: 'GEMINI.md', // current default; AGENTS.md only via .gemini/settings.json context.fileName
43
- hermes: 'HERMES.md', // highest-priority native name; only ONE context loads (first match)
44
- openclaw: 'AGENTS.md',
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
- 'claude-code': '.claude/skills', claude: '.claude/skills', claudish: '.claude/skills', nanoclaw: '.claude/skills',
49
- codex: '.codex/skills', opencode: '.opencode/skills', 'kimi-code': '.kimi/skills', kimi: '.kimi/skills',
50
- pi: '.pi/skills', gemini: '.gemini/skills',
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: 'skills', // <workspace>/skills/<name>/SKILL.md
71
+ openclaw: "skills", // <workspace>/skills/<name>/SKILL.md
53
72
  };
54
73
  function canonicalHarness(h) {
55
- if (h === 'claude' || h === 'claudish')
56
- return 'claude-code';
57
- if (h === 'kimi')
58
- return 'kimi-code';
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, ' ').trim();
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('\n');
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] ?? '').replace(/^description:\s*/, '').trim();
76
- if (inline && !['>', '|', '>-', '|-'].includes(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 = { harness: h, files: [], env: {}, flags: [], unsupported: [] };
106
- const add = (relPath, content, mode) => plan.files.push({ relPath, content, ...(mode ? { mode } : {}) });
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
- // 1) CONTEXT (instructions + systemPrompt → the harness's native context file).
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 === 'string')
246
+ if (typeof resInstr === "string")
116
247
  instr.push(resInstr);
117
- else if (resInstr && resInstr.kind === 'inline')
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('\n\n')}\n`);
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
- // 2) SKILLS → native skill dir, from resources.skills refs (the box's shape), or
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('skills', 'skill ref is not inline (github refs need async pre-resolution)');
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('skills', `${h}: no cwd skill dir (skills live in a user/global dir); skill "${r.name}" not mounted`);
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
- add(`${dir}/${r.name}/SKILL.md`, normalizeSkillMd(r.name, r.content));
267
+ addExplicitResource("skills", `${dir}/${r.name}/SKILL.md`, normalizeSkillMd(r.name, r.content));
137
268
  }
138
- // 3) MCP → per-harness format (the divergence the matrix names). Skippable so the
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('mcp') && Object.keys(mcp).length)
272
+ if (!skip.has("mcp") && Object.keys(mcp).length)
142
273
  materializeMcp(h, mcp, plan, add, unsupported);
143
- // 4) HOOKS → per-harness format; cwd-native only for claude/codex/gemini/opencode(plugin).
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
- // 5) SUBAGENTS → cwd-native for claude/codex/opencode/gemini; flag/global elsewhere.
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
- // 6) COMMANDS → cwd-native (resources.commands refs); "commands are skills" on kimi.
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('commands', 'command ref is not inline (github needs pre-resolution)');
286
+ unsupported("commands", "command ref is not inline (github needs pre-resolution)");
156
287
  continue;
157
288
  }
158
- materializeCommand(h, r.name, r.content, add, unsupported);
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('.json')) {
566
+ if (f.relPath.endsWith(".json")) {
176
567
  try {
177
- const merged = { ...JSON.parse(prev.content), ...JSON.parse(f.content) };
178
- byPath.set(f.relPath, { ...prev, content: JSON.stringify(merged, null, 2) });
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 { /* fall through to last-wins */ }
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 f of plan.files) {
196
- const abs = join(workspaceDir, f.relPath);
197
- mkdirSync(dirname(abs), { recursive: true });
198
- writeFileSync(abs, f.content, { mode: f.mode ?? 0o644 });
199
- written.push(f.relPath);
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
- return { env: plan.env, flags: plan.flags, unsupported: plan.unsupported, written };
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 'claude-code':
207
- case 'nanoclaw':
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('.mcp.json', JSON.stringify({ mcpServers: mcp }, null, 2));
211
- add('.claude/settings.json', JSON.stringify({ enabledMcpjsonServers: Object.keys(mcp) }, null, 2));
212
- if (h === 'nanoclaw')
213
- unsupported('mcp', 'nanoclaw MCP is SDK-injected (options.mcpServers); .mcp.json may be ignored — inject via the SDK adapter');
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 'codex': {
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).map(([k, v]) => `${k} = ${tomlStr(v)}`).join(', ')} }`);
228
- lines.push('');
862
+ lines.push(`env = { ${Object.entries(s.env)
863
+ .map(([k, v]) => `${k} = ${tomlStr(v)}`)
864
+ .join(", ")} }`);
865
+ lines.push("");
229
866
  }
230
- add('.codex/config.toml', lines.join('\n'));
867
+ add(".codex/config.toml", lines.join("\n"));
231
868
  break;
232
869
  }
233
- case 'opencode':
870
+ case "opencode":
234
871
  // opencode.json "mcp" block (local|remote).
235
- add('opencode.json', JSON.stringify({ $schema: 'https://opencode.ai/config.json', mcp: opencodeMcp(mcp) }, null, 2));
872
+ add("opencode.json", JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: opencodeMcp(mcp) }, null, 2));
236
873
  break;
237
- case 'gemini':
238
- add('.gemini/settings.json', JSON.stringify({ mcpServers: mcp }, null, 2));
874
+ case "gemini":
875
+ add(".gemini/settings.json", JSON.stringify({ mcpServers: mcp }, null, 2));
239
876
  break;
240
- case 'kimi-code': {
877
+ case "kimi-code": {
241
878
  // NO cwd auto-discovery — kimi needs an explicit --mcp-config-file FLAG.
242
- const rel = '.kimi/mcp.json';
879
+ const rel = ".kimi/mcp.json";
243
880
  add(rel, JSON.stringify({ mcpServers: mcp }, null, 2));
244
- plan.flags.push('--mcp-config-file', rel);
881
+ plan.flags.push("--mcp-config-file", rel);
245
882
  break;
246
883
  }
247
- case 'pi':
248
- unsupported('mcp', 'pi MCP is wired via a TS extension (.pi/extensions/*.ts), not a declarative cwd file — build an extension');
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 'hermes':
251
- unsupported('mcp', 'hermes MCP is global config.yaml (mcp_servers), no cwd discovery — write to the hermes global config or pass a config flag');
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 'openclaw':
254
- unsupported('mcp', 'openclaw MCP is central ~/.openclaw/openclaw.json (mcp.servers), no cwd discovery');
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
- ? { type: 'remote', url: s.url, enabled: s.enabled !== false, ...(s.headers ? { headers: s.headers } : {}) }
263
- : { type: 'local', command: [s.command, ...(s.args ?? [])].filter(Boolean), enabled: s.enabled !== false, ...(s.env ? { environment: s.env } : {}) };
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 'claude-code':
270
- case 'nanoclaw': {
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) => ({ matcher: c.matcher ?? '*', hooks: [{ type: 'command', command: c.command, ...(c.timeoutMs ? { timeout: Math.round(c.timeoutMs / 1000) } : {}) }] }));
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('.claude/settings.json', JSON.stringify({ hooks: settingsHooks }, null, 2));
934
+ add(".claude/settings.json", JSON.stringify({ hooks: settingsHooks }, null, 2));
277
935
  break;
278
936
  }
279
- case 'gemini': {
937
+ case "gemini": {
280
938
  const settingsHooks = {};
281
939
  for (const [event, cmds] of Object.entries(hooks))
282
- settingsHooks[event] = cmds.map((c) => ({ matcher: c.matcher ?? '*', hooks: [{ type: 'command', command: c.command }] }));
283
- add('.gemini/settings.json', JSON.stringify({ hooks: settingsHooks }, null, 2));
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 'codex': {
947
+ case "codex": {
287
948
  // codex hooks.json (event → commands).
288
- add('.codex/hooks.json', JSON.stringify({ hooks }, null, 2));
949
+ add(".codex/hooks.json", JSON.stringify({ hooks }, null, 2));
289
950
  break;
290
951
  }
291
- case 'opencode':
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('\n');
295
- add(`.opencode/plugin/vb-hooks-${event}.js`, `// auto-generated hook plugin for ${event}\nexport default async function () {\n${body}\n}\n`);
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('hooks', 'opencode hooks are JS/TS plugins — generated a stub; a real handler must run the command (format differs from settings.json)');
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 'kimi-code':
300
- unsupported('hooks', 'kimi hooks are [[hooks]] in the config TOML loaded via --config-file, not cwd — pass --config-file');
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 'pi':
303
- unsupported('hooks', 'pi hooks are TS extensions (.pi/extensions/*.ts) — build an extension');
963
+ case "pi":
964
+ unsupported("hooks", "pi hooks are TS extensions (.pi/extensions/*.ts) — build an extension");
304
965
  break;
305
- case 'hermes': {
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('\n'));
309
- plan.env.HERMES_ENABLE_PROJECT_PLUGINS = '1';
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 'openclaw':
313
- unsupported('hooks', 'openclaw hooks are DISABLED by default — enable in ~/.openclaw/openclaw.json; cwd hooks/ alone is a no-op');
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 'claude-code':
320
- case 'nanoclaw': {
321
- const fm = ['---', `name: ${name}`];
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).filter((t) => sa.tools[t]).join(', ')}`);
328
- fm.push('---', '', sa.prompt ?? sa.description ?? name);
329
- add(`.claude/agents/${name}.md`, fm.join('\n') + '\n');
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 'opencode': {
333
- const fm = ['---', `description: ${JSON.stringify(sa.description ?? name)}`];
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('---', '', sa.prompt ?? '');
337
- add(`.opencode/agent/${name}.md`, fm.join('\n') + '\n');
1002
+ fm.push("---", "", sa.prompt ?? "");
1003
+ add(`.opencode/agents/${name}.md`, `${fm.join("\n")}\n`);
338
1004
  break;
339
1005
  }
340
- case 'gemini':
341
- add(`.gemini/agents/${name}.md`, `---\nname: ${name}\ndescription: ${JSON.stringify(sa.description ?? name)}\n---\n\n${sa.prompt ?? ''}\n`);
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 'codex': {
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('\n') + '\n');
1017
+ add(`.codex/agents/${name}.toml`, `${lines.join("\n")}\n`);
352
1018
  break;
353
1019
  }
354
1020
  default:
355
- unsupported('subagents', `${h}: subagents have no cwd file (config/flag/global only); "${name}" not mounted`);
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 'claude-code':
361
- case 'nanoclaw':
362
- add(`.claude/commands/${name}.md`, body.endsWith('\n') ? body : body + '\n');
1026
+ case "claude-code":
1027
+ case "nanoclaw":
1028
+ add(`.claude/commands/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
363
1029
  break;
364
- case 'opencode':
365
- add(`.opencode/command/${name}.md`, body.endsWith('\n') ? body : body + '\n');
1030
+ case "opencode":
1031
+ add(`.opencode/commands/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
366
1032
  break;
367
- case 'gemini':
1033
+ case "gemini":
368
1034
  add(`.gemini/commands/${name}.toml`, `prompt = ${tomlStr(body)}\n`);
369
1035
  break;
370
- case 'pi':
371
- add(`.pi/prompts/${name}.md`, body.endsWith('\n') ? body : body + '\n');
1036
+ case "pi":
1037
+ add(`.pi/prompts/${name}.md`, body.endsWith("\n") ? body : `${body}\n`);
372
1038
  break;
373
- case 'kimi-code':
374
- unsupported('commands', 'kimi commands ARE skills (/skill:<name>) — provide as a skill instead');
1039
+ case "kimi-code":
1040
+ unsupported("commands", "kimi commands ARE skills (/skill:<name>) — provide as a skill instead");
375
1041
  break;
376
- case 'codex':
377
- unsupported('commands', 'codex project commands unsupported (~/.codex/prompts is user-global only)');
1042
+ case "codex":
1043
+ unsupported("commands", "codex project commands unsupported (~/.codex/prompts is user-global only)");
378
1044
  break;
379
- default: unsupported('commands', `${h}: no cwd command path`);
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.1.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.5.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
  }