@vaubaan/harness 1.3.2 → 1.4.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.js CHANGED
@@ -1,186 +1,217 @@
1
1
  // src/version.ts
2
- var VERSION = "1.3.2";
2
+ var VERSION = "1.4.0";
3
3
 
4
4
  // src/cli/init.ts
5
- import { existsSync as existsSync6, mkdirSync, writeFileSync as writeFileSync3 } from "fs";
6
- import { dirname as dirname3, join as join7, resolve as resolve3 } from "path";
7
- import { fileURLToPath } from "url";
8
-
9
- // src/loader.ts
10
- import { existsSync, readFileSync as readFileSync2 } from "fs";
11
- import { join } from "path";
12
- import fg from "fast-glob";
13
- import { z as z2 } from "zod";
14
-
15
- // src/parser/common.ts
16
- import { readFileSync } from "fs";
17
- import matter from "gray-matter";
18
-
19
- // src/parser/schemas.ts
20
- import { z } from "zod";
21
-
22
- // src/types.ts
23
- var AGENT_IDS = ["coding", "review", "merge", "planning"];
24
- var ENGINES = ["claude-code", "codex-cli", "opencode"];
25
- var CAPABILITIES = ["multi-step", "slash-command", "inline-exec"];
26
- var CAPABILITIES_MATRIX = {
27
- "claude-code": ["multi-step", "slash-command", "inline-exec"],
28
- "codex-cli": ["multi-step", "slash-command", "inline-exec"],
29
- opencode: ["multi-step", "slash-command", "inline-exec"]
30
- };
31
- var DEGRADED_CAPABILITIES = {
32
- "codex-cli": ["multi-step"]
33
- };
5
+ import { existsSync as existsSync9, mkdirSync as mkdirSync2, rmSync, writeFileSync as writeFileSync4 } from "fs";
6
+ import { dirname as dirname5, join as join11, resolve as resolve6 } from "path";
34
7
 
35
- // src/parser/schemas.ts
36
- var IdSchema = z.string().min(1).regex(/^[a-z][a-z0-9-]*$/, "id must be kebab-case (lowercase + digits + hyphens)");
37
- var SkillFrontmatterSchema = z.object({
38
- id: IdSchema,
39
- description: z.string().min(1),
40
- engines: z.array(z.enum(ENGINES)).min(1).optional(),
41
- requires: z.array(z.enum(CAPABILITIES)).optional(),
42
- tags: z.array(z.string()).optional()
43
- }).strict();
44
- var CmdArgEnumSchema = z.object({
45
- name: z.string().min(1),
46
- type: z.literal("enum"),
47
- values: z.array(z.string()).min(1),
48
- default: z.string().optional()
49
- }).strict();
50
- var CmdArgStringSchema = z.object({
51
- name: z.string().min(1),
52
- type: z.literal("string"),
53
- default: z.string().optional()
54
- }).strict();
55
- var CmdArgSchema = z.discriminatedUnion("type", [CmdArgEnumSchema, CmdArgStringSchema]);
56
- var CmdFrontmatterSchema = z.object({
57
- id: IdSchema,
58
- trigger: z.string().regex(/^\/[a-z][a-z0-9-]*$/, "trigger must be /kebab-case"),
59
- invokes_skill: IdSchema,
60
- args: z.array(CmdArgSchema).optional(),
61
- description: z.string().min(1)
62
- }).strict();
63
- var AgentFrontmatterSchema = z.object({
64
- id: z.enum(AGENT_IDS),
65
- description: z.string().min(1)
66
- }).strict();
67
- var Schemas = {
68
- Skill: SkillFrontmatterSchema,
69
- Cmd: CmdFrontmatterSchema,
70
- Agent: AgentFrontmatterSchema
71
- };
72
- var ParseError = class extends Error {
73
- constructor(file, issues) {
74
- const messages = issues.map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
75
- super(`Frontmatter validation failed for ${file}:
76
- ${messages}`);
77
- this.file = file;
78
- this.issues = issues;
79
- this.name = "ParseError";
8
+ // src/cli/managed-paths.ts
9
+ import { lstatSync, realpathSync, statSync } from "fs";
10
+ import { basename, dirname, isAbsolute, join, resolve, win32 } from "path";
11
+ var MANAGED_FILES = /* @__PURE__ */ new Set([
12
+ "AGENTS.md",
13
+ "CLAUDE.md",
14
+ ".mcp.json",
15
+ ".harness-manifest.json",
16
+ ".claude/settings.json",
17
+ ".codex/config.toml",
18
+ ".codex/hooks.json",
19
+ ".opencode/opencode.json",
20
+ ".opencode/mcp.json"
21
+ ]);
22
+ var MANAGED_DIRECTORIES = [
23
+ ".claude/agents/",
24
+ ".claude/commands/",
25
+ ".claude/skills/",
26
+ ".codex/agents/",
27
+ ".codex/commands/",
28
+ ".codex/skills/",
29
+ ".agents/skills/",
30
+ ".opencode/agents/",
31
+ ".opencode/commands/",
32
+ ".opencode/skills/",
33
+ ".opencode/plugins/"
34
+ ];
35
+ function canonicalTargetRoot(root) {
36
+ let ancestor = resolve(root);
37
+ const missing = [];
38
+ while (!lstatSync(ancestor, { throwIfNoEntry: false })) {
39
+ missing.unshift(basename(ancestor));
40
+ ancestor = dirname(ancestor);
80
41
  }
81
- file;
82
- issues;
83
- };
84
-
85
- // src/parser/common.ts
86
- function parseWithSchema(filePath, source, schema) {
87
- const parsed = matter(source);
88
- const result = schema.safeParse(parsed.data);
89
- if (!result.success) {
90
- throw new ParseError(filePath, result.error.issues);
42
+ const canonical = realpathSync(ancestor);
43
+ if (!statSync(canonical).isDirectory()) {
44
+ throw new Error(`harness target is not a directory: ${root}`);
91
45
  }
92
- return {
93
- meta: result.data,
94
- body: parsed.content.trim(),
95
- sourcePath: filePath
96
- };
46
+ return join(canonical, ...missing);
97
47
  }
98
-
99
- // src/parser/agent.ts
100
- function parseAgent(filePath, source) {
101
- return parseWithSchema(filePath, source, Schemas.Agent);
48
+ function assertManagedLocation(path) {
49
+ const invalidSegment = path.split("/").some((part) => part === "" || part === "." || part === "..");
50
+ if (isAbsolute(path) || win32.isAbsolute(path) || path.includes("\\") || path.includes("\0") || invalidSegment) {
51
+ throw new Error(`unsafe harness path: ${path}`);
52
+ }
53
+ if (!MANAGED_FILES.has(path) && !MANAGED_DIRECTORIES.some((prefix) => path.startsWith(prefix))) {
54
+ throw new Error(`path is outside harness-managed locations: ${path}`);
55
+ }
102
56
  }
103
-
104
- // src/parser/cmd.ts
105
- function parseCmd(filePath, source) {
106
- return parseWithSchema(filePath, source, Schemas.Cmd);
57
+ function assertNoSymbolicComponents(root, path) {
58
+ const parts = path.split("/");
59
+ let current = root;
60
+ for (const [index, part] of parts.entries()) {
61
+ current = join(current, part);
62
+ const info = lstatSync(current, { throwIfNoEntry: false });
63
+ if (!info) return;
64
+ if (info.isSymbolicLink()) {
65
+ throw new Error(`symbolic link in harness path: ${path}`);
66
+ }
67
+ const isLeaf = index === parts.length - 1;
68
+ if (isLeaf ? !info.isFile() : !info.isDirectory()) {
69
+ throw new Error(`unexpected file type in harness path: ${path}`);
70
+ }
71
+ }
107
72
  }
108
-
109
- // src/parser/skill.ts
110
- function parseSkill(filePath, source) {
111
- return parseWithSchema(filePath, source, Schemas.Skill);
73
+ function assertManagedPaths(root, files) {
74
+ for (const path of files) assertManagedLocation(path);
75
+ const canonicalRoot2 = canonicalTargetRoot(root);
76
+ for (const path of files) assertNoSymbolicComponents(canonicalRoot2, path);
112
77
  }
113
78
 
114
- // src/util/yaml.ts
115
- import { parse as yamlParse } from "yaml";
116
- function parseYAML(source) {
117
- return yamlParse(source);
118
- }
79
+ // src/cli/manifest.ts
80
+ import { createHash } from "crypto";
81
+ import { chmodSync, existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync } from "fs";
82
+ import { join as join4 } from "path";
83
+ import { z as z3 } from "zod";
119
84
 
120
- // src/loader.ts
121
- var DisableYamlSchema = z2.object({ disable: z2.array(z2.string()).optional() }).passthrough();
122
- function loadHarness(packageDir, opts = {}) {
123
- const agentsDir = opts.agentsDir ?? join(packageDir, "agents");
124
- const skillsDir = opts.skillsDir ?? join(packageDir, "skills");
125
- const commandsDir = opts.commandsDir ?? join(packageDir, "commands");
126
- const agentFiles = fg.sync("*.agent.md", { cwd: agentsDir, absolute: true });
127
- const skillFiles = fg.sync("*.skill.md", { cwd: skillsDir, absolute: true });
128
- const cmdFiles = fg.sync("*.cmd.md", { cwd: commandsDir, absolute: true });
129
- return {
130
- agents: agentFiles.map((f) => parseAgent(f, readFileSync2(f, "utf8"))),
131
- skills: skillFiles.map((f) => parseSkill(f, readFileSync2(f, "utf8"))),
132
- cmds: cmdFiles.map((f) => parseCmd(f, readFileSync2(f, "utf8")))
133
- };
85
+ // src/cli/opencode-config.ts
86
+ import { existsSync, readFileSync } from "fs";
87
+ import { join as join2 } from "path";
88
+ import { isDeepStrictEqual } from "util";
89
+ import { z } from "zod";
90
+ var OPENCODE_CONFIG_PATH = ".opencode/opencode.json";
91
+ var CLAUDE_MCP_PATH = ".mcp.json";
92
+ var OPENCODE_SCHEMA = "https://opencode.ai/config.json";
93
+ var ObjectSchema = z.record(z.unknown());
94
+ var OPENCODE_TARGET = {
95
+ path: OPENCODE_CONFIG_PATH,
96
+ key: "mcp",
97
+ label: "OpenCode",
98
+ schema: OPENCODE_SCHEMA
99
+ };
100
+ var CLAUDE_TARGET = {
101
+ path: CLAUDE_MCP_PATH,
102
+ key: "mcpServers",
103
+ label: "Claude Code"
104
+ };
105
+ var OpenCodeConfigOwnershipSchema = z.object({
106
+ createdFile: z.boolean(),
107
+ addedMcp: z.boolean(),
108
+ addedSchema: z.boolean(),
109
+ entries: z.record(ObjectSchema)
110
+ });
111
+ function parseConfig(target, content) {
112
+ try {
113
+ const config = ObjectSchema.parse(JSON.parse(content));
114
+ if (Object.hasOwn(config, target.key)) ObjectSchema.parse(config[target.key]);
115
+ return config;
116
+ } catch {
117
+ throw new Error(`invalid ${target.path}: expected a JSON object with an MCP object`);
118
+ }
134
119
  }
135
- function loadProjectSkills(targetDir) {
136
- const skillsDir = join(targetDir, "docs", "skills");
137
- if (!existsSync(skillsDir)) return [];
138
- const skillFiles = fg.sync("*.skill.md", { cwd: skillsDir, absolute: true });
139
- return skillFiles.map((f) => parseSkill(f, readFileSync2(f, "utf8")));
120
+ function removeOwnedEntries(target, config, ownership, warnings) {
121
+ if (Object.hasOwn(config, target.key)) {
122
+ const mcp = ObjectSchema.parse(config[target.key]);
123
+ for (const [id, installed] of Object.entries(ownership.entries)) {
124
+ if (!Object.hasOwn(mcp, id)) continue;
125
+ if (isDeepStrictEqual(mcp[id], installed)) delete mcp[id];
126
+ else warnings.push(`preserved modified ${target.label} MCP server: ${id}`);
127
+ }
128
+ config[target.key] = mcp;
129
+ if (ownership.addedMcp && Object.keys(mcp).length === 0)
130
+ Reflect.deleteProperty(config, target.key);
131
+ }
132
+ if (target.schema && ownership.addedSchema && config.$schema === target.schema)
133
+ Reflect.deleteProperty(config, "$schema");
140
134
  }
141
- function loadOverrides(worktreeDir) {
142
- const overridesDir = join(worktreeDir, ".harness-overrides");
143
- if (!existsSync(overridesDir)) return void 0;
144
- const added = loadHarness(overridesDir, {
145
- agentsDir: join(overridesDir, "agents"),
146
- skillsDir: join(overridesDir, "skills"),
147
- commandsDir: join(overridesDir, "commands")
148
- });
149
- const disablePath = join(overridesDir, "disable.yaml");
150
- let disable = [];
151
- if (existsSync(disablePath)) {
152
- try {
153
- const parsed = DisableYamlSchema.safeParse(parseYAML(readFileSync2(disablePath, "utf8")));
154
- if (parsed.success && parsed.data.disable) {
155
- disable = parsed.data.disable;
135
+ function addMissingEntries(target, config, desired, createdFile, warnings) {
136
+ const requested = ObjectSchema.parse(parseConfig(target, desired.content)[target.key]);
137
+ const mcp = ObjectSchema.parse(config[target.key] ?? {});
138
+ const entries = {};
139
+ for (const [id, entry] of Object.entries(requested)) {
140
+ if (Object.hasOwn(mcp, id)) {
141
+ if (!isDeepStrictEqual(mcp[id], entry)) {
142
+ warnings.push(
143
+ `preserved existing ${target.label} MCP server instead of replacing it: ${id}`
144
+ );
156
145
  }
157
- } catch {
146
+ continue;
158
147
  }
148
+ entries[id] = ObjectSchema.parse(entry);
149
+ mcp[id] = entry;
159
150
  }
160
- return { add: added, disable };
151
+ if (Object.keys(entries).length === 0) return void 0;
152
+ const ownership = {
153
+ createdFile,
154
+ addedMcp: !Object.hasOwn(config, target.key),
155
+ addedSchema: target.schema !== void 0 && !Object.hasOwn(config, "$schema"),
156
+ entries
157
+ };
158
+ config[target.key] = mcp;
159
+ if (target.schema && ownership.addedSchema) config.$schema = target.schema;
160
+ return ownership;
161
161
  }
162
-
163
- // src/mcp.ts
164
- var MCP_SERVER_IDS = ["context7"];
165
- var MCP_SERVERS = {
166
- context7: {
167
- id: "context7",
168
- description: "Upstash Context7 \u2014 fetches up-to-date library docs via resolve-library-id + get-library-docs.",
169
- command: "npx",
170
- args: ["-y", "@upstash/context7-mcp"]
162
+ function planMcpJson(target, root, desired, previous) {
163
+ const warnings = [];
164
+ if (!desired && !previous) return { remove: false, warnings };
165
+ const path = join2(root, target.path);
166
+ const existed = existsSync(path);
167
+ if (!existed && !desired) return { remove: false, warnings };
168
+ const original = existed ? readFileSync(path, "utf8") : void 0;
169
+ const config = original === void 0 ? {} : parseConfig(target, original);
170
+ const before = structuredClone(config);
171
+ if (previous) removeOwnedEntries(target, config, previous, warnings);
172
+ const createdFile = !existed || previous?.createdFile === true;
173
+ const ownership = desired ? addMissingEntries(target, config, desired, createdFile, warnings) : void 0;
174
+ if (!ownership && createdFile && Object.keys(config).length === 0) {
175
+ return { remove: existed, warnings };
171
176
  }
172
- };
173
- function resolveMcpIds(requested) {
174
- return requested.map((id) => {
175
- if (!MCP_SERVER_IDS.includes(id)) {
176
- throw new Error(`unknown MCP server: ${id} (expected one of ${MCP_SERVER_IDS.join(", ")})`);
177
- }
178
- return id;
179
- });
177
+ return {
178
+ remove: false,
179
+ ownership,
180
+ warnings,
181
+ ...!existed || !isDeepStrictEqual(before, config) ? { write: { path: target.path, content: `${JSON.stringify(config, null, 2)}
182
+ ` } } : {}
183
+ };
184
+ }
185
+ function planOpenCodeConfig(root, desired, previous) {
186
+ return planMcpJson(OPENCODE_TARGET, root, desired, previous);
187
+ }
188
+ function planClaudeMcpConfig(root, desired, previous) {
189
+ return planMcpJson(CLAUDE_TARGET, root, desired, previous);
180
190
  }
181
191
 
192
+ // src/cli/update-config.ts
193
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
194
+ import { join as join3 } from "path";
195
+ import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
196
+ import { z as z2 } from "zod";
197
+
182
198
  // src/renderers/common.ts
183
199
  import { stringify as yamlStringify } from "yaml";
200
+
201
+ // src/types.ts
202
+ var AGENT_IDS = ["coding", "review", "merge", "planning"];
203
+ var ENGINES = ["claude-code", "codex-cli", "opencode"];
204
+ var CAPABILITIES = ["multi-step", "slash-command", "inline-exec"];
205
+ var CAPABILITIES_MATRIX = {
206
+ "claude-code": ["multi-step", "slash-command", "inline-exec"],
207
+ "codex-cli": ["multi-step", "slash-command", "inline-exec"],
208
+ opencode: ["multi-step", "slash-command", "inline-exec"]
209
+ };
210
+ var DEGRADED_CAPABILITIES = {
211
+ "codex-cli": ["multi-step"]
212
+ };
213
+
214
+ // src/renderers/common.ts
184
215
  var STOP_HOOK_PACKAGE = "@vaubaan/harness";
185
216
  function checkSkillCapabilities(skill, engine) {
186
217
  const blockingWarnings = [];
@@ -624,274 +655,7 @@ function getRenderer(engine) {
624
655
  return RENDERERS[engine];
625
656
  }
626
657
 
627
- // src/render.ts
628
- var RenderError = class extends Error {
629
- constructor(message) {
630
- super(message);
631
- this.name = "RenderError";
632
- }
633
- };
634
- function mergeById(stock, added) {
635
- const result = /* @__PURE__ */ new Map();
636
- for (const item of stock) result.set(item.meta.id, item);
637
- if (added) {
638
- for (const item of added) result.set(item.meta.id, item);
639
- }
640
- return Array.from(result.values());
641
- }
642
- function removeByIds(items, disabled) {
643
- if (disabled.length === 0) return [...items];
644
- const disabledSet = new Set(disabled);
645
- return items.filter((item) => !disabledSet.has(item.meta.id));
646
- }
647
- function applyOverrides(harness, overrides) {
648
- const disabled = overrides?.disable ?? [];
649
- return {
650
- agents: removeByIds(mergeById(harness.agents, overrides?.add?.agents), disabled),
651
- skills: removeByIds(mergeById(harness.skills, overrides?.add?.skills), disabled),
652
- cmds: removeByIds(mergeById(harness.cmds, overrides?.add?.cmds), disabled)
653
- };
654
- }
655
- var CLAUDE_MD_STUB = "@AGENTS.md\n";
656
- function opencodeLast(engines) {
657
- return [...engines].sort((a, b) => Number(a === "opencode") - Number(b === "opencode"));
658
- }
659
- function render(input) {
660
- const { engines } = input;
661
- if (engines.length === 0) {
662
- throw new RenderError("at least one engine must be selected");
663
- }
664
- const base = input.projectSkills?.length ? { ...input.harness, skills: mergeById(input.harness.skills, input.projectSkills) } : input.harness;
665
- const harness = applyOverrides(base, input.overrides);
666
- if (harness.agents.length === 0) {
667
- throw new RenderError("No agents found in harness");
668
- }
669
- const files = [];
670
- const warnings = [];
671
- const exposedSkills = /* @__PURE__ */ new Map();
672
- const exposedCmds = /* @__PURE__ */ new Map();
673
- for (const engine of opencodeLast(engines)) {
674
- const renderer = getRenderer(engine);
675
- const renderedSkills = [];
676
- for (const skill of harness.skills) {
677
- const result = renderer.renderSkill(skill);
678
- warnings.push(...result.warnings);
679
- if (result.files.length > 0) {
680
- const alreadyVisible = engine === "opencode" && exposedSkills.has(skill.meta.id);
681
- if (!alreadyVisible) files.push(...result.files);
682
- renderedSkills.push(skill);
683
- exposedSkills.set(skill.meta.id, skill);
684
- }
685
- }
686
- const renderedSkillIds = new Set(renderedSkills.map((s) => s.meta.id));
687
- const renderedCmds = [];
688
- for (const cmd of harness.cmds) {
689
- if (!renderedSkillIds.has(cmd.meta.invokes_skill)) continue;
690
- const result = renderer.renderCmd(cmd);
691
- warnings.push(...result.warnings);
692
- if (result.files.length > 0) {
693
- files.push(...result.files);
694
- renderedCmds.push(cmd);
695
- exposedCmds.set(cmd.meta.id, cmd);
696
- }
697
- }
698
- const agentsResult = renderer.renderAgents(harness.agents);
699
- warnings.push(...agentsResult.warnings);
700
- files.push(...agentsResult.files);
701
- if (input.mcpServers && input.mcpServers.length > 0) {
702
- const specs = input.mcpServers.map((id) => MCP_SERVERS[id]);
703
- const mcpResult = renderer.renderMcp(specs);
704
- warnings.push(...mcpResult.warnings);
705
- files.push(...mcpResult.files);
706
- }
707
- if (input.withStopHook !== false) {
708
- const hookResult = renderer.renderStopHook();
709
- warnings.push(...hookResult.warnings);
710
- files.push(...hookResult.files);
711
- }
712
- }
713
- const hasProjectSkills = (input.projectSkills?.length ?? 0) > 0;
714
- const umbrellaContent = buildUmbrellaBody(harness.agents, {
715
- availableSkills: Array.from(exposedSkills.values()),
716
- availableCommands: Array.from(exposedCmds.values()),
717
- hasProjectSkills,
718
- hasQualityDoc: input.hasQualityDoc === true
719
- });
720
- files.push({ path: "AGENTS.md", content: umbrellaContent });
721
- if (engines.includes("claude-code")) {
722
- files.push({ path: "CLAUDE.md", content: CLAUDE_MD_STUB });
723
- }
724
- return { files, warnings };
725
- }
726
-
727
- // src/cli/managed-paths.ts
728
- import { lstatSync, realpathSync, statSync } from "fs";
729
- import { basename, dirname, isAbsolute, join as join2, resolve, win32 } from "path";
730
- var MANAGED_FILES = /* @__PURE__ */ new Set([
731
- "AGENTS.md",
732
- "CLAUDE.md",
733
- ".mcp.json",
734
- ".harness-manifest.json",
735
- ".claude/settings.json",
736
- ".codex/config.toml",
737
- ".codex/hooks.json",
738
- ".opencode/opencode.json",
739
- ".opencode/mcp.json"
740
- ]);
741
- var MANAGED_DIRECTORIES = [
742
- ".claude/agents/",
743
- ".claude/commands/",
744
- ".claude/skills/",
745
- ".codex/agents/",
746
- ".codex/commands/",
747
- ".codex/skills/",
748
- ".agents/skills/",
749
- ".opencode/agents/",
750
- ".opencode/commands/",
751
- ".opencode/skills/",
752
- ".opencode/plugins/"
753
- ];
754
- function canonicalTargetRoot(root) {
755
- let ancestor = resolve(root);
756
- const missing = [];
757
- while (!lstatSync(ancestor, { throwIfNoEntry: false })) {
758
- missing.unshift(basename(ancestor));
759
- ancestor = dirname(ancestor);
760
- }
761
- const canonical = realpathSync(ancestor);
762
- if (!statSync(canonical).isDirectory()) {
763
- throw new Error(`harness target is not a directory: ${root}`);
764
- }
765
- return join2(canonical, ...missing);
766
- }
767
- function assertManagedLocation(path) {
768
- const invalidSegment = path.split("/").some((part) => part === "" || part === "." || part === "..");
769
- if (isAbsolute(path) || win32.isAbsolute(path) || path.includes("\\") || path.includes("\0") || invalidSegment) {
770
- throw new Error(`unsafe harness path: ${path}`);
771
- }
772
- if (!MANAGED_FILES.has(path) && !MANAGED_DIRECTORIES.some((prefix) => path.startsWith(prefix))) {
773
- throw new Error(`path is outside harness-managed locations: ${path}`);
774
- }
775
- }
776
- function assertNoSymbolicComponents(root, path) {
777
- const parts = path.split("/");
778
- let current = root;
779
- for (const [index, part] of parts.entries()) {
780
- current = join2(current, part);
781
- const info = lstatSync(current, { throwIfNoEntry: false });
782
- if (!info) return;
783
- if (info.isSymbolicLink()) {
784
- throw new Error(`symbolic link in harness path: ${path}`);
785
- }
786
- const isLeaf = index === parts.length - 1;
787
- if (isLeaf ? !info.isFile() : !info.isDirectory()) {
788
- throw new Error(`unexpected file type in harness path: ${path}`);
789
- }
790
- }
791
- }
792
- function assertManagedPaths(root, files) {
793
- for (const path of files) assertManagedLocation(path);
794
- const canonicalRoot2 = canonicalTargetRoot(root);
795
- for (const path of files) assertNoSymbolicComponents(canonicalRoot2, path);
796
- }
797
-
798
- // src/cli/manifest.ts
799
- import { createHash } from "crypto";
800
- import { chmodSync, existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync } from "fs";
801
- import { join as join5 } from "path";
802
- import { z as z5 } from "zod";
803
-
804
- // src/cli/opencode-config.ts
805
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
806
- import { join as join3 } from "path";
807
- import { isDeepStrictEqual } from "util";
808
- import { z as z3 } from "zod";
809
- var OPENCODE_CONFIG_PATH = ".opencode/opencode.json";
810
- var OPENCODE_SCHEMA = "https://opencode.ai/config.json";
811
- var ObjectSchema = z3.record(z3.unknown());
812
- var OpenCodeConfigOwnershipSchema = z3.object({
813
- createdFile: z3.boolean(),
814
- addedMcp: z3.boolean(),
815
- addedSchema: z3.boolean(),
816
- entries: z3.record(ObjectSchema)
817
- });
818
- function parseConfig(content) {
819
- try {
820
- const config = ObjectSchema.parse(JSON.parse(content));
821
- if (Object.hasOwn(config, "mcp")) ObjectSchema.parse(config.mcp);
822
- return config;
823
- } catch {
824
- throw new Error(`invalid ${OPENCODE_CONFIG_PATH}: expected a JSON object with an MCP object`);
825
- }
826
- }
827
- function removeOwnedEntries(config, ownership, warnings) {
828
- if (Object.hasOwn(config, "mcp")) {
829
- const mcp = ObjectSchema.parse(config.mcp);
830
- for (const [id, installed] of Object.entries(ownership.entries)) {
831
- if (!Object.hasOwn(mcp, id)) continue;
832
- if (isDeepStrictEqual(mcp[id], installed)) delete mcp[id];
833
- else warnings.push(`preserved modified OpenCode MCP server: ${id}`);
834
- }
835
- config.mcp = mcp;
836
- if (ownership.addedMcp && Object.keys(mcp).length === 0) Reflect.deleteProperty(config, "mcp");
837
- }
838
- if (ownership.addedSchema && config.$schema === OPENCODE_SCHEMA)
839
- Reflect.deleteProperty(config, "$schema");
840
- }
841
- function addMissingEntries(config, desired, createdFile, warnings) {
842
- const requested = ObjectSchema.parse(parseConfig(desired.content).mcp);
843
- const mcp = ObjectSchema.parse(config.mcp ?? {});
844
- const entries = {};
845
- for (const [id, entry] of Object.entries(requested)) {
846
- if (Object.hasOwn(mcp, id)) {
847
- if (!isDeepStrictEqual(mcp[id], entry)) {
848
- warnings.push(`preserved existing OpenCode MCP server instead of replacing it: ${id}`);
849
- }
850
- continue;
851
- }
852
- entries[id] = ObjectSchema.parse(entry);
853
- mcp[id] = entry;
854
- }
855
- if (Object.keys(entries).length === 0) return void 0;
856
- const ownership = {
857
- createdFile,
858
- addedMcp: !Object.hasOwn(config, "mcp"),
859
- addedSchema: !Object.hasOwn(config, "$schema"),
860
- entries
861
- };
862
- config.mcp = mcp;
863
- if (ownership.addedSchema) config.$schema = OPENCODE_SCHEMA;
864
- return ownership;
865
- }
866
- function planOpenCodeConfig(root, desired, previous) {
867
- const warnings = [];
868
- if (!desired && !previous) return { remove: false, warnings };
869
- const path = join3(root, OPENCODE_CONFIG_PATH);
870
- const existed = existsSync2(path);
871
- if (!existed && !desired) return { remove: false, warnings };
872
- const original = existed ? readFileSync3(path, "utf8") : void 0;
873
- const config = original === void 0 ? {} : parseConfig(original);
874
- const before = structuredClone(config);
875
- if (previous) removeOwnedEntries(config, previous, warnings);
876
- const createdFile = !existed || previous?.createdFile === true;
877
- const ownership = desired ? addMissingEntries(config, desired, createdFile, warnings) : void 0;
878
- if (!ownership && createdFile && Object.keys(config).length === 0) {
879
- return { remove: existed, warnings };
880
- }
881
- return {
882
- remove: false,
883
- ownership,
884
- warnings,
885
- ...!existed || !isDeepStrictEqual(before, config) ? { write: { path: OPENCODE_CONFIG_PATH, content: `${JSON.stringify(config, null, 2)}
886
- ` } } : {}
887
- };
888
- }
889
-
890
658
  // src/cli/update-config.ts
891
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
892
- import { join as join4 } from "path";
893
- import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
894
- import { z as z4 } from "zod";
895
659
  var LEGACY_OPENCODE_CONFIG = ".opencode/mcp.json";
896
660
  var SHARED_CONFIG_PATHS = [
897
661
  ".mcp.json",
@@ -901,15 +665,15 @@ var SHARED_CONFIG_PATHS = [
901
665
  ".claude/settings.json",
902
666
  ".codex/hooks.json"
903
667
  ];
904
- var ObjectSchema2 = z4.record(z4.unknown());
905
- var HookEntrySchema = z4.object({ group: ObjectSchema2, hook: ObjectSchema2 });
906
- var HookConfigOwnershipSchema = z4.object({
907
- createdFile: z4.boolean(),
908
- addedHooks: z4.boolean(),
909
- addedStop: z4.boolean(),
910
- entries: z4.array(HookEntrySchema)
668
+ var ObjectSchema2 = z2.record(z2.unknown());
669
+ var HookEntrySchema = z2.object({ group: ObjectSchema2, hook: ObjectSchema2 });
670
+ var HookConfigOwnershipSchema = z2.object({
671
+ createdFile: z2.boolean(),
672
+ addedHooks: z2.boolean(),
673
+ addedStop: z2.boolean(),
674
+ entries: z2.array(HookEntrySchema)
911
675
  });
912
- var HookConfigsOwnershipSchema = z4.object({
676
+ var HookConfigsOwnershipSchema = z2.object({
913
677
  ".claude/settings.json": HookConfigOwnershipSchema.optional(),
914
678
  ".codex/hooks.json": HookConfigOwnershipSchema.optional()
915
679
  }).strict();
@@ -1033,8 +797,8 @@ function planHookConfigCleanup(root, ownership) {
1033
797
  assertManagedPaths(root, Object.keys(validated));
1034
798
  for (const path of Object.keys(HOOK_ENGINES)) {
1035
799
  const owned = validated[path];
1036
- if (!owned || !existsSync3(join4(root, path))) continue;
1037
- const before = parseObject(readFileSync4(join4(root, path), "utf8"), path);
800
+ if (!owned || !existsSync2(join3(root, path))) continue;
801
+ const before = parseObject(readFileSync2(join3(root, path), "utf8"), path);
1038
802
  const config = structuredClone(before);
1039
803
  removeOwnedHooks(config, owned, path, plan.warnings);
1040
804
  recordMutation(plan, path, before, config, owned.createdFile);
@@ -1043,12 +807,12 @@ function planHookConfigCleanup(root, ownership) {
1043
807
  }
1044
808
  function planActiveHook(root, path, previous, claimExisting, plan) {
1045
809
  assertManagedPaths(root, [path]);
1046
- const existed = existsSync3(join4(root, path));
810
+ const existed = existsSync2(join3(root, path));
1047
811
  if (!existed && previous?.entries.length) {
1048
812
  plan.warnings.push(`preserved removed harness Stop hook configuration: ${path}`);
1049
813
  return previous;
1050
814
  }
1051
- const before = existed ? parseObject(readFileSync4(join4(root, path), "utf8"), path) : void 0;
815
+ const before = existed ? parseObject(readFileSync2(join3(root, path), "utf8"), path) : void 0;
1052
816
  const config = structuredClone(before ?? {});
1053
817
  const groups = stopHooks(config, path);
1054
818
  const hooks = asObject(config.hooks) ?? {};
@@ -1060,10 +824,11 @@ function planActiveHook(root, path, previous, claimExisting, plan) {
1060
824
  plan.warnings,
1061
825
  path
1062
826
  );
827
+ const legacyWholeFile = !previous && claimExisting && existed;
1063
828
  const ownership = {
1064
- createdFile: !existed || previous?.createdFile === true,
1065
- addedHooks: previous?.addedHooks ?? !Object.hasOwn(config, "hooks"),
1066
- addedStop: previous?.addedStop ?? !Object.hasOwn(hooks, "Stop"),
829
+ createdFile: !existed || previous?.createdFile === true || legacyWholeFile,
830
+ addedHooks: previous?.addedHooks ?? (legacyWholeFile || !Object.hasOwn(config, "hooks")),
831
+ addedStop: previous?.addedStop ?? (legacyWholeFile || !Object.hasOwn(hooks, "Stop")),
1067
832
  entries: normalized.entries
1068
833
  };
1069
834
  if (!isDeepStrictEqual2(groups, normalized.groups)) {
@@ -1074,10 +839,10 @@ function planActiveHook(root, path, previous, claimExisting, plan) {
1074
839
  }
1075
840
  function planLegacyOpenCode(root, previous, plan) {
1076
841
  if (!previous.files.includes(LEGACY_OPENCODE_CONFIG)) return;
1077
- const legacyPath = join4(root, LEGACY_OPENCODE_CONFIG);
1078
- if (!existsSync3(legacyPath)) return;
842
+ const legacyPath = join3(root, LEGACY_OPENCODE_CONFIG);
843
+ if (!existsSync2(legacyPath)) return;
1079
844
  assertManagedPaths(root, [LEGACY_OPENCODE_CONFIG, OPENCODE_CONFIG_PATH]);
1080
- const legacy = parseObject(readFileSync4(legacyPath, "utf8"), LEGACY_OPENCODE_CONFIG);
845
+ const legacy = parseObject(readFileSync2(legacyPath, "utf8"), LEGACY_OPENCODE_CONFIG);
1081
846
  if (!asObject(legacy.mcp)) {
1082
847
  throw new Error(
1083
848
  `invalid shared configuration ${LEGACY_OPENCODE_CONFIG}: expected an MCP object`
@@ -1141,45 +906,24 @@ function planUpdateConfigs(root, previous, hookEngines) {
1141
906
  // src/cli/manifest.ts
1142
907
  var MANIFEST_FILENAME = ".harness-manifest.json";
1143
908
  var MANIFEST_VERSION = "6";
1144
- var ManifestSchema = z5.object({
1145
- version: z5.enum(["4", "5", MANIFEST_VERSION]),
1146
- engines: z5.array(z5.string()),
1147
- renderedAt: z5.string(),
1148
- files: z5.array(z5.string()),
1149
- stopHookInstalled: z5.boolean().optional(),
1150
- stopHookEngines: z5.array(z5.string()).optional(),
909
+ var ManifestSchema = z3.object({
910
+ version: z3.enum(["4", "5", MANIFEST_VERSION]),
911
+ engines: z3.array(z3.string()),
912
+ renderedAt: z3.string(),
913
+ files: z3.array(z3.string()),
914
+ stopHookInstalled: z3.boolean().optional(),
915
+ stopHookEngines: z3.array(z3.string()).optional(),
1151
916
  opencodeConfig: OpenCodeConfigOwnershipSchema.optional(),
917
+ /** Entries the harness added to `.mcp.json` (Claude Code). */
918
+ mcpConfig: OpenCodeConfigOwnershipSchema.optional(),
1152
919
  hookConfigs: HookConfigsOwnershipSchema.optional(),
1153
- harnessVersion: z5.string().optional(),
1154
- overridesDir: z5.string().optional(),
1155
- fileHashes: z5.record(z5.string().regex(/^[a-f0-9]{64}$/)).optional(),
1156
- preservedFiles: z5.array(z5.string()).optional()
920
+ harnessVersion: z3.string().optional(),
921
+ overridesDir: z3.string().optional(),
922
+ fileHashes: z3.record(z3.string().regex(/^[a-f0-9]{64}$/)).optional(),
923
+ preservedFiles: z3.array(z3.string()).optional()
1157
924
  });
1158
925
  function manifestPath(targetDir) {
1159
- return join5(targetDir, MANIFEST_FILENAME);
1160
- }
1161
- function writeManifest(targetDir, engines, files, extras = {}) {
1162
- assertManagedPaths(targetDir, [MANIFEST_FILENAME]);
1163
- const manifest = {
1164
- version: MANIFEST_VERSION,
1165
- harnessVersion: VERSION,
1166
- engines: [...engines].sort(),
1167
- renderedAt: (/* @__PURE__ */ new Date()).toISOString(),
1168
- files: [...files].sort(),
1169
- fileHashes: Object.fromEntries(
1170
- files.filter((file) => existsSync4(join5(targetDir, file))).sort().map((file) => [file, hashContent(readFileSync5(join5(targetDir, file)))])
1171
- ),
1172
- ...extras.overridesDir ? { overridesDir: extras.overridesDir } : {},
1173
- ...extras.preservedFiles ? { preservedFiles: extras.preservedFiles } : {},
1174
- ...extras.hookConfigs ? { hookConfigs: extras.hookConfigs } : {},
1175
- ...extras.opencodeConfig ? { opencodeConfig: extras.opencodeConfig } : {},
1176
- ...extras.stopHookInstalled !== void 0 ? { stopHookInstalled: extras.stopHookInstalled } : {},
1177
- ...extras.stopHookEngines !== void 0 ? { stopHookEngines: [...extras.stopHookEngines].sort() } : {}
1178
- };
1179
- const path = manifestPath(targetDir);
1180
- if (existsSync4(path)) chmodSync(path, 384);
1181
- writeFileSync(path, `${JSON.stringify(manifest, null, 2)}
1182
- `, { mode: 384 });
926
+ return join4(targetDir, MANIFEST_FILENAME);
1183
927
  }
1184
928
  function hashContent(content) {
1185
929
  return createHash("sha256").update(content).digest("hex");
@@ -1187,66 +931,291 @@ function hashContent(content) {
1187
931
  function readManifest(targetDir) {
1188
932
  assertManagedPaths(targetDir, [MANIFEST_FILENAME]);
1189
933
  const path = manifestPath(targetDir);
1190
- if (!existsSync4(path)) return null;
1191
- const raw = JSON.parse(readFileSync5(path, "utf8"));
934
+ if (!existsSync3(path)) return null;
935
+ const raw = JSON.parse(readFileSync3(path, "utf8"));
1192
936
  return ManifestSchema.parse(raw);
1193
937
  }
1194
938
 
1195
- // src/cli/uninstall.ts
1196
- import { existsSync as existsSync5, readdirSync, rmdirSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
1197
- import { dirname as dirname2, join as join6, relative, resolve as resolve2 } from "path";
1198
- function removeRenderedFiles(root, files) {
1199
- assertManagedPaths(root, files);
1200
- const filesRemoved = [];
1201
- const missing = [];
1202
- const dirsToConsider = /* @__PURE__ */ new Set();
1203
- for (const rel of files) {
1204
- const full = join6(root, rel);
1205
- if (existsSync5(full)) {
1206
- unlinkSync(full);
1207
- filesRemoved.push(rel);
1208
- dirsToConsider.add(dirname2(full));
1209
- } else {
1210
- missing.push(rel);
939
+ // src/cli/plan-render.ts
940
+ import { existsSync as existsSync5 } from "fs";
941
+ import { dirname as dirname2, join as join6, resolve as resolve2 } from "path";
942
+ import { fileURLToPath } from "url";
943
+
944
+ // src/loader.ts
945
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
946
+ import { join as join5 } from "path";
947
+ import fg from "fast-glob";
948
+ import { z as z5 } from "zod";
949
+
950
+ // src/parser/common.ts
951
+ import { readFileSync as readFileSync4 } from "fs";
952
+ import matter from "gray-matter";
953
+
954
+ // src/parser/schemas.ts
955
+ import { z as z4 } from "zod";
956
+ var IdSchema = z4.string().min(1).regex(/^[a-z][a-z0-9-]*$/, "id must be kebab-case (lowercase + digits + hyphens)");
957
+ var SkillFrontmatterSchema = z4.object({
958
+ id: IdSchema,
959
+ description: z4.string().min(1),
960
+ engines: z4.array(z4.enum(ENGINES)).min(1).optional(),
961
+ requires: z4.array(z4.enum(CAPABILITIES)).optional(),
962
+ tags: z4.array(z4.string()).optional()
963
+ }).strict();
964
+ var CmdArgEnumSchema = z4.object({
965
+ name: z4.string().min(1),
966
+ type: z4.literal("enum"),
967
+ values: z4.array(z4.string()).min(1),
968
+ default: z4.string().optional()
969
+ }).strict();
970
+ var CmdArgStringSchema = z4.object({
971
+ name: z4.string().min(1),
972
+ type: z4.literal("string"),
973
+ default: z4.string().optional()
974
+ }).strict();
975
+ var CmdArgSchema = z4.discriminatedUnion("type", [CmdArgEnumSchema, CmdArgStringSchema]);
976
+ var CmdFrontmatterSchema = z4.object({
977
+ id: IdSchema,
978
+ trigger: z4.string().regex(/^\/[a-z][a-z0-9-]*$/, "trigger must be /kebab-case"),
979
+ invokes_skill: IdSchema,
980
+ args: z4.array(CmdArgSchema).optional(),
981
+ description: z4.string().min(1)
982
+ }).strict();
983
+ var AgentFrontmatterSchema = z4.object({
984
+ id: z4.enum(AGENT_IDS),
985
+ description: z4.string().min(1)
986
+ }).strict();
987
+ var Schemas = {
988
+ Skill: SkillFrontmatterSchema,
989
+ Cmd: CmdFrontmatterSchema,
990
+ Agent: AgentFrontmatterSchema
991
+ };
992
+ var ParseError = class extends Error {
993
+ constructor(file, issues) {
994
+ const messages = issues.map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
995
+ super(`Frontmatter validation failed for ${file}:
996
+ ${messages}`);
997
+ this.file = file;
998
+ this.issues = issues;
999
+ this.name = "ParseError";
1000
+ }
1001
+ file;
1002
+ issues;
1003
+ };
1004
+
1005
+ // src/parser/common.ts
1006
+ function parseWithSchema(filePath, source, schema) {
1007
+ const parsed = matter(source);
1008
+ const result = schema.safeParse(parsed.data);
1009
+ if (!result.success) {
1010
+ throw new ParseError(filePath, result.error.issues);
1011
+ }
1012
+ return {
1013
+ meta: result.data,
1014
+ body: parsed.content.trim(),
1015
+ sourcePath: filePath
1016
+ };
1017
+ }
1018
+
1019
+ // src/parser/agent.ts
1020
+ function parseAgent(filePath, source) {
1021
+ return parseWithSchema(filePath, source, Schemas.Agent);
1022
+ }
1023
+
1024
+ // src/parser/cmd.ts
1025
+ function parseCmd(filePath, source) {
1026
+ return parseWithSchema(filePath, source, Schemas.Cmd);
1027
+ }
1028
+
1029
+ // src/parser/skill.ts
1030
+ function parseSkill(filePath, source) {
1031
+ return parseWithSchema(filePath, source, Schemas.Skill);
1032
+ }
1033
+
1034
+ // src/util/yaml.ts
1035
+ import { parse as yamlParse } from "yaml";
1036
+ function parseYAML(source) {
1037
+ return yamlParse(source);
1038
+ }
1039
+
1040
+ // src/loader.ts
1041
+ var DisableYamlSchema = z5.object({ disable: z5.array(z5.string()).optional() }).passthrough();
1042
+ function loadHarness(packageDir, opts = {}) {
1043
+ const agentsDir = opts.agentsDir ?? join5(packageDir, "agents");
1044
+ const skillsDir = opts.skillsDir ?? join5(packageDir, "skills");
1045
+ const commandsDir = opts.commandsDir ?? join5(packageDir, "commands");
1046
+ const agentFiles = fg.sync("*.agent.md", { cwd: agentsDir, absolute: true });
1047
+ const skillFiles = fg.sync("*.skill.md", { cwd: skillsDir, absolute: true });
1048
+ const cmdFiles = fg.sync("*.cmd.md", { cwd: commandsDir, absolute: true });
1049
+ return {
1050
+ agents: agentFiles.map((f) => parseAgent(f, readFileSync5(f, "utf8"))),
1051
+ skills: skillFiles.map((f) => parseSkill(f, readFileSync5(f, "utf8"))),
1052
+ cmds: cmdFiles.map((f) => parseCmd(f, readFileSync5(f, "utf8")))
1053
+ };
1054
+ }
1055
+ function loadProjectSkills(targetDir) {
1056
+ const skillsDir = join5(targetDir, "docs", "skills");
1057
+ if (!existsSync4(skillsDir)) return [];
1058
+ const skillFiles = fg.sync("*.skill.md", { cwd: skillsDir, absolute: true });
1059
+ return skillFiles.map((f) => parseSkill(f, readFileSync5(f, "utf8")));
1060
+ }
1061
+ function loadOverrides(worktreeDir) {
1062
+ const overridesDir = join5(worktreeDir, ".harness-overrides");
1063
+ if (!existsSync4(overridesDir)) return void 0;
1064
+ const added = loadHarness(overridesDir, {
1065
+ agentsDir: join5(overridesDir, "agents"),
1066
+ skillsDir: join5(overridesDir, "skills"),
1067
+ commandsDir: join5(overridesDir, "commands")
1068
+ });
1069
+ const disablePath = join5(overridesDir, "disable.yaml");
1070
+ let disable = [];
1071
+ if (existsSync4(disablePath)) {
1072
+ try {
1073
+ const parsed = DisableYamlSchema.safeParse(parseYAML(readFileSync5(disablePath, "utf8")));
1074
+ if (parsed.success && parsed.data.disable) {
1075
+ disable = parsed.data.disable;
1076
+ }
1077
+ } catch {
1078
+ }
1079
+ }
1080
+ return { add: added, disable };
1081
+ }
1082
+
1083
+ // src/mcp.ts
1084
+ var MCP_SERVER_IDS = ["context7"];
1085
+ var MCP_SERVERS = {
1086
+ context7: {
1087
+ id: "context7",
1088
+ description: "Upstash Context7 \u2014 fetches up-to-date library docs via resolve-library-id + get-library-docs.",
1089
+ command: "npx",
1090
+ args: ["-y", "@upstash/context7-mcp"]
1091
+ }
1092
+ };
1093
+ function resolveMcpIds(requested) {
1094
+ return requested.map((id) => {
1095
+ if (!MCP_SERVER_IDS.includes(id)) {
1096
+ throw new Error(`unknown MCP server: ${id} (expected one of ${MCP_SERVER_IDS.join(", ")})`);
1211
1097
  }
1098
+ return id;
1099
+ });
1100
+ }
1101
+
1102
+ // src/render.ts
1103
+ var RenderError = class extends Error {
1104
+ constructor(message) {
1105
+ super(message);
1106
+ this.name = "RenderError";
1107
+ }
1108
+ };
1109
+ function mergeById(stock, added) {
1110
+ const result = /* @__PURE__ */ new Map();
1111
+ for (const item of stock) result.set(item.meta.id, item);
1112
+ if (added) {
1113
+ for (const item of added) result.set(item.meta.id, item);
1114
+ }
1115
+ return Array.from(result.values());
1116
+ }
1117
+ function removeByIds(items, disabled) {
1118
+ if (disabled.length === 0) return [...items];
1119
+ const disabledSet = new Set(disabled);
1120
+ return items.filter((item) => !disabledSet.has(item.meta.id));
1121
+ }
1122
+ function applyOverrides(harness, overrides) {
1123
+ const disabled = overrides?.disable ?? [];
1124
+ return {
1125
+ agents: removeByIds(mergeById(harness.agents, overrides?.add?.agents), disabled),
1126
+ skills: removeByIds(mergeById(harness.skills, overrides?.add?.skills), disabled),
1127
+ cmds: removeByIds(mergeById(harness.cmds, overrides?.add?.cmds), disabled)
1128
+ };
1129
+ }
1130
+ var CLAUDE_MD_STUB = "@AGENTS.md\n";
1131
+ function opencodeLast(engines) {
1132
+ return [...engines].sort((a, b) => Number(a === "opencode") - Number(b === "opencode"));
1133
+ }
1134
+ function render(input) {
1135
+ const { engines } = input;
1136
+ if (engines.length === 0) {
1137
+ throw new RenderError("at least one engine must be selected");
1138
+ }
1139
+ const base = input.projectSkills?.length ? { ...input.harness, skills: mergeById(input.harness.skills, input.projectSkills) } : input.harness;
1140
+ const harness = applyOverrides(base, input.overrides);
1141
+ if (harness.agents.length === 0) {
1142
+ throw new RenderError("No agents found in harness");
1212
1143
  }
1213
- return { filesRemoved, dirsPruned: pruneEmptyDirs(root, dirsToConsider), missing };
1214
- }
1215
- function pruneEmptyDirs(root, leaves) {
1216
- const candidates = /* @__PURE__ */ new Set();
1217
- for (const leaf of leaves) {
1218
- let cur = leaf;
1219
- while (cur !== root && cur.startsWith(`${root}/`)) {
1220
- candidates.add(cur);
1221
- cur = dirname2(cur);
1144
+ const files = [];
1145
+ const warnings = [];
1146
+ const exposedSkills = /* @__PURE__ */ new Map();
1147
+ const exposedCmds = /* @__PURE__ */ new Map();
1148
+ for (const engine of opencodeLast(engines)) {
1149
+ const renderer = getRenderer(engine);
1150
+ const renderedSkills = [];
1151
+ for (const skill of harness.skills) {
1152
+ const result = renderer.renderSkill(skill);
1153
+ warnings.push(...result.warnings);
1154
+ if (result.files.length > 0) {
1155
+ const alreadyVisible = engine === "opencode" && exposedSkills.has(skill.meta.id);
1156
+ if (!alreadyVisible) files.push(...result.files);
1157
+ renderedSkills.push(skill);
1158
+ exposedSkills.set(skill.meta.id, skill);
1159
+ }
1160
+ }
1161
+ const renderedSkillIds = new Set(renderedSkills.map((s) => s.meta.id));
1162
+ const renderedCmds = [];
1163
+ for (const cmd of harness.cmds) {
1164
+ if (!renderedSkillIds.has(cmd.meta.invokes_skill)) continue;
1165
+ const result = renderer.renderCmd(cmd);
1166
+ warnings.push(...result.warnings);
1167
+ if (result.files.length > 0) {
1168
+ files.push(...result.files);
1169
+ renderedCmds.push(cmd);
1170
+ exposedCmds.set(cmd.meta.id, cmd);
1171
+ }
1172
+ }
1173
+ const agentsResult = renderer.renderAgents(harness.agents);
1174
+ warnings.push(...agentsResult.warnings);
1175
+ files.push(...agentsResult.files);
1176
+ if (input.mcpServers && input.mcpServers.length > 0) {
1177
+ const specs = input.mcpServers.map((id) => MCP_SERVERS[id]);
1178
+ const mcpResult = renderer.renderMcp(specs);
1179
+ warnings.push(...mcpResult.warnings);
1180
+ files.push(...mcpResult.files);
1181
+ }
1182
+ if (input.withStopHook !== false) {
1183
+ const hookResult = renderer.renderStopHook();
1184
+ warnings.push(...hookResult.warnings);
1185
+ files.push(...hookResult.files);
1222
1186
  }
1223
1187
  }
1224
- const pruned = [];
1225
- const sorted = [...candidates].sort((a, b) => b.length - a.length);
1226
- for (const dir of sorted) {
1227
- if (!existsSync5(dir)) continue;
1228
- if (readdirSync(dir).length > 0) continue;
1229
- rmdirSync(dir);
1230
- pruned.push(relative(root, dir));
1188
+ const hasProjectSkills = (input.projectSkills?.length ?? 0) > 0;
1189
+ const umbrellaContent = buildUmbrellaBody(harness.agents, {
1190
+ availableSkills: Array.from(exposedSkills.values()),
1191
+ availableCommands: Array.from(exposedCmds.values()),
1192
+ hasProjectSkills,
1193
+ hasQualityDoc: input.hasQualityDoc === true
1194
+ });
1195
+ files.push({ path: "AGENTS.md", content: umbrellaContent });
1196
+ if (engines.includes("claude-code")) {
1197
+ files.push({ path: "CLAUDE.md", content: CLAUDE_MD_STUB });
1231
1198
  }
1232
- return pruned;
1199
+ return { files, warnings };
1233
1200
  }
1234
1201
 
1235
- // src/cli/init.ts
1202
+ // src/cli/plan-render.ts
1236
1203
  var QUALITY_DOC_PATH = "docs/quality/code-quality.md";
1237
1204
  function resolveEngineList(requested) {
1238
- if (!requested || requested.length === 0) return [...ENGINES];
1239
- return requested.map((e) => {
1205
+ if (!requested) return [...ENGINES];
1206
+ if (requested.length === 0) throw new Error("no engine selected");
1207
+ const engines = requested.map((e) => {
1240
1208
  if (!ENGINES.includes(e)) {
1241
1209
  throw new Error(`unknown engine: ${e} (expected one of ${ENGINES.join(", ")})`);
1242
1210
  }
1243
1211
  return e;
1244
1212
  });
1213
+ return [...new Set(engines)];
1245
1214
  }
1246
1215
  function defaultPackageDir() {
1247
- const here = dirname3(fileURLToPath(import.meta.url));
1248
- if (existsSync6(join7(here, "agents"))) return here;
1249
- return resolve3(here, "..");
1216
+ const here = dirname2(fileURLToPath(import.meta.url));
1217
+ if (existsSync5(join6(here, "agents"))) return here;
1218
+ return resolve2(here, "..");
1250
1219
  }
1251
1220
  function planInit(args) {
1252
1221
  const engines = resolveEngineList(args.engines);
@@ -1254,7 +1223,7 @@ function planInit(args) {
1254
1223
  const harness = loadHarness(packageDir);
1255
1224
  const overrides = args.overridesDir ? loadOverrides(args.overridesDir) : void 0;
1256
1225
  const projectSkills = loadProjectSkills(args.targetDir);
1257
- const hasQualityDoc = existsSync6(join7(args.targetDir, QUALITY_DOC_PATH));
1226
+ const hasQualityDoc = existsSync5(join6(args.targetDir, QUALITY_DOC_PATH));
1258
1227
  const mcpServers = args.mcpServers ? resolveMcpIds(args.mcpServers) : void 0;
1259
1228
  const stopHookInstalled = args.withStopHook !== false;
1260
1229
  const output = render({
@@ -1268,107 +1237,35 @@ function planInit(args) {
1268
1237
  });
1269
1238
  return { engines, output, stopHookInstalled };
1270
1239
  }
1271
- function writeFilesToTarget(files, targetDir) {
1272
- assertManagedPaths(
1273
- targetDir,
1274
- files.map((file) => file.path)
1275
- );
1276
- const written = [];
1277
- for (const f of files) {
1278
- const full = join7(targetDir, f.path);
1279
- mkdirSync(dirname3(full), { recursive: true });
1280
- writeFileSync3(full, f.content);
1281
- written.push(f.path);
1282
- }
1283
- return written;
1284
- }
1285
- function initCommand(args) {
1286
- const { engines, output, stopHookInstalled } = planInit(args);
1287
- const previous = readManifest(args.targetDir);
1288
- assertManagedPaths(args.targetDir, [
1289
- MANIFEST_FILENAME,
1290
- ...previous?.files ?? [],
1291
- ...output.files.map((file) => file.path),
1292
- ...previous?.opencodeConfig ? [OPENCODE_CONFIG_PATH] : []
1293
- ]);
1294
- const desiredConfig = output.files.find((file) => file.path === OPENCODE_CONFIG_PATH);
1295
- const config = planOpenCodeConfig(args.targetDir, desiredConfig, previous?.opencodeConfig);
1296
- const next = new Set(output.files.map((f) => f.path));
1297
- const stale = (previous?.files ?? []).filter((f) => !next.has(f) && f !== OPENCODE_CONFIG_PATH);
1298
- if (config.remove) stale.push(OPENCODE_CONFIG_PATH);
1299
- const staleFilesRemoved = removeRenderedFiles(resolve3(args.targetDir), stale).filesRemoved;
1300
- const ownedFiles = output.files.filter((file) => file.path !== OPENCODE_CONFIG_PATH);
1301
- const filesWritten = writeFilesToTarget(ownedFiles, args.targetDir);
1302
- if (config.write) writeFilesToTarget([config.write], args.targetDir);
1303
- writeManifest(args.targetDir, engines, filesWritten, {
1304
- stopHookInstalled,
1305
- stopHookEngines: stopHookInstalled ? engines : void 0,
1306
- opencodeConfig: config.ownership,
1307
- overridesDir: args.overridesDir ? resolve3(args.overridesDir) === resolve3(args.targetDir) ? "." : resolve3(args.overridesDir) : void 0
1308
- });
1309
- if (config.write) filesWritten.push(OPENCODE_CONFIG_PATH);
1310
- const warnings = [...output.warnings, ...config.warnings];
1311
- if (previous?.files.includes(OPENCODE_CONFIG_PATH) && !previous.opencodeConfig) {
1312
- warnings.push(`preserved legacy shared configuration: ${OPENCODE_CONFIG_PATH}`);
1313
- }
1314
- return { filesWritten, staleFilesRemoved, warnings, stopHookInstalled };
1315
- }
1316
-
1317
- // src/cli/update.ts
1318
- import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
1319
- import { join as join10, resolve as resolve5 } from "path";
1320
- import { isDeepStrictEqual as isDeepStrictEqual3 } from "util";
1321
1240
 
1322
- // src/cli/update-manifest.ts
1323
- import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
1324
- import { join as join8 } from "path";
1325
- import { z as z6 } from "zod";
1326
- var EngineSchema = z6.enum(ENGINES);
1327
- var LegacySchema = z6.object({
1328
- version: z6.enum(["1", "2", "3"]),
1329
- engine: EngineSchema.optional(),
1330
- engines: z6.array(EngineSchema).nonempty().optional(),
1331
- renderedAt: z6.string(),
1332
- files: z6.array(z6.string())
1333
- });
1334
- var HOOK_PATHS = {
1335
- "claude-code": [".claude/settings.json"],
1336
- "codex-cli": [".codex/hooks.json"],
1337
- opencode: [
1338
- ".opencode/plugins/harness-stop-hook/index.ts",
1339
- ".opencode/plugins/harness-stop-hook.ts"
1340
- ]
1341
- };
1342
- function readUpdateInstallation(root) {
1343
- assertManagedPaths(root, [MANIFEST_FILENAME]);
1344
- if (!existsSync7(join8(root, MANIFEST_FILENAME))) {
1345
- throw new Error("no harness manifest found; use init for a new installation");
1346
- }
1347
- const raw = JSON.parse(readFileSync6(join8(root, MANIFEST_FILENAME), "utf8"));
1348
- const sourceVersion = String(raw?.version);
1349
- let manifest;
1350
- if (["1", "2", "3"].includes(sourceVersion)) {
1351
- const legacy = LegacySchema.parse(raw);
1352
- const engines2 = legacy.version === "1" ? [EngineSchema.parse(legacy.engine)] : legacy.engines;
1353
- manifest = {
1354
- version: "4",
1355
- engines: z6.array(EngineSchema).nonempty().parse(engines2),
1356
- files: legacy.files,
1357
- renderedAt: legacy.renderedAt
1358
- };
1359
- } else {
1360
- const current = readManifest(root);
1361
- if (!current) throw new Error("harness manifest disappeared during planning");
1362
- manifest = current;
1241
+ // src/cli/uninstall.ts
1242
+ import {
1243
+ existsSync as existsSync6,
1244
+ readFileSync as readFileSync6,
1245
+ readdirSync,
1246
+ rmdirSync,
1247
+ unlinkSync,
1248
+ writeFileSync as writeFileSync2
1249
+ } from "fs";
1250
+ import { dirname as dirname3, join as join7, relative, resolve as resolve3 } from "path";
1251
+ function pruneEmptyDirs(root, leaves) {
1252
+ const candidates = /* @__PURE__ */ new Set();
1253
+ for (const leaf of leaves) {
1254
+ let cur = leaf;
1255
+ while (cur !== root && cur.startsWith(`${root}/`)) {
1256
+ candidates.add(cur);
1257
+ cur = dirname3(cur);
1258
+ }
1363
1259
  }
1364
- const engines = [...new Set(z6.array(EngineSchema).nonempty().parse(manifest.engines))];
1365
- const hookEngines = manifest.stopHookInstalled === false ? [] : manifest.stopHookEngines !== void 0 ? z6.array(EngineSchema).parse(manifest.stopHookEngines) : manifest.stopHookInstalled === true ? engines : engines.filter(
1366
- (engine) => HOOK_PATHS[engine].some((path) => manifest.files.includes(path))
1367
- );
1368
- if (hookEngines.some((engine) => !engines.includes(engine))) {
1369
- throw new Error("manifest stopHookEngines must be a subset of installed engines");
1260
+ const pruned = [];
1261
+ const sorted = [...candidates].sort((a, b) => b.length - a.length);
1262
+ for (const dir of sorted) {
1263
+ if (!existsSync6(dir)) continue;
1264
+ if (readdirSync(dir).length > 0) continue;
1265
+ rmdirSync(dir);
1266
+ pruned.push(relative(root, dir));
1370
1267
  }
1371
- return { manifest, sourceVersion, engines, hookEngines: [...new Set(hookEngines)] };
1268
+ return pruned;
1372
1269
  }
1373
1270
 
1374
1271
  // src/cli/update-transaction.ts
@@ -1378,17 +1275,17 @@ import {
1378
1275
  closeSync,
1379
1276
  fsyncSync,
1380
1277
  lstatSync as lstatSync2,
1381
- mkdirSync as mkdirSync2,
1278
+ mkdirSync,
1382
1279
  openSync,
1383
1280
  readFileSync as readFileSync7,
1384
1281
  readdirSync as readdirSync2,
1385
1282
  realpathSync as realpathSync2,
1386
1283
  rmdirSync as rmdirSync2,
1387
1284
  unlinkSync as unlinkSync2,
1388
- writeFileSync as writeFileSync4
1285
+ writeFileSync as writeFileSync3
1389
1286
  } from "fs";
1390
1287
  import { homedir } from "os";
1391
- import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as join9, relative as relative2, resolve as resolve4, sep } from "path";
1288
+ import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as join8, relative as relative2, resolve as resolve4, sep } from "path";
1392
1289
  var MANIFEST_PATH = ".harness-manifest.json";
1393
1290
  var PRIVATE_FILES = /* @__PURE__ */ new Set([
1394
1291
  MANIFEST_PATH,
@@ -1406,7 +1303,7 @@ function canonicalRoot(root) {
1406
1303
  missing.unshift(basename2(ancestor));
1407
1304
  ancestor = dirname4(ancestor);
1408
1305
  }
1409
- return join9(realpathSync2(ancestor), ...missing);
1306
+ return join8(realpathSync2(ancestor), ...missing);
1410
1307
  }
1411
1308
  function contains(parent, child) {
1412
1309
  const path = relative2(parent, child);
@@ -1438,7 +1335,7 @@ function createDirectories(path, created, mode = 511) {
1438
1335
  missing.unshift(current);
1439
1336
  }
1440
1337
  for (const directory of missing) {
1441
- mkdirSync2(directory, { mode });
1338
+ mkdirSync(directory, { mode });
1442
1339
  created.push(directory);
1443
1340
  }
1444
1341
  }
@@ -1446,7 +1343,7 @@ function writeBackupFile(path, content) {
1446
1343
  createDirectories(dirname4(path), [], 448);
1447
1344
  const fd = openSync(path, "wx", 384);
1448
1345
  try {
1449
- writeFileSync4(fd, content);
1346
+ writeFileSync3(fd, content);
1450
1347
  fsyncSync(fd);
1451
1348
  } finally {
1452
1349
  closeSync(fd);
@@ -1455,16 +1352,16 @@ function writeBackupFile(path, content) {
1455
1352
  function saveSnapshot(root, paths, backup) {
1456
1353
  const entries = [];
1457
1354
  createDirectories(dirname4(backup), [], 448);
1458
- mkdirSync2(backup, { mode: 448 });
1355
+ mkdirSync(backup, { mode: 448 });
1459
1356
  for (const path of paths) {
1460
- const full = join9(root, path);
1357
+ const full = join8(root, path);
1461
1358
  const info = lstatSync2(full, { throwIfNoEntry: false });
1462
1359
  if (!info) {
1463
1360
  entries.push({ path, exists: false });
1464
1361
  continue;
1465
1362
  }
1466
1363
  const content = readFileSync7(full);
1467
- writeBackupFile(join9(backup, "files", path), content);
1364
+ writeBackupFile(join8(backup, "files", path), content);
1468
1365
  entries.push({
1469
1366
  path,
1470
1367
  exists: true,
@@ -1473,7 +1370,7 @@ function saveSnapshot(root, paths, backup) {
1473
1370
  });
1474
1371
  }
1475
1372
  writeBackupFile(
1476
- join9(backup, "snapshot.json"),
1373
+ join8(backup, "snapshot.json"),
1477
1374
  `${JSON.stringify({ version: 1, root, createdAt: (/* @__PURE__ */ new Date()).toISOString(), entries }, null, 2)}
1478
1375
  `
1479
1376
  );
@@ -1488,16 +1385,16 @@ function rollback(root, backup, entries, touched, createdDirectories) {
1488
1385
  try {
1489
1386
  assertManagedPaths(root, [path]);
1490
1387
  const entry = entries.get(path);
1491
- const full = join9(root, path);
1388
+ const full = join8(root, path);
1492
1389
  const current = lstatSync2(full, { throwIfNoEntry: false });
1493
1390
  if (!entry?.exists) {
1494
1391
  if (current) unlinkSync2(full);
1495
1392
  continue;
1496
1393
  }
1497
- const original = readFileSync7(join9(backup, "files", path));
1394
+ const original = readFileSync7(join8(backup, "files", path));
1498
1395
  if (!current || !readFileSync7(full).equals(original)) {
1499
1396
  if (current) unlinkSync2(full);
1500
- writeFileSync4(full, original, { mode: 384 });
1397
+ writeFileSync3(full, original, { mode: 384 });
1501
1398
  }
1502
1399
  chmodSync2(full, entry.mode);
1503
1400
  } catch (error) {
@@ -1518,7 +1415,7 @@ function applyUpdateTransaction(root, writes, removals, backupDir) {
1518
1415
  assertManagedPaths(root, paths);
1519
1416
  const target = canonicalRoot(root);
1520
1417
  const backup = resolve4(
1521
- backupDir ?? join9(homedir(), ".local", "state", "vaubaan-harness", "backups", randomUUID())
1418
+ backupDir ?? join8(homedir(), ".local", "state", "vaubaan-harness", "backups", randomUUID())
1522
1419
  );
1523
1420
  assertBackupLocation(target, backup);
1524
1421
  let snapshot;
@@ -1536,18 +1433,18 @@ function applyUpdateTransaction(root, writes, removals, backupDir) {
1536
1433
  try {
1537
1434
  assertManagedPaths(target, paths);
1538
1435
  for (const path of removals) {
1539
- if (!lstatSync2(join9(target, path), { throwIfNoEntry: false })) continue;
1436
+ if (!lstatSync2(join8(target, path), { throwIfNoEntry: false })) continue;
1540
1437
  touched.add(path);
1541
- unlinkSync2(join9(target, path));
1438
+ unlinkSync2(join8(target, path));
1542
1439
  }
1543
1440
  for (const file of writes) {
1544
- const full = join9(target, file.path);
1441
+ const full = join8(target, file.path);
1545
1442
  createDirectories(dirname4(full), createdDirectories);
1546
1443
  touched.add(file.path);
1547
1444
  const entry = entries.get(file.path);
1548
1445
  const mode = file.path === MANIFEST_PATH ? 384 : entry?.exists ? entry.mode : PRIVATE_FILES.has(file.path) ? 384 : 420;
1549
1446
  if (entry?.exists && file.path === MANIFEST_PATH) chmodSync2(full, mode);
1550
- writeFileSync4(full, file.content, { mode });
1447
+ writeFileSync3(full, file.content, { mode });
1551
1448
  if (entry?.exists) chmodSync2(full, mode);
1552
1449
  }
1553
1450
  } catch (error) {
@@ -1561,29 +1458,159 @@ function applyUpdateTransaction(root, writes, removals, backupDir) {
1561
1458
  }
1562
1459
 
1563
1460
  // src/cli/update.ts
1461
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
1462
+ import { join as join10, resolve as resolve5 } from "path";
1463
+ import { isDeepStrictEqual as isDeepStrictEqual3 } from "util";
1464
+
1465
+ // src/cli/update-manifest.ts
1466
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
1467
+ import { join as join9 } from "path";
1468
+ import { z as z6 } from "zod";
1469
+ var EngineSchema = z6.enum(ENGINES);
1470
+ var LegacySchema = z6.object({
1471
+ version: z6.enum(["1", "2", "3"]),
1472
+ engine: EngineSchema.optional(),
1473
+ engines: z6.array(EngineSchema).nonempty().optional(),
1474
+ renderedAt: z6.string(),
1475
+ files: z6.array(z6.string())
1476
+ });
1477
+ var HOOK_PATHS = {
1478
+ "claude-code": [".claude/settings.json"],
1479
+ "codex-cli": [".codex/hooks.json"],
1480
+ opencode: [
1481
+ ".opencode/plugins/harness-stop-hook/index.ts",
1482
+ ".opencode/plugins/harness-stop-hook.ts"
1483
+ ]
1484
+ };
1485
+ function readUpdateInstallation(root) {
1486
+ assertManagedPaths(root, [MANIFEST_FILENAME]);
1487
+ if (!existsSync7(join9(root, MANIFEST_FILENAME))) {
1488
+ throw new Error("no harness manifest found; use init for a new installation");
1489
+ }
1490
+ const raw = JSON.parse(readFileSync8(join9(root, MANIFEST_FILENAME), "utf8"));
1491
+ const sourceVersion = String(raw?.version);
1492
+ let manifest;
1493
+ if (["1", "2", "3"].includes(sourceVersion)) {
1494
+ const legacy = LegacySchema.parse(raw);
1495
+ const engines2 = legacy.version === "1" ? [EngineSchema.parse(legacy.engine)] : legacy.engines;
1496
+ manifest = {
1497
+ version: "4",
1498
+ engines: z6.array(EngineSchema).nonempty().parse(engines2),
1499
+ files: legacy.files,
1500
+ renderedAt: legacy.renderedAt
1501
+ };
1502
+ } else {
1503
+ const current = readManifest(root);
1504
+ if (!current) throw new Error("harness manifest disappeared during planning");
1505
+ manifest = current;
1506
+ }
1507
+ const engines = [...new Set(z6.array(EngineSchema).nonempty().parse(manifest.engines))];
1508
+ const hookEngines = manifest.stopHookInstalled === false ? [] : manifest.stopHookEngines !== void 0 ? z6.array(EngineSchema).parse(manifest.stopHookEngines) : manifest.stopHookInstalled === true ? engines : engines.filter(
1509
+ (engine) => HOOK_PATHS[engine].some((path) => manifest.files.includes(path))
1510
+ );
1511
+ if (hookEngines.some((engine) => !engines.includes(engine))) {
1512
+ throw new Error("manifest stopHookEngines must be a subset of installed engines");
1513
+ }
1514
+ return { manifest, sourceVersion, engines, hookEngines: [...new Set(hookEngines)] };
1515
+ }
1516
+
1517
+ // src/cli/update.ts
1518
+ var CODEX_CONFIG_PATH = ".codex/config.toml";
1564
1519
  function currentContent(root, path) {
1565
- return existsSync8(join10(root, path)) ? readFileSync8(join10(root, path), "utf8") : void 0;
1520
+ return existsSync8(join10(root, path)) ? readFileSync9(join10(root, path), "utf8") : void 0;
1521
+ }
1522
+ function ownedUnchanged(root, previous, path) {
1523
+ const content = currentContent(root, path);
1524
+ if (content === void 0 || !previous.files.includes(path)) return false;
1525
+ const hash = previous.fileHashes?.[path];
1526
+ return hash === void 0 || hash === hashContent(content);
1566
1527
  }
1567
1528
  function checkArtifactConflicts(root, previous, writes, removals) {
1568
1529
  const desired = new Map(writes.map((file) => [file.path, file.content]));
1530
+ const conflicts = [];
1569
1531
  for (const path of [...desired.keys(), ...removals]) {
1570
1532
  if (SHARED_CONFIG_PATHS.includes(path)) continue;
1571
1533
  const content = currentContent(root, path);
1572
- if (content === void 0) continue;
1534
+ if (content === void 0 || content === desired.get(path)) continue;
1573
1535
  if (!previous.files.includes(path)) {
1574
- throw new Error(
1575
- `existing file outside harness manifest: ${path}; use overrides or --preserve`
1576
- );
1536
+ conflicts.push(`existing file outside harness manifest: ${path}`);
1537
+ continue;
1577
1538
  }
1578
1539
  const hash = previous.fileHashes?.[path];
1579
- if (hash && hash !== hashContent(content) && content !== desired.get(path)) {
1580
- throw new Error(`modified harness file: ${path}; move edits to overrides or use --preserve`);
1540
+ if (hash && hash !== hashContent(content)) conflicts.push(`modified harness file: ${path}`);
1541
+ }
1542
+ if (conflicts.length) {
1543
+ throw new Error(
1544
+ `${conflicts.join("\n")}
1545
+ move their content to docs/ or .harness-overrides/, or keep them as yours with --preserve <path>`
1546
+ );
1547
+ }
1548
+ }
1549
+ function legacyMcpOwnership(root, previous) {
1550
+ if (!ownedUnchanged(root, previous, CLAUDE_MCP_PATH)) return void 0;
1551
+ try {
1552
+ const servers = JSON.parse(currentContent(root, CLAUDE_MCP_PATH) ?? "").mcpServers;
1553
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) return void 0;
1554
+ return { createdFile: true, addedMcp: true, addedSchema: false, entries: servers };
1555
+ } catch {
1556
+ return void 0;
1557
+ }
1558
+ }
1559
+ function planMcp(root, settings, rendered, pendingWrites) {
1560
+ const { previous } = settings;
1561
+ const plan = {
1562
+ writes: [],
1563
+ removals: [],
1564
+ owned: [],
1565
+ plannedOpenCode: false,
1566
+ warnings: []
1567
+ };
1568
+ const claudeOwnership = previous.mcpConfig ?? legacyMcpOwnership(root, previous);
1569
+ const codexOwned = ownedUnchanged(root, previous, CODEX_CONFIG_PATH);
1570
+ if (settings.mcpServers === void 0) {
1571
+ if (codexOwned) plan.owned.push(CODEX_CONFIG_PATH);
1572
+ plan.mcpConfig = claudeOwnership;
1573
+ return plan;
1574
+ }
1575
+ const desired = (path) => rendered.find((file) => file.path === path);
1576
+ if (!pendingWrites.some((file) => file.path === OPENCODE_CONFIG_PATH)) {
1577
+ plan.plannedOpenCode = true;
1578
+ if (previous.files.includes(OPENCODE_CONFIG_PATH) && !previous.opencodeConfig) {
1579
+ plan.warnings.push(`preserved legacy shared configuration: ${OPENCODE_CONFIG_PATH}`);
1581
1580
  }
1581
+ const opencode = planOpenCodeConfig(
1582
+ root,
1583
+ desired(OPENCODE_CONFIG_PATH),
1584
+ previous.opencodeConfig
1585
+ );
1586
+ if (opencode.write) plan.writes.push(opencode.write);
1587
+ if (opencode.remove) plan.removals.push(OPENCODE_CONFIG_PATH);
1588
+ plan.opencodeConfig = opencode.ownership;
1589
+ plan.warnings.push(...opencode.warnings);
1590
+ }
1591
+ const claude = planClaudeMcpConfig(root, desired(CLAUDE_MCP_PATH), claudeOwnership);
1592
+ if (claude.write) plan.writes.push(claude.write);
1593
+ if (claude.remove) plan.removals.push(CLAUDE_MCP_PATH);
1594
+ plan.mcpConfig = claude.ownership;
1595
+ plan.warnings.push(...claude.warnings);
1596
+ const codex = desired(CODEX_CONFIG_PATH);
1597
+ const codexExists = existsSync8(join10(root, CODEX_CONFIG_PATH));
1598
+ if (codex && (!codexExists || codexOwned)) {
1599
+ plan.writes.push(codex);
1600
+ plan.owned.push(CODEX_CONFIG_PATH);
1601
+ } else if (codex) {
1602
+ plan.warnings.push(
1603
+ `left ${CODEX_CONFIG_PATH} unchanged; add this block to it to enable MCP in Codex:
1604
+ ${codex.content.trimEnd()}`
1605
+ );
1606
+ } else if (codexOwned) {
1607
+ plan.removals.push(CODEX_CONFIG_PATH);
1582
1608
  }
1609
+ return plan;
1583
1610
  }
1584
- function planUpdate(args) {
1611
+ function planInstallation(args, settings) {
1585
1612
  const root = resolve5(args.targetDir);
1586
- const { manifest: previous, sourceVersion, engines, hookEngines } = readUpdateInstallation(root);
1613
+ const { previous, engines, hookEngines } = settings;
1587
1614
  const preserved = [
1588
1615
  .../* @__PURE__ */ new Set([...previous.preservedFiles ?? [], ...args.preserveFiles ?? []])
1589
1616
  ].sort();
@@ -1604,7 +1631,7 @@ function planUpdate(args) {
1604
1631
  const disablePath = join10(overridesDir, ".harness-overrides", "disable.yaml");
1605
1632
  if (existsSync8(disablePath)) {
1606
1633
  try {
1607
- DisableYamlSchema.parse(parseYAML(readFileSync8(disablePath, "utf8")));
1634
+ DisableYamlSchema.parse(parseYAML(readFileSync9(disablePath, "utf8")));
1608
1635
  } catch {
1609
1636
  throw new Error(`invalid override selection: ${disablePath}`);
1610
1637
  }
@@ -1614,21 +1641,27 @@ function planUpdate(args) {
1614
1641
  packageDir: args.packageDir,
1615
1642
  engines,
1616
1643
  overridesDir: hasOverrides ? overridesDir : void 0,
1644
+ mcpServers: settings.mcpServers,
1617
1645
  withStopHook: false
1618
1646
  });
1647
+ const generated = rendered.output.files.filter(
1648
+ (file) => !SHARED_CONFIG_PATHS.includes(file.path)
1649
+ );
1619
1650
  const config = planUpdateConfigs(root, previous, hookEngines);
1620
- const output = [...rendered.output.files, ...config.writes].filter(
1651
+ const mcp = planMcp(root, settings, rendered.output.files, config.writes);
1652
+ const output = [...generated, ...config.writes, ...mcp.writes].filter(
1621
1653
  (file) => !preserved.includes(file.path)
1622
1654
  );
1623
1655
  const owned = [
1624
- .../* @__PURE__ */ new Set([...rendered.output.files.map((file) => file.path), ...config.ownedFiles])
1656
+ .../* @__PURE__ */ new Set([...generated.map((file) => file.path), ...config.ownedFiles, ...mcp.owned])
1625
1657
  ].filter((file) => !preserved.includes(file)).sort();
1626
1658
  const removals = [
1627
1659
  .../* @__PURE__ */ new Set([
1628
1660
  ...previous.files.filter(
1629
1661
  (file) => !owned.includes(file) && !SHARED_CONFIG_PATHS.includes(file)
1630
1662
  ),
1631
- ...config.removals
1663
+ ...config.removals,
1664
+ ...mcp.removals
1632
1665
  ])
1633
1666
  ].filter((file) => !preserved.includes(file));
1634
1667
  assertManagedPaths(root, [...output.map((file) => file.path), ...removals]);
@@ -1640,6 +1673,7 @@ function planUpdate(args) {
1640
1673
  return [path, hashContent(content)];
1641
1674
  })
1642
1675
  );
1676
+ const opencodeConfig = mcp.plannedOpenCode ? mcp.opencodeConfig : config.opencodeConfig;
1643
1677
  const manifest = {
1644
1678
  version: MANIFEST_VERSION,
1645
1679
  harnessVersion: VERSION,
@@ -1651,7 +1685,8 @@ function planUpdate(args) {
1651
1685
  stopHookEngines: [...hookEngines].sort(),
1652
1686
  ...hasOverrides ? { overridesDir: overridesDir === root ? "." : overridesDir } : {},
1653
1687
  ...preserved.length ? { preservedFiles: preserved } : {},
1654
- ...config.opencodeConfig ? { opencodeConfig: config.opencodeConfig } : {},
1688
+ ...opencodeConfig ? { opencodeConfig } : {},
1689
+ ...mcp.mcpConfig ? { mcpConfig: mcp.mcpConfig } : {},
1655
1690
  ...config.hookConfigs ? { hookConfigs: config.hookConfigs } : {}
1656
1691
  };
1657
1692
  const writes = output.filter((file) => currentContent(root, file.path) !== file.content);
@@ -1661,25 +1696,33 @@ function planUpdate(args) {
1661
1696
  writes.push({ path: MANIFEST_FILENAME, content: `${JSON.stringify(manifest, null, 2)}
1662
1697
  ` });
1663
1698
  }
1664
- const warnings = [...rendered.output.warnings, ...config.warnings];
1699
+ const warnings = [...rendered.output.warnings, ...config.warnings, ...mcp.warnings];
1700
+ if (preserved.length) warnings.push(`preserved user-owned artifacts: ${preserved.join(", ")}`);
1701
+ return { writes, removals: existingRemovals, owned, manifest, warnings };
1702
+ }
1703
+ function planUpdate(args) {
1704
+ const root = resolve5(args.targetDir);
1705
+ const { manifest: previous, sourceVersion, engines, hookEngines } = readUpdateInstallation(root);
1706
+ const plan = planInstallation(args, { previous, sourceVersion, engines, hookEngines });
1707
+ const warnings = [...plan.warnings];
1665
1708
  if (!previous.fileHashes) {
1666
1709
  warnings.push(
1667
1710
  "legacy manifest has no file fingerprints: managed artifacts will be regenerated; backup preserves their previous contents"
1668
1711
  );
1669
1712
  }
1670
- if (preserved.length) warnings.push(`preserved user-owned artifacts: ${preserved.join(", ")}`);
1671
1713
  if (engines.includes("codex-cli"))
1672
1714
  warnings.push("ensure .agents/ is ignored by Git (run harness check)");
1673
1715
  return {
1674
- writes,
1675
- removals: existingRemovals,
1716
+ writes: plan.writes,
1717
+ removals: plan.removals,
1718
+ owned: plan.owned,
1676
1719
  result: {
1677
1720
  version: VERSION,
1678
1721
  fromManifestVersion: sourceVersion,
1679
1722
  dryRun: args.dryRun === true,
1680
- upToDate: writes.length === 0 && existingRemovals.length === 0,
1681
- filesToWrite: writes.map((file) => file.path),
1682
- filesToRemove: existingRemovals,
1723
+ upToDate: plan.writes.length === 0 && plan.removals.length === 0,
1724
+ filesToWrite: plan.writes.map((file) => file.path),
1725
+ filesToRemove: plan.removals,
1683
1726
  warnings
1684
1727
  }
1685
1728
  };
@@ -1695,6 +1738,67 @@ function updateCommand(args) {
1695
1738
  );
1696
1739
  return { ...plan.result, backupDir };
1697
1740
  }
1741
+
1742
+ // src/cli/init.ts
1743
+ function writeFilesToTarget(files, targetDir) {
1744
+ assertManagedPaths(
1745
+ targetDir,
1746
+ files.map((file) => file.path)
1747
+ );
1748
+ const written = [];
1749
+ for (const f of files) {
1750
+ const full = join11(targetDir, f.path);
1751
+ mkdirSync2(dirname5(full), { recursive: true });
1752
+ writeFileSync4(full, f.content);
1753
+ written.push(f.path);
1754
+ }
1755
+ return written;
1756
+ }
1757
+ function emptyManifest(engines) {
1758
+ return { version: MANIFEST_VERSION, engines: [...engines], renderedAt: "", files: [] };
1759
+ }
1760
+ function initCommand(args) {
1761
+ const root = resolve6(args.targetDir);
1762
+ const engines = resolveEngineList(args.engines);
1763
+ const stopHookInstalled = args.withStopHook !== false;
1764
+ const previous = readManifest(root) ?? emptyManifest(engines);
1765
+ const plan = planInstallation(
1766
+ {
1767
+ targetDir: root,
1768
+ packageDir: args.packageDir,
1769
+ overridesDir: args.overridesDir,
1770
+ preserveFiles: args.preserveFiles
1771
+ },
1772
+ {
1773
+ previous,
1774
+ sourceVersion: previous.version,
1775
+ engines,
1776
+ hookEngines: stopHookInstalled ? engines : [],
1777
+ mcpServers: args.mcpServers ?? []
1778
+ }
1779
+ );
1780
+ const touchedExisting = [...plan.writes.map((file) => file.path), ...plan.removals].some(
1781
+ (path) => path !== MANIFEST_FILENAME && existsSync9(join11(root, path))
1782
+ );
1783
+ let backupDir;
1784
+ if (plan.writes.length || plan.removals.length) {
1785
+ backupDir = applyUpdateTransaction(root, plan.writes, plan.removals, args.backupDir).backupDir;
1786
+ if (!touchedExisting && !args.backupDir) {
1787
+ rmSync(backupDir, { recursive: true, force: true });
1788
+ backupDir = void 0;
1789
+ }
1790
+ pruneEmptyDirs(root, new Set(plan.removals.map((path) => dirname5(join11(root, path)))));
1791
+ }
1792
+ const sharedWritten = plan.writes.map((file) => file.path).filter((path) => SHARED_CONFIG_PATHS.includes(path) && !plan.owned.includes(path));
1793
+ return {
1794
+ filesWritten: [...plan.owned, ...sharedWritten],
1795
+ staleFilesRemoved: plan.removals,
1796
+ warnings: plan.warnings,
1797
+ stopHookInstalled,
1798
+ engines,
1799
+ ...backupDir ? { backupDir } : {}
1800
+ };
1801
+ }
1698
1802
  export {
1699
1803
  VERSION,
1700
1804
  initCommand,