@tangle-network/agent-profile-materialize 0.5.1 → 0.7.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 CHANGED
@@ -28,6 +28,26 @@ export interface PlanFile {
28
28
  content: string;
29
29
  /** chmod mode (e.g. 0o755 for hook scripts); default 0o644. */
30
30
  mode?: number;
31
+ /**
32
+ * Provenance of this entry. `"generated"` = the materializer synthesized the
33
+ * content itself (native harness config, merged settings, lowered prompt/MCP/
34
+ * hooks/subagents). `"user"` = the exact bytes of a profile-declared resource
35
+ * ref (`resources.files`/`tools`/`agents`/`skills`/`commands`), passed through
36
+ * verbatim at the path the profile author chose.
37
+ *
38
+ * A consumer that needs to reinterpret a SPECIFIC generated file (e.g. parse
39
+ * `opencode.json` as the synthetic native config) MUST gate on
40
+ * `source === "generated"` at that relPath — matching by relPath alone cannot
41
+ * tell a harness-authored config apart from a user resource mount that
42
+ * happens to share the same conventional path, and treating arbitrary user
43
+ * content as trusted generated config is a fail-open bug, not a fallback.
44
+ *
45
+ * Optional: callers that hand-construct a {@link WorkspacePlan} directly for
46
+ * {@link applyWorkspacePlan} (tests, ad hoc writes) are not materializer
47
+ * output and have no provenance to report. `materializeProfile` always sets
48
+ * this on every file it emits.
49
+ */
50
+ source?: "user" | "generated";
31
51
  }
32
52
  export interface Unsupported {
33
53
  dimension: "files" | "skills" | "mcp" | "hooks" | "subagents" | "commands" | "instructions" | "systemPrompt" | "connections" | "confidential" | "permissions" | "tools" | "modes" | "extensions";
@@ -74,6 +94,15 @@ export interface ApplyWorkspacePlanOptions {
74
94
  }
75
95
  /** Fail when a profile asks for controls the chosen harness cannot execute. */
76
96
  export declare function assertWorkspacePlanSupported(plan: Pick<WorkspacePlan, "unsupported">): void;
97
+ /**
98
+ * Enforce plan supportability under the profile's `resources.failOnError`
99
+ * policy. Strict by default: `failOnError` absent or `true` throws on any
100
+ * unsupported dimension. An explicitly best-effort profile
101
+ * (`failOnError: false`) materializes the supported subset instead; the
102
+ * returned `"dimension: reason"` entries are what the caller must surface
103
+ * as a warning so the skipped dimensions stay visible to operators.
104
+ */
105
+ export declare function enforceWorkspacePlanSupport(plan: Pick<WorkspacePlan, "unsupported">, failOnError: boolean | undefined): string[];
77
106
  /**
78
107
  * Candidate-safe workspace plan. Spawn values remain typed until the runtime
79
108
  * resolves them; the materializer never joins argv into a shell command.
@@ -99,6 +128,42 @@ export interface MaterializeCandidateProfileOptions {
99
128
  /** Exact text fetched from pinned resource refs, keyed by declared SHA-256. */
100
129
  resolvedResources?: ReadonlyMap<Sha256Digest, string>;
101
130
  }
131
+ /**
132
+ * Whether the harness LOADS its {@link CONTEXT_FILE} from cwd on its own.
133
+ *
134
+ * True for every harness whose CLI discovers the file by name (CLAUDE.md,
135
+ * AGENTS.md, …). opencode is the exception: it reads only what its
136
+ * `opencode.json` `instructions[]` names, so a caller writing context for
137
+ * opencode must register the path as well as write it — see the `instructions`
138
+ * lowering below, and the interactive TUI's persona fold.
139
+ */
140
+ export declare function harnessAutoLoadsContextFile(harness: HarnessId): boolean;
141
+ /**
142
+ * The project context file this harness reads from cwd — its stable, documented
143
+ * ALWAYS-LOADED surface. Exported so a caller lowering additive context (e.g. a
144
+ * bare TUI's persona, which has no per-message system channel) targets the same
145
+ * file the materializer writes instructions to, instead of keeping its own copy
146
+ * of this per-harness map.
147
+ */
148
+ export declare function harnessContextFile(harness: HarnessId): string;
149
+ /**
150
+ * cwd-relative native skill dir per harness, or null if the harness has no
151
+ * cwd skill primitive (skills live in a user/global dir the workspace plan
152
+ * cannot reach). Public so consumers composing a profile can (a) render the
153
+ * exact path the platform will mount a skill at into their prompts instead
154
+ * of hardcoding `~/.claude/skills/...`, and (b) branch on skill support
155
+ * ahead of materialization instead of catching the materialize-time throw.
156
+ */
157
+ export declare const HARNESS_SKILL_DIRS: Readonly<Record<HarnessId, string | null>>;
158
+ /**
159
+ * Resolve the cwd-relative directory `resources.skills` refs materialize
160
+ * into on `harness` (after alias canonicalization: claude/claudish →
161
+ * claude-code, kimi → kimi-code). `null` means the harness cannot receive
162
+ * cwd skills — the same condition that surfaces as an `unsupported`
163
+ * "skills" entry (and an {@link assertWorkspacePlanSupported} throw) at
164
+ * materialize time, exposed here as branchable data.
165
+ */
166
+ export declare function skillDirForHarness(harness: HarnessId): string | null;
102
167
  export interface ResolveAgentProfileResourcesOptions {
103
168
  /** Repository used when a GitHub resource omits its own `repository`. */
104
169
  repository?: string;
@@ -154,6 +219,10 @@ export declare function applyAgentCandidateWorkspacePlan(plan: AgentCandidateWor
154
219
  * agent-dev-container box, and VB. Returns `unsupported` so the caller can log/fail.
155
220
  */
156
221
  export declare function applyWorkspacePlan(plan: WorkspacePlan, workspaceDir: string, options?: ApplyWorkspacePlanOptions): WorkspacePlanReceipt;
222
+ /** Tool IDs reported by the OpenCode binary shipped in the Sandbox image. */
223
+ export declare const OPENCODE_NATIVE_TOOL_IDS: readonly ["apply_patch", "bash", "codesearch", "edit", "glob", "grep", "question", "read", "skill", "task", "todowrite", "webfetch", "websearch", "write"];
224
+ /** Resolve portable and legacy profile names to current OpenCode tool IDs. */
225
+ export declare function openCodeNativeToolId(name: string): string | undefined;
157
226
  /** OpenCode-native MCP configuration used by workspace and startup paths. */
158
227
  export type OpenCodeMcpConfig = Record<string, {
159
228
  type: "remote";
package/dist/index.js CHANGED
@@ -25,15 +25,32 @@ import { createHash, randomUUID } from "node:crypto";
25
25
  import { closeSync, fchmodSync, constants as fsConstants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
26
26
  import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32, } from "node:path";
27
27
  import { agentCandidateProfileActivationSchema, agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, agentProfileSchema, canonicalCandidateBytes, canonicalCandidateDigest, resolveNativeWebTools, } from "@tangle-network/agent-interface";
28
+ function describeUnresolvedRef(r) {
29
+ const name = typeof r.name === "string" ? r.name : undefined;
30
+ const path = typeof r.path === "string" ? r.path : undefined;
31
+ if (name && path)
32
+ return `"${name}" (${path})`;
33
+ if (name)
34
+ return `"${name}"`;
35
+ if (path)
36
+ return `"${path}"`;
37
+ return "resource";
38
+ }
28
39
  /** Resolve inline bytes already present on the profile. Remote resources must
29
- * be resolved by {@link resolveAgentProfileResources} before materialization. */
40
+ * be resolved by {@link resolveAgentProfileResources} before materialization.
41
+ *
42
+ * FAIL-CLOSED: a ref that declares `kind: "inline"` is asserting its bytes are
43
+ * already resolved, so missing/non-string `content` is a caller bug, not an
44
+ * empty file — silently coercing it (`String(content ?? "")`) would produce a
45
+ * file that "looks provisioned, loads nothing", exactly the trap this module's
46
+ * docstring warns against. */
30
47
  function resolveInlineRef(ref) {
31
48
  const r = ref;
32
- if (r.kind === "inline" || typeof r.content === "string") {
33
- return {
34
- name: String(r.name ?? r.path ?? "resource"),
35
- content: String(r.content ?? ""),
36
- };
49
+ if (typeof r.content === "string") {
50
+ return { name: String(r.name ?? r.path ?? "resource"), content: r.content };
51
+ }
52
+ if (r.kind === "inline") {
53
+ throw new Error(`Profile resource ${describeUnresolvedRef(r)} declares kind "inline" but has no string content`);
37
54
  }
38
55
  return null; // github / unresolved
39
56
  }
@@ -54,7 +71,52 @@ export function assertWorkspacePlanSupported(plan) {
54
71
  throw new Error(formatUnsupported(plan.unsupported));
55
72
  }
56
73
  }
74
+ /**
75
+ * Enforce plan supportability under the profile's `resources.failOnError`
76
+ * policy. Strict by default: `failOnError` absent or `true` throws on any
77
+ * unsupported dimension. An explicitly best-effort profile
78
+ * (`failOnError: false`) materializes the supported subset instead; the
79
+ * returned `"dimension: reason"` entries are what the caller must surface
80
+ * as a warning so the skipped dimensions stay visible to operators.
81
+ */
82
+ export function enforceWorkspacePlanSupport(plan, failOnError) {
83
+ if (failOnError === false) {
84
+ return plan.unsupported.map(({ dimension, reason }) => `${dimension}: ${reason}`);
85
+ }
86
+ assertWorkspacePlanSupported(plan);
87
+ return [];
88
+ }
57
89
  // ─── Verified per-harness conventions (capability matrix) ───────────────────
90
+ /**
91
+ * Whether the harness LOADS its {@link CONTEXT_FILE} from cwd on its own.
92
+ *
93
+ * True for every harness whose CLI discovers the file by name (CLAUDE.md,
94
+ * AGENTS.md, …). opencode is the exception: it reads only what its
95
+ * `opencode.json` `instructions[]` names, so a caller writing context for
96
+ * opencode must register the path as well as write it — see the `instructions`
97
+ * lowering below, and the interactive TUI's persona fold.
98
+ */
99
+ export function harnessAutoLoadsContextFile(harness) {
100
+ return canonicalHarness(harness) !== "opencode";
101
+ }
102
+ /**
103
+ * The project context file this harness reads from cwd — its stable, documented
104
+ * ALWAYS-LOADED surface. Exported so a caller lowering additive context (e.g. a
105
+ * bare TUI's persona, which has no per-message system channel) targets the same
106
+ * file the materializer writes instructions to, instead of keeping its own copy
107
+ * of this per-harness map.
108
+ */
109
+ export function harnessContextFile(harness) {
110
+ // The map is keyed by HarnessId, but callers reach this from runtime strings
111
+ // (a registry spec name, a request field). A miss would otherwise return
112
+ // undefined behind a `string` return type and surface as an undefined write
113
+ // path much later.
114
+ const path = CONTEXT_FILE[canonicalHarness(harness)];
115
+ if (!path) {
116
+ throw new Error(`No project context file is known for harness "${harness}"`);
117
+ }
118
+ return path;
119
+ }
58
120
  /** Project context file each harness actually reads from cwd. NEVER a constant. */
59
121
  const CONTEXT_FILE = {
60
122
  "claude-code": "CLAUDE.md",
@@ -70,8 +132,15 @@ const CONTEXT_FILE = {
70
132
  hermes: "HERMES.md", // highest-priority native name; only ONE context loads (first match)
71
133
  openclaw: "AGENTS.md",
72
134
  };
73
- /** cwd-relative native skill dir, or null if the harness has no cwd skill primitive. */
74
- const SKILL_DIR = {
135
+ /**
136
+ * cwd-relative native skill dir per harness, or null if the harness has no
137
+ * cwd skill primitive (skills live in a user/global dir the workspace plan
138
+ * cannot reach). Public so consumers composing a profile can (a) render the
139
+ * exact path the platform will mount a skill at into their prompts instead
140
+ * of hardcoding `~/.claude/skills/...`, and (b) branch on skill support
141
+ * ahead of materialization instead of catching the materialize-time throw.
142
+ */
143
+ export const HARNESS_SKILL_DIRS = {
75
144
  "claude-code": ".claude/skills",
76
145
  claude: ".claude/skills",
77
146
  claudish: ".claude/skills",
@@ -85,6 +154,18 @@ const SKILL_DIR = {
85
154
  hermes: null, // skills are ~/.hermes/skills (user) only — no cwd discovery
86
155
  openclaw: "skills", // <workspace>/skills/<name>/SKILL.md
87
156
  };
157
+ const SKILL_DIR = HARNESS_SKILL_DIRS;
158
+ /**
159
+ * Resolve the cwd-relative directory `resources.skills` refs materialize
160
+ * into on `harness` (after alias canonicalization: claude/claudish →
161
+ * claude-code, kimi → kimi-code). `null` means the harness cannot receive
162
+ * cwd skills — the same condition that surfaces as an `unsupported`
163
+ * "skills" entry (and an {@link assertWorkspacePlanSupported} throw) at
164
+ * materialize time, exposed here as branchable data.
165
+ */
166
+ export function skillDirForHarness(harness) {
167
+ return HARNESS_SKILL_DIRS[canonicalHarness(harness)];
168
+ }
88
169
  function canonicalHarness(h) {
89
170
  if (h === "claude" || h === "claudish")
90
171
  return "claude-code";
@@ -360,14 +441,23 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
360
441
  flags: [],
361
442
  unsupported: [],
362
443
  };
363
- const add = (relPath, content, mode) => {
444
+ // Every plan file is tagged by provenance at the single push site: `add`
445
+ // (harness-generated native config) vs `addExplicitResource` (a profile
446
+ // resource ref's bytes, passed through verbatim). Downstream consumers that
447
+ // reinterpret a specific generated file by relPath (e.g. opencode.json as
448
+ // the synthetic native config) must gate on `source === "generated"` —
449
+ // otherwise a user resource mounted at the same conventional path is
450
+ // silently misread as trusted harness config.
451
+ const pushPlanFile = (relPath, content, source, mode) => {
364
452
  const normalizedPath = normalizeWorkspaceRelativePath(relPath);
365
453
  plan.files.push({
366
454
  relPath: normalizedPath,
367
455
  content,
456
+ source,
368
457
  ...(mode === undefined ? {} : { mode: validateFileMode(mode) }),
369
458
  });
370
459
  };
460
+ const add = (relPath, content, mode) => pushPlanFile(relPath, content, "generated", mode);
371
461
  const unsupported = (dimension, reason) => plan.unsupported.push({ dimension, reason });
372
462
  const explicitResourcePaths = new Map();
373
463
  const addExplicitResource = (dimension, relPath, content, mode) => {
@@ -377,7 +467,7 @@ export function materializeProfile(inputProfile, harness, opts = {}) {
377
467
  throw new Error(`Duplicate profile resource path: ${normalizedPath} (${previousDimension}, ${dimension})`);
378
468
  }
379
469
  explicitResourcePaths.set(normalizedPath, dimension);
380
- add(normalizedPath, content, mode);
470
+ pushPlanFile(normalizedPath, content, "user", mode);
381
471
  };
382
472
  // 1) GENERIC FILES → exact workspace-relative paths. Inline refs are
383
473
  // deterministic; remote refs must be resolved by an async caller first.
@@ -1310,33 +1400,38 @@ function claudeToolName(name) {
1310
1400
  return "WebFetch";
1311
1401
  return CLAUDE_TOOL_NAMES[normalizedControlName(name)];
1312
1402
  }
1313
- const OPENCODE_TOOL_NAMES = new Set([
1403
+ /** Tool IDs reported by the OpenCode binary shipped in the Sandbox image. */
1404
+ export const OPENCODE_NATIVE_TOOL_IDS = [
1405
+ "apply_patch",
1314
1406
  "bash",
1315
- "batch",
1316
1407
  "codesearch",
1317
1408
  "edit",
1318
1409
  "glob",
1319
1410
  "grep",
1320
- "list",
1321
- "lsp",
1322
- "multiedit",
1323
- "patch",
1411
+ "question",
1324
1412
  "read",
1413
+ "skill",
1325
1414
  "task",
1326
- "todoread",
1327
1415
  "todowrite",
1328
1416
  "webfetch",
1329
1417
  "websearch",
1330
1418
  "write",
1331
- ]);
1332
- function openCodeToolName(name) {
1419
+ ];
1420
+ const OPENCODE_NATIVE_TOOL_ID_SET = new Set(OPENCODE_NATIVE_TOOL_IDS);
1421
+ /** Resolve portable and legacy profile names to current OpenCode tool IDs. */
1422
+ export function openCodeNativeToolId(name) {
1333
1423
  const web = nativeWebToolKind(name);
1334
1424
  if (web === "search")
1335
1425
  return "websearch";
1336
1426
  if (web === "fetch")
1337
1427
  return "webfetch";
1338
1428
  const normalized = normalizedControlName(name);
1339
- return OPENCODE_TOOL_NAMES.has(normalized) ? normalized : undefined;
1429
+ if (normalized === "patch")
1430
+ return "apply_patch";
1431
+ return OPENCODE_NATIVE_TOOL_ID_SET.has(normalized) ? normalized : undefined;
1432
+ }
1433
+ function openCodeToolName(name) {
1434
+ return openCodeNativeToolId(name);
1340
1435
  }
1341
1436
  function openCodePermissionName(name) {
1342
1437
  const normalized = normalizedControlName(name);
@@ -1350,7 +1445,7 @@ function openCodePermissionName(name) {
1350
1445
  if (["bash", "mcp", "doom_loop", "external_directory"].includes(normalized)) {
1351
1446
  return normalized;
1352
1447
  }
1353
- return normalized !== "read" && OPENCODE_TOOL_NAMES.has(normalized)
1448
+ return normalized !== "read" && OPENCODE_NATIVE_TOOL_ID_SET.has(normalized)
1354
1449
  ? normalized
1355
1450
  : undefined;
1356
1451
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-profile-materialize",
3
- "version": "0.5.1",
3
+ "version": "0.7.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,7 +16,7 @@
16
16
  "dist"
17
17
  ],
18
18
  "dependencies": {
19
- "@tangle-network/agent-interface": "0.30.0"
19
+ "@tangle-network/agent-interface": "0.32.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "25.6.0",