@vaubaan/harness 1.3.0 → 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.0";
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 = [];
@@ -546,18 +577,26 @@ ${body}` });
546
577
  renderStopHook() {
547
578
  const content = `import { spawnSync } from "node:child_process";
548
579
 
549
- export const HarnessStopHook = async ({ client, directory }) => ({
580
+ // Runs the harness lint and returns the report to inject, or "" when the
581
+ // turn may end.
582
+ const lintReport = (directory, sessionID) => {
583
+ const r = spawnSync(
584
+ "npx",
585
+ ["-y", "${STOP_HOOK_PACKAGE}", "stop-hook", "--engine", "opencode", "--cwd", directory],
586
+ { input: JSON.stringify({ sessionID }), encoding: "utf-8" },
587
+ );
588
+ const report = (r.stderr ?? "").trim();
589
+ return r.status === 2 ? report : "";
590
+ };
591
+
592
+ // OpenCode 1.x entry point, exposed as server() below.
593
+ const HarnessStopHook = async ({ client, directory }) => ({
550
594
  event: async ({ event }) => {
551
595
  if (event.type !== "session.idle") return;
552
596
  const sessionID = event.properties?.sessionID;
553
597
  if (!sessionID) return;
554
- const r = spawnSync(
555
- "npx",
556
- ["-y", "${STOP_HOOK_PACKAGE}", "stop-hook", "--engine", "opencode", "--cwd", directory],
557
- { input: JSON.stringify({ sessionID }), encoding: "utf-8" },
558
- );
559
- const report = (r.stderr ?? "").trim();
560
- if (r.status !== 2 || report === "") return;
598
+ const report = lintReport(directory, sessionID);
599
+ if (report === "") return;
561
600
  // OpenCode defaults to its build agent when a prompt omits agent.
562
601
  // Preserve the last conversation persona, excluding compaction summaries.
563
602
  const history = await client.session.messages({ path: { id: sessionID } }).catch(() => undefined);
@@ -573,6 +612,31 @@ export const HarnessStopHook = async ({ client, directory }) => ({
573
612
  });
574
613
  },
575
614
  });
615
+
616
+ // One default export for both majors: OpenCode 1.x calls server(),
617
+ // OpenCode 2.x calls setup(). A V2 session keeps its agent, so the report
618
+ // is sent without naming the persona.
619
+ export default {
620
+ id: "vaubaan-harness-stop-hook",
621
+ server: HarnessStopHook,
622
+ setup(context) {
623
+ const directory = context.location.directory;
624
+ const controller = new AbortController();
625
+ const listen = async () => {
626
+ for await (const event of context.event.subscribe({ signal: controller.signal })) {
627
+ if (event.type !== "session.idle") continue;
628
+ if (event.location?.directory && event.location.directory !== directory) continue;
629
+ const sessionID = event.data?.sessionID;
630
+ if (!sessionID) continue;
631
+ const report = lintReport(directory, sessionID);
632
+ if (report === "") continue;
633
+ await context.session.prompt({ sessionID, text: report }).catch(() => undefined);
634
+ }
635
+ };
636
+ listen().catch(() => undefined);
637
+ return () => controller.abort();
638
+ },
639
+ };
576
640
  `;
577
641
  return {
578
642
  files: [{ path: `${BASE3}/plugins/harness-stop-hook.ts`, content }],
@@ -591,274 +655,7 @@ function getRenderer(engine) {
591
655
  return RENDERERS[engine];
592
656
  }
593
657
 
594
- // src/render.ts
595
- var RenderError = class extends Error {
596
- constructor(message) {
597
- super(message);
598
- this.name = "RenderError";
599
- }
600
- };
601
- function mergeById(stock, added) {
602
- const result = /* @__PURE__ */ new Map();
603
- for (const item of stock) result.set(item.meta.id, item);
604
- if (added) {
605
- for (const item of added) result.set(item.meta.id, item);
606
- }
607
- return Array.from(result.values());
608
- }
609
- function removeByIds(items, disabled) {
610
- if (disabled.length === 0) return [...items];
611
- const disabledSet = new Set(disabled);
612
- return items.filter((item) => !disabledSet.has(item.meta.id));
613
- }
614
- function applyOverrides(harness, overrides) {
615
- const disabled = overrides?.disable ?? [];
616
- return {
617
- agents: removeByIds(mergeById(harness.agents, overrides?.add?.agents), disabled),
618
- skills: removeByIds(mergeById(harness.skills, overrides?.add?.skills), disabled),
619
- cmds: removeByIds(mergeById(harness.cmds, overrides?.add?.cmds), disabled)
620
- };
621
- }
622
- var CLAUDE_MD_STUB = "@AGENTS.md\n";
623
- function opencodeLast(engines) {
624
- return [...engines].sort((a, b) => Number(a === "opencode") - Number(b === "opencode"));
625
- }
626
- function render(input) {
627
- const { engines } = input;
628
- if (engines.length === 0) {
629
- throw new RenderError("at least one engine must be selected");
630
- }
631
- const base = input.projectSkills?.length ? { ...input.harness, skills: mergeById(input.harness.skills, input.projectSkills) } : input.harness;
632
- const harness = applyOverrides(base, input.overrides);
633
- if (harness.agents.length === 0) {
634
- throw new RenderError("No agents found in harness");
635
- }
636
- const files = [];
637
- const warnings = [];
638
- const exposedSkills = /* @__PURE__ */ new Map();
639
- const exposedCmds = /* @__PURE__ */ new Map();
640
- for (const engine of opencodeLast(engines)) {
641
- const renderer = getRenderer(engine);
642
- const renderedSkills = [];
643
- for (const skill of harness.skills) {
644
- const result = renderer.renderSkill(skill);
645
- warnings.push(...result.warnings);
646
- if (result.files.length > 0) {
647
- const alreadyVisible = engine === "opencode" && exposedSkills.has(skill.meta.id);
648
- if (!alreadyVisible) files.push(...result.files);
649
- renderedSkills.push(skill);
650
- exposedSkills.set(skill.meta.id, skill);
651
- }
652
- }
653
- const renderedSkillIds = new Set(renderedSkills.map((s) => s.meta.id));
654
- const renderedCmds = [];
655
- for (const cmd of harness.cmds) {
656
- if (!renderedSkillIds.has(cmd.meta.invokes_skill)) continue;
657
- const result = renderer.renderCmd(cmd);
658
- warnings.push(...result.warnings);
659
- if (result.files.length > 0) {
660
- files.push(...result.files);
661
- renderedCmds.push(cmd);
662
- exposedCmds.set(cmd.meta.id, cmd);
663
- }
664
- }
665
- const agentsResult = renderer.renderAgents(harness.agents);
666
- warnings.push(...agentsResult.warnings);
667
- files.push(...agentsResult.files);
668
- if (input.mcpServers && input.mcpServers.length > 0) {
669
- const specs = input.mcpServers.map((id) => MCP_SERVERS[id]);
670
- const mcpResult = renderer.renderMcp(specs);
671
- warnings.push(...mcpResult.warnings);
672
- files.push(...mcpResult.files);
673
- }
674
- if (input.withStopHook !== false) {
675
- const hookResult = renderer.renderStopHook();
676
- warnings.push(...hookResult.warnings);
677
- files.push(...hookResult.files);
678
- }
679
- }
680
- const hasProjectSkills = (input.projectSkills?.length ?? 0) > 0;
681
- const umbrellaContent = buildUmbrellaBody(harness.agents, {
682
- availableSkills: Array.from(exposedSkills.values()),
683
- availableCommands: Array.from(exposedCmds.values()),
684
- hasProjectSkills,
685
- hasQualityDoc: input.hasQualityDoc === true
686
- });
687
- files.push({ path: "AGENTS.md", content: umbrellaContent });
688
- if (engines.includes("claude-code")) {
689
- files.push({ path: "CLAUDE.md", content: CLAUDE_MD_STUB });
690
- }
691
- return { files, warnings };
692
- }
693
-
694
- // src/cli/managed-paths.ts
695
- import { lstatSync, realpathSync, statSync } from "fs";
696
- import { basename, dirname, isAbsolute, join as join2, resolve, win32 } from "path";
697
- var MANAGED_FILES = /* @__PURE__ */ new Set([
698
- "AGENTS.md",
699
- "CLAUDE.md",
700
- ".mcp.json",
701
- ".harness-manifest.json",
702
- ".claude/settings.json",
703
- ".codex/config.toml",
704
- ".codex/hooks.json",
705
- ".opencode/opencode.json",
706
- ".opencode/mcp.json"
707
- ]);
708
- var MANAGED_DIRECTORIES = [
709
- ".claude/agents/",
710
- ".claude/commands/",
711
- ".claude/skills/",
712
- ".codex/agents/",
713
- ".codex/commands/",
714
- ".codex/skills/",
715
- ".agents/skills/",
716
- ".opencode/agents/",
717
- ".opencode/commands/",
718
- ".opencode/skills/",
719
- ".opencode/plugins/"
720
- ];
721
- function canonicalTargetRoot(root) {
722
- let ancestor = resolve(root);
723
- const missing = [];
724
- while (!lstatSync(ancestor, { throwIfNoEntry: false })) {
725
- missing.unshift(basename(ancestor));
726
- ancestor = dirname(ancestor);
727
- }
728
- const canonical = realpathSync(ancestor);
729
- if (!statSync(canonical).isDirectory()) {
730
- throw new Error(`harness target is not a directory: ${root}`);
731
- }
732
- return join2(canonical, ...missing);
733
- }
734
- function assertManagedLocation(path) {
735
- const invalidSegment = path.split("/").some((part) => part === "" || part === "." || part === "..");
736
- if (isAbsolute(path) || win32.isAbsolute(path) || path.includes("\\") || path.includes("\0") || invalidSegment) {
737
- throw new Error(`unsafe harness path: ${path}`);
738
- }
739
- if (!MANAGED_FILES.has(path) && !MANAGED_DIRECTORIES.some((prefix) => path.startsWith(prefix))) {
740
- throw new Error(`path is outside harness-managed locations: ${path}`);
741
- }
742
- }
743
- function assertNoSymbolicComponents(root, path) {
744
- const parts = path.split("/");
745
- let current = root;
746
- for (const [index, part] of parts.entries()) {
747
- current = join2(current, part);
748
- const info = lstatSync(current, { throwIfNoEntry: false });
749
- if (!info) return;
750
- if (info.isSymbolicLink()) {
751
- throw new Error(`symbolic link in harness path: ${path}`);
752
- }
753
- const isLeaf = index === parts.length - 1;
754
- if (isLeaf ? !info.isFile() : !info.isDirectory()) {
755
- throw new Error(`unexpected file type in harness path: ${path}`);
756
- }
757
- }
758
- }
759
- function assertManagedPaths(root, files) {
760
- for (const path of files) assertManagedLocation(path);
761
- const canonicalRoot2 = canonicalTargetRoot(root);
762
- for (const path of files) assertNoSymbolicComponents(canonicalRoot2, path);
763
- }
764
-
765
- // src/cli/manifest.ts
766
- import { createHash } from "crypto";
767
- import { chmodSync, existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync } from "fs";
768
- import { join as join5 } from "path";
769
- import { z as z5 } from "zod";
770
-
771
- // src/cli/opencode-config.ts
772
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
773
- import { join as join3 } from "path";
774
- import { isDeepStrictEqual } from "util";
775
- import { z as z3 } from "zod";
776
- var OPENCODE_CONFIG_PATH = ".opencode/opencode.json";
777
- var OPENCODE_SCHEMA = "https://opencode.ai/config.json";
778
- var ObjectSchema = z3.record(z3.unknown());
779
- var OpenCodeConfigOwnershipSchema = z3.object({
780
- createdFile: z3.boolean(),
781
- addedMcp: z3.boolean(),
782
- addedSchema: z3.boolean(),
783
- entries: z3.record(ObjectSchema)
784
- });
785
- function parseConfig(content) {
786
- try {
787
- const config = ObjectSchema.parse(JSON.parse(content));
788
- if (Object.hasOwn(config, "mcp")) ObjectSchema.parse(config.mcp);
789
- return config;
790
- } catch {
791
- throw new Error(`invalid ${OPENCODE_CONFIG_PATH}: expected a JSON object with an MCP object`);
792
- }
793
- }
794
- function removeOwnedEntries(config, ownership, warnings) {
795
- if (Object.hasOwn(config, "mcp")) {
796
- const mcp = ObjectSchema.parse(config.mcp);
797
- for (const [id, installed] of Object.entries(ownership.entries)) {
798
- if (!Object.hasOwn(mcp, id)) continue;
799
- if (isDeepStrictEqual(mcp[id], installed)) delete mcp[id];
800
- else warnings.push(`preserved modified OpenCode MCP server: ${id}`);
801
- }
802
- config.mcp = mcp;
803
- if (ownership.addedMcp && Object.keys(mcp).length === 0) Reflect.deleteProperty(config, "mcp");
804
- }
805
- if (ownership.addedSchema && config.$schema === OPENCODE_SCHEMA)
806
- Reflect.deleteProperty(config, "$schema");
807
- }
808
- function addMissingEntries(config, desired, createdFile, warnings) {
809
- const requested = ObjectSchema.parse(parseConfig(desired.content).mcp);
810
- const mcp = ObjectSchema.parse(config.mcp ?? {});
811
- const entries = {};
812
- for (const [id, entry] of Object.entries(requested)) {
813
- if (Object.hasOwn(mcp, id)) {
814
- if (!isDeepStrictEqual(mcp[id], entry)) {
815
- warnings.push(`preserved existing OpenCode MCP server instead of replacing it: ${id}`);
816
- }
817
- continue;
818
- }
819
- entries[id] = ObjectSchema.parse(entry);
820
- mcp[id] = entry;
821
- }
822
- if (Object.keys(entries).length === 0) return void 0;
823
- const ownership = {
824
- createdFile,
825
- addedMcp: !Object.hasOwn(config, "mcp"),
826
- addedSchema: !Object.hasOwn(config, "$schema"),
827
- entries
828
- };
829
- config.mcp = mcp;
830
- if (ownership.addedSchema) config.$schema = OPENCODE_SCHEMA;
831
- return ownership;
832
- }
833
- function planOpenCodeConfig(root, desired, previous) {
834
- const warnings = [];
835
- if (!desired && !previous) return { remove: false, warnings };
836
- const path = join3(root, OPENCODE_CONFIG_PATH);
837
- const existed = existsSync2(path);
838
- if (!existed && !desired) return { remove: false, warnings };
839
- const original = existed ? readFileSync3(path, "utf8") : void 0;
840
- const config = original === void 0 ? {} : parseConfig(original);
841
- const before = structuredClone(config);
842
- if (previous) removeOwnedEntries(config, previous, warnings);
843
- const createdFile = !existed || previous?.createdFile === true;
844
- const ownership = desired ? addMissingEntries(config, desired, createdFile, warnings) : void 0;
845
- if (!ownership && createdFile && Object.keys(config).length === 0) {
846
- return { remove: existed, warnings };
847
- }
848
- return {
849
- remove: false,
850
- ownership,
851
- warnings,
852
- ...!existed || !isDeepStrictEqual(before, config) ? { write: { path: OPENCODE_CONFIG_PATH, content: `${JSON.stringify(config, null, 2)}
853
- ` } } : {}
854
- };
855
- }
856
-
857
658
  // src/cli/update-config.ts
858
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
859
- import { join as join4 } from "path";
860
- import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
861
- import { z as z4 } from "zod";
862
659
  var LEGACY_OPENCODE_CONFIG = ".opencode/mcp.json";
863
660
  var SHARED_CONFIG_PATHS = [
864
661
  ".mcp.json",
@@ -868,15 +665,15 @@ var SHARED_CONFIG_PATHS = [
868
665
  ".claude/settings.json",
869
666
  ".codex/hooks.json"
870
667
  ];
871
- var ObjectSchema2 = z4.record(z4.unknown());
872
- var HookEntrySchema = z4.object({ group: ObjectSchema2, hook: ObjectSchema2 });
873
- var HookConfigOwnershipSchema = z4.object({
874
- createdFile: z4.boolean(),
875
- addedHooks: z4.boolean(),
876
- addedStop: z4.boolean(),
877
- 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)
878
675
  });
879
- var HookConfigsOwnershipSchema = z4.object({
676
+ var HookConfigsOwnershipSchema = z2.object({
880
677
  ".claude/settings.json": HookConfigOwnershipSchema.optional(),
881
678
  ".codex/hooks.json": HookConfigOwnershipSchema.optional()
882
679
  }).strict();
@@ -1000,8 +797,8 @@ function planHookConfigCleanup(root, ownership) {
1000
797
  assertManagedPaths(root, Object.keys(validated));
1001
798
  for (const path of Object.keys(HOOK_ENGINES)) {
1002
799
  const owned = validated[path];
1003
- if (!owned || !existsSync3(join4(root, path))) continue;
1004
- 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);
1005
802
  const config = structuredClone(before);
1006
803
  removeOwnedHooks(config, owned, path, plan.warnings);
1007
804
  recordMutation(plan, path, before, config, owned.createdFile);
@@ -1010,12 +807,12 @@ function planHookConfigCleanup(root, ownership) {
1010
807
  }
1011
808
  function planActiveHook(root, path, previous, claimExisting, plan) {
1012
809
  assertManagedPaths(root, [path]);
1013
- const existed = existsSync3(join4(root, path));
810
+ const existed = existsSync2(join3(root, path));
1014
811
  if (!existed && previous?.entries.length) {
1015
812
  plan.warnings.push(`preserved removed harness Stop hook configuration: ${path}`);
1016
813
  return previous;
1017
814
  }
1018
- 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;
1019
816
  const config = structuredClone(before ?? {});
1020
817
  const groups = stopHooks(config, path);
1021
818
  const hooks = asObject(config.hooks) ?? {};
@@ -1027,10 +824,11 @@ function planActiveHook(root, path, previous, claimExisting, plan) {
1027
824
  plan.warnings,
1028
825
  path
1029
826
  );
827
+ const legacyWholeFile = !previous && claimExisting && existed;
1030
828
  const ownership = {
1031
- createdFile: !existed || previous?.createdFile === true,
1032
- addedHooks: previous?.addedHooks ?? !Object.hasOwn(config, "hooks"),
1033
- 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")),
1034
832
  entries: normalized.entries
1035
833
  };
1036
834
  if (!isDeepStrictEqual2(groups, normalized.groups)) {
@@ -1041,10 +839,10 @@ function planActiveHook(root, path, previous, claimExisting, plan) {
1041
839
  }
1042
840
  function planLegacyOpenCode(root, previous, plan) {
1043
841
  if (!previous.files.includes(LEGACY_OPENCODE_CONFIG)) return;
1044
- const legacyPath = join4(root, LEGACY_OPENCODE_CONFIG);
1045
- if (!existsSync3(legacyPath)) return;
842
+ const legacyPath = join3(root, LEGACY_OPENCODE_CONFIG);
843
+ if (!existsSync2(legacyPath)) return;
1046
844
  assertManagedPaths(root, [LEGACY_OPENCODE_CONFIG, OPENCODE_CONFIG_PATH]);
1047
- const legacy = parseObject(readFileSync4(legacyPath, "utf8"), LEGACY_OPENCODE_CONFIG);
845
+ const legacy = parseObject(readFileSync2(legacyPath, "utf8"), LEGACY_OPENCODE_CONFIG);
1048
846
  if (!asObject(legacy.mcp)) {
1049
847
  throw new Error(
1050
848
  `invalid shared configuration ${LEGACY_OPENCODE_CONFIG}: expected an MCP object`
@@ -1108,45 +906,24 @@ function planUpdateConfigs(root, previous, hookEngines) {
1108
906
  // src/cli/manifest.ts
1109
907
  var MANIFEST_FILENAME = ".harness-manifest.json";
1110
908
  var MANIFEST_VERSION = "6";
1111
- var ManifestSchema = z5.object({
1112
- version: z5.enum(["4", "5", MANIFEST_VERSION]),
1113
- engines: z5.array(z5.string()),
1114
- renderedAt: z5.string(),
1115
- files: z5.array(z5.string()),
1116
- stopHookInstalled: z5.boolean().optional(),
1117
- 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(),
1118
916
  opencodeConfig: OpenCodeConfigOwnershipSchema.optional(),
917
+ /** Entries the harness added to `.mcp.json` (Claude Code). */
918
+ mcpConfig: OpenCodeConfigOwnershipSchema.optional(),
1119
919
  hookConfigs: HookConfigsOwnershipSchema.optional(),
1120
- harnessVersion: z5.string().optional(),
1121
- overridesDir: z5.string().optional(),
1122
- fileHashes: z5.record(z5.string().regex(/^[a-f0-9]{64}$/)).optional(),
1123
- 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()
1124
924
  });
1125
925
  function manifestPath(targetDir) {
1126
- return join5(targetDir, MANIFEST_FILENAME);
1127
- }
1128
- function writeManifest(targetDir, engines, files, extras = {}) {
1129
- assertManagedPaths(targetDir, [MANIFEST_FILENAME]);
1130
- const manifest = {
1131
- version: MANIFEST_VERSION,
1132
- harnessVersion: VERSION,
1133
- engines: [...engines].sort(),
1134
- renderedAt: (/* @__PURE__ */ new Date()).toISOString(),
1135
- files: [...files].sort(),
1136
- fileHashes: Object.fromEntries(
1137
- files.filter((file) => existsSync4(join5(targetDir, file))).sort().map((file) => [file, hashContent(readFileSync5(join5(targetDir, file)))])
1138
- ),
1139
- ...extras.overridesDir ? { overridesDir: extras.overridesDir } : {},
1140
- ...extras.preservedFiles ? { preservedFiles: extras.preservedFiles } : {},
1141
- ...extras.hookConfigs ? { hookConfigs: extras.hookConfigs } : {},
1142
- ...extras.opencodeConfig ? { opencodeConfig: extras.opencodeConfig } : {},
1143
- ...extras.stopHookInstalled !== void 0 ? { stopHookInstalled: extras.stopHookInstalled } : {},
1144
- ...extras.stopHookEngines !== void 0 ? { stopHookEngines: [...extras.stopHookEngines].sort() } : {}
1145
- };
1146
- const path = manifestPath(targetDir);
1147
- if (existsSync4(path)) chmodSync(path, 384);
1148
- writeFileSync(path, `${JSON.stringify(manifest, null, 2)}
1149
- `, { mode: 384 });
926
+ return join4(targetDir, MANIFEST_FILENAME);
1150
927
  }
1151
928
  function hashContent(content) {
1152
929
  return createHash("sha256").update(content).digest("hex");
@@ -1154,66 +931,291 @@ function hashContent(content) {
1154
931
  function readManifest(targetDir) {
1155
932
  assertManagedPaths(targetDir, [MANIFEST_FILENAME]);
1156
933
  const path = manifestPath(targetDir);
1157
- if (!existsSync4(path)) return null;
1158
- const raw = JSON.parse(readFileSync5(path, "utf8"));
934
+ if (!existsSync3(path)) return null;
935
+ const raw = JSON.parse(readFileSync3(path, "utf8"));
1159
936
  return ManifestSchema.parse(raw);
1160
937
  }
1161
938
 
1162
- // src/cli/uninstall.ts
1163
- import { existsSync as existsSync5, readdirSync, rmdirSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
1164
- import { dirname as dirname2, join as join6, relative, resolve as resolve2 } from "path";
1165
- function removeRenderedFiles(root, files) {
1166
- assertManagedPaths(root, files);
1167
- const filesRemoved = [];
1168
- const missing = [];
1169
- const dirsToConsider = /* @__PURE__ */ new Set();
1170
- for (const rel of files) {
1171
- const full = join6(root, rel);
1172
- if (existsSync5(full)) {
1173
- unlinkSync(full);
1174
- filesRemoved.push(rel);
1175
- dirsToConsider.add(dirname2(full));
1176
- } else {
1177
- 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 {
1178
1078
  }
1179
1079
  }
1180
- return { filesRemoved, dirsPruned: pruneEmptyDirs(root, dirsToConsider), missing };
1181
- }
1182
- function pruneEmptyDirs(root, leaves) {
1183
- const candidates = /* @__PURE__ */ new Set();
1184
- for (const leaf of leaves) {
1185
- let cur = leaf;
1186
- while (cur !== root && cur.startsWith(`${root}/`)) {
1187
- candidates.add(cur);
1188
- cur = dirname2(cur);
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(", ")})`);
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");
1143
+ }
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);
1189
1186
  }
1190
1187
  }
1191
- const pruned = [];
1192
- const sorted = [...candidates].sort((a, b) => b.length - a.length);
1193
- for (const dir of sorted) {
1194
- if (!existsSync5(dir)) continue;
1195
- if (readdirSync(dir).length > 0) continue;
1196
- rmdirSync(dir);
1197
- 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 });
1198
1198
  }
1199
- return pruned;
1199
+ return { files, warnings };
1200
1200
  }
1201
1201
 
1202
- // src/cli/init.ts
1202
+ // src/cli/plan-render.ts
1203
1203
  var QUALITY_DOC_PATH = "docs/quality/code-quality.md";
1204
1204
  function resolveEngineList(requested) {
1205
- if (!requested || requested.length === 0) return [...ENGINES];
1206
- 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) => {
1207
1208
  if (!ENGINES.includes(e)) {
1208
1209
  throw new Error(`unknown engine: ${e} (expected one of ${ENGINES.join(", ")})`);
1209
1210
  }
1210
1211
  return e;
1211
1212
  });
1213
+ return [...new Set(engines)];
1212
1214
  }
1213
1215
  function defaultPackageDir() {
1214
- const here = dirname3(fileURLToPath(import.meta.url));
1215
- if (existsSync6(join7(here, "agents"))) return here;
1216
- return resolve3(here, "..");
1216
+ const here = dirname2(fileURLToPath(import.meta.url));
1217
+ if (existsSync5(join6(here, "agents"))) return here;
1218
+ return resolve2(here, "..");
1217
1219
  }
1218
1220
  function planInit(args) {
1219
1221
  const engines = resolveEngineList(args.engines);
@@ -1221,7 +1223,7 @@ function planInit(args) {
1221
1223
  const harness = loadHarness(packageDir);
1222
1224
  const overrides = args.overridesDir ? loadOverrides(args.overridesDir) : void 0;
1223
1225
  const projectSkills = loadProjectSkills(args.targetDir);
1224
- const hasQualityDoc = existsSync6(join7(args.targetDir, QUALITY_DOC_PATH));
1226
+ const hasQualityDoc = existsSync5(join6(args.targetDir, QUALITY_DOC_PATH));
1225
1227
  const mcpServers = args.mcpServers ? resolveMcpIds(args.mcpServers) : void 0;
1226
1228
  const stopHookInstalled = args.withStopHook !== false;
1227
1229
  const output = render({
@@ -1235,107 +1237,35 @@ function planInit(args) {
1235
1237
  });
1236
1238
  return { engines, output, stopHookInstalled };
1237
1239
  }
1238
- function writeFilesToTarget(files, targetDir) {
1239
- assertManagedPaths(
1240
- targetDir,
1241
- files.map((file) => file.path)
1242
- );
1243
- const written = [];
1244
- for (const f of files) {
1245
- const full = join7(targetDir, f.path);
1246
- mkdirSync(dirname3(full), { recursive: true });
1247
- writeFileSync3(full, f.content);
1248
- written.push(f.path);
1249
- }
1250
- return written;
1251
- }
1252
- function initCommand(args) {
1253
- const { engines, output, stopHookInstalled } = planInit(args);
1254
- const previous = readManifest(args.targetDir);
1255
- assertManagedPaths(args.targetDir, [
1256
- MANIFEST_FILENAME,
1257
- ...previous?.files ?? [],
1258
- ...output.files.map((file) => file.path),
1259
- ...previous?.opencodeConfig ? [OPENCODE_CONFIG_PATH] : []
1260
- ]);
1261
- const desiredConfig = output.files.find((file) => file.path === OPENCODE_CONFIG_PATH);
1262
- const config = planOpenCodeConfig(args.targetDir, desiredConfig, previous?.opencodeConfig);
1263
- const next = new Set(output.files.map((f) => f.path));
1264
- const stale = (previous?.files ?? []).filter((f) => !next.has(f) && f !== OPENCODE_CONFIG_PATH);
1265
- if (config.remove) stale.push(OPENCODE_CONFIG_PATH);
1266
- const staleFilesRemoved = removeRenderedFiles(resolve3(args.targetDir), stale).filesRemoved;
1267
- const ownedFiles = output.files.filter((file) => file.path !== OPENCODE_CONFIG_PATH);
1268
- const filesWritten = writeFilesToTarget(ownedFiles, args.targetDir);
1269
- if (config.write) writeFilesToTarget([config.write], args.targetDir);
1270
- writeManifest(args.targetDir, engines, filesWritten, {
1271
- stopHookInstalled,
1272
- stopHookEngines: stopHookInstalled ? engines : void 0,
1273
- opencodeConfig: config.ownership,
1274
- overridesDir: args.overridesDir ? resolve3(args.overridesDir) === resolve3(args.targetDir) ? "." : resolve3(args.overridesDir) : void 0
1275
- });
1276
- if (config.write) filesWritten.push(OPENCODE_CONFIG_PATH);
1277
- const warnings = [...output.warnings, ...config.warnings];
1278
- if (previous?.files.includes(OPENCODE_CONFIG_PATH) && !previous.opencodeConfig) {
1279
- warnings.push(`preserved legacy shared configuration: ${OPENCODE_CONFIG_PATH}`);
1280
- }
1281
- return { filesWritten, staleFilesRemoved, warnings, stopHookInstalled };
1282
- }
1283
-
1284
- // src/cli/update.ts
1285
- import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
1286
- import { join as join10, resolve as resolve5 } from "path";
1287
- import { isDeepStrictEqual as isDeepStrictEqual3 } from "util";
1288
1240
 
1289
- // src/cli/update-manifest.ts
1290
- import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
1291
- import { join as join8 } from "path";
1292
- import { z as z6 } from "zod";
1293
- var EngineSchema = z6.enum(ENGINES);
1294
- var LegacySchema = z6.object({
1295
- version: z6.enum(["1", "2", "3"]),
1296
- engine: EngineSchema.optional(),
1297
- engines: z6.array(EngineSchema).nonempty().optional(),
1298
- renderedAt: z6.string(),
1299
- files: z6.array(z6.string())
1300
- });
1301
- var HOOK_PATHS = {
1302
- "claude-code": [".claude/settings.json"],
1303
- "codex-cli": [".codex/hooks.json"],
1304
- opencode: [
1305
- ".opencode/plugins/harness-stop-hook/index.ts",
1306
- ".opencode/plugins/harness-stop-hook.ts"
1307
- ]
1308
- };
1309
- function readUpdateInstallation(root) {
1310
- assertManagedPaths(root, [MANIFEST_FILENAME]);
1311
- if (!existsSync7(join8(root, MANIFEST_FILENAME))) {
1312
- throw new Error("no harness manifest found; use init for a new installation");
1313
- }
1314
- const raw = JSON.parse(readFileSync6(join8(root, MANIFEST_FILENAME), "utf8"));
1315
- const sourceVersion = String(raw?.version);
1316
- let manifest;
1317
- if (["1", "2", "3"].includes(sourceVersion)) {
1318
- const legacy = LegacySchema.parse(raw);
1319
- const engines2 = legacy.version === "1" ? [EngineSchema.parse(legacy.engine)] : legacy.engines;
1320
- manifest = {
1321
- version: "4",
1322
- engines: z6.array(EngineSchema).nonempty().parse(engines2),
1323
- files: legacy.files,
1324
- renderedAt: legacy.renderedAt
1325
- };
1326
- } else {
1327
- const current = readManifest(root);
1328
- if (!current) throw new Error("harness manifest disappeared during planning");
1329
- 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
+ }
1330
1259
  }
1331
- const engines = [...new Set(z6.array(EngineSchema).nonempty().parse(manifest.engines))];
1332
- const hookEngines = manifest.stopHookInstalled === false ? [] : manifest.stopHookEngines !== void 0 ? z6.array(EngineSchema).parse(manifest.stopHookEngines) : manifest.stopHookInstalled === true ? engines : engines.filter(
1333
- (engine) => HOOK_PATHS[engine].some((path) => manifest.files.includes(path))
1334
- );
1335
- if (hookEngines.some((engine) => !engines.includes(engine))) {
1336
- 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));
1337
1267
  }
1338
- return { manifest, sourceVersion, engines, hookEngines: [...new Set(hookEngines)] };
1268
+ return pruned;
1339
1269
  }
1340
1270
 
1341
1271
  // src/cli/update-transaction.ts
@@ -1345,17 +1275,17 @@ import {
1345
1275
  closeSync,
1346
1276
  fsyncSync,
1347
1277
  lstatSync as lstatSync2,
1348
- mkdirSync as mkdirSync2,
1278
+ mkdirSync,
1349
1279
  openSync,
1350
1280
  readFileSync as readFileSync7,
1351
1281
  readdirSync as readdirSync2,
1352
1282
  realpathSync as realpathSync2,
1353
1283
  rmdirSync as rmdirSync2,
1354
1284
  unlinkSync as unlinkSync2,
1355
- writeFileSync as writeFileSync4
1285
+ writeFileSync as writeFileSync3
1356
1286
  } from "fs";
1357
1287
  import { homedir } from "os";
1358
- 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";
1359
1289
  var MANIFEST_PATH = ".harness-manifest.json";
1360
1290
  var PRIVATE_FILES = /* @__PURE__ */ new Set([
1361
1291
  MANIFEST_PATH,
@@ -1373,7 +1303,7 @@ function canonicalRoot(root) {
1373
1303
  missing.unshift(basename2(ancestor));
1374
1304
  ancestor = dirname4(ancestor);
1375
1305
  }
1376
- return join9(realpathSync2(ancestor), ...missing);
1306
+ return join8(realpathSync2(ancestor), ...missing);
1377
1307
  }
1378
1308
  function contains(parent, child) {
1379
1309
  const path = relative2(parent, child);
@@ -1405,7 +1335,7 @@ function createDirectories(path, created, mode = 511) {
1405
1335
  missing.unshift(current);
1406
1336
  }
1407
1337
  for (const directory of missing) {
1408
- mkdirSync2(directory, { mode });
1338
+ mkdirSync(directory, { mode });
1409
1339
  created.push(directory);
1410
1340
  }
1411
1341
  }
@@ -1413,7 +1343,7 @@ function writeBackupFile(path, content) {
1413
1343
  createDirectories(dirname4(path), [], 448);
1414
1344
  const fd = openSync(path, "wx", 384);
1415
1345
  try {
1416
- writeFileSync4(fd, content);
1346
+ writeFileSync3(fd, content);
1417
1347
  fsyncSync(fd);
1418
1348
  } finally {
1419
1349
  closeSync(fd);
@@ -1422,16 +1352,16 @@ function writeBackupFile(path, content) {
1422
1352
  function saveSnapshot(root, paths, backup) {
1423
1353
  const entries = [];
1424
1354
  createDirectories(dirname4(backup), [], 448);
1425
- mkdirSync2(backup, { mode: 448 });
1355
+ mkdirSync(backup, { mode: 448 });
1426
1356
  for (const path of paths) {
1427
- const full = join9(root, path);
1357
+ const full = join8(root, path);
1428
1358
  const info = lstatSync2(full, { throwIfNoEntry: false });
1429
1359
  if (!info) {
1430
1360
  entries.push({ path, exists: false });
1431
1361
  continue;
1432
1362
  }
1433
1363
  const content = readFileSync7(full);
1434
- writeBackupFile(join9(backup, "files", path), content);
1364
+ writeBackupFile(join8(backup, "files", path), content);
1435
1365
  entries.push({
1436
1366
  path,
1437
1367
  exists: true,
@@ -1440,7 +1370,7 @@ function saveSnapshot(root, paths, backup) {
1440
1370
  });
1441
1371
  }
1442
1372
  writeBackupFile(
1443
- join9(backup, "snapshot.json"),
1373
+ join8(backup, "snapshot.json"),
1444
1374
  `${JSON.stringify({ version: 1, root, createdAt: (/* @__PURE__ */ new Date()).toISOString(), entries }, null, 2)}
1445
1375
  `
1446
1376
  );
@@ -1455,16 +1385,16 @@ function rollback(root, backup, entries, touched, createdDirectories) {
1455
1385
  try {
1456
1386
  assertManagedPaths(root, [path]);
1457
1387
  const entry = entries.get(path);
1458
- const full = join9(root, path);
1388
+ const full = join8(root, path);
1459
1389
  const current = lstatSync2(full, { throwIfNoEntry: false });
1460
1390
  if (!entry?.exists) {
1461
1391
  if (current) unlinkSync2(full);
1462
1392
  continue;
1463
1393
  }
1464
- const original = readFileSync7(join9(backup, "files", path));
1394
+ const original = readFileSync7(join8(backup, "files", path));
1465
1395
  if (!current || !readFileSync7(full).equals(original)) {
1466
1396
  if (current) unlinkSync2(full);
1467
- writeFileSync4(full, original, { mode: 384 });
1397
+ writeFileSync3(full, original, { mode: 384 });
1468
1398
  }
1469
1399
  chmodSync2(full, entry.mode);
1470
1400
  } catch (error) {
@@ -1485,7 +1415,7 @@ function applyUpdateTransaction(root, writes, removals, backupDir) {
1485
1415
  assertManagedPaths(root, paths);
1486
1416
  const target = canonicalRoot(root);
1487
1417
  const backup = resolve4(
1488
- backupDir ?? join9(homedir(), ".local", "state", "vaubaan-harness", "backups", randomUUID())
1418
+ backupDir ?? join8(homedir(), ".local", "state", "vaubaan-harness", "backups", randomUUID())
1489
1419
  );
1490
1420
  assertBackupLocation(target, backup);
1491
1421
  let snapshot;
@@ -1503,18 +1433,18 @@ function applyUpdateTransaction(root, writes, removals, backupDir) {
1503
1433
  try {
1504
1434
  assertManagedPaths(target, paths);
1505
1435
  for (const path of removals) {
1506
- if (!lstatSync2(join9(target, path), { throwIfNoEntry: false })) continue;
1436
+ if (!lstatSync2(join8(target, path), { throwIfNoEntry: false })) continue;
1507
1437
  touched.add(path);
1508
- unlinkSync2(join9(target, path));
1438
+ unlinkSync2(join8(target, path));
1509
1439
  }
1510
1440
  for (const file of writes) {
1511
- const full = join9(target, file.path);
1441
+ const full = join8(target, file.path);
1512
1442
  createDirectories(dirname4(full), createdDirectories);
1513
1443
  touched.add(file.path);
1514
1444
  const entry = entries.get(file.path);
1515
1445
  const mode = file.path === MANIFEST_PATH ? 384 : entry?.exists ? entry.mode : PRIVATE_FILES.has(file.path) ? 384 : 420;
1516
1446
  if (entry?.exists && file.path === MANIFEST_PATH) chmodSync2(full, mode);
1517
- writeFileSync4(full, file.content, { mode });
1447
+ writeFileSync3(full, file.content, { mode });
1518
1448
  if (entry?.exists) chmodSync2(full, mode);
1519
1449
  }
1520
1450
  } catch (error) {
@@ -1528,29 +1458,159 @@ function applyUpdateTransaction(root, writes, removals, backupDir) {
1528
1458
  }
1529
1459
 
1530
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";
1531
1519
  function currentContent(root, path) {
1532
- 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);
1533
1527
  }
1534
1528
  function checkArtifactConflicts(root, previous, writes, removals) {
1535
1529
  const desired = new Map(writes.map((file) => [file.path, file.content]));
1530
+ const conflicts = [];
1536
1531
  for (const path of [...desired.keys(), ...removals]) {
1537
1532
  if (SHARED_CONFIG_PATHS.includes(path)) continue;
1538
1533
  const content = currentContent(root, path);
1539
- if (content === void 0) continue;
1534
+ if (content === void 0 || content === desired.get(path)) continue;
1540
1535
  if (!previous.files.includes(path)) {
1541
- throw new Error(
1542
- `existing file outside harness manifest: ${path}; use overrides or --preserve`
1543
- );
1536
+ conflicts.push(`existing file outside harness manifest: ${path}`);
1537
+ continue;
1544
1538
  }
1545
1539
  const hash = previous.fileHashes?.[path];
1546
- if (hash && hash !== hashContent(content) && content !== desired.get(path)) {
1547
- 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}`);
1548
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);
1549
1608
  }
1609
+ return plan;
1550
1610
  }
1551
- function planUpdate(args) {
1611
+ function planInstallation(args, settings) {
1552
1612
  const root = resolve5(args.targetDir);
1553
- const { manifest: previous, sourceVersion, engines, hookEngines } = readUpdateInstallation(root);
1613
+ const { previous, engines, hookEngines } = settings;
1554
1614
  const preserved = [
1555
1615
  .../* @__PURE__ */ new Set([...previous.preservedFiles ?? [], ...args.preserveFiles ?? []])
1556
1616
  ].sort();
@@ -1571,7 +1631,7 @@ function planUpdate(args) {
1571
1631
  const disablePath = join10(overridesDir, ".harness-overrides", "disable.yaml");
1572
1632
  if (existsSync8(disablePath)) {
1573
1633
  try {
1574
- DisableYamlSchema.parse(parseYAML(readFileSync8(disablePath, "utf8")));
1634
+ DisableYamlSchema.parse(parseYAML(readFileSync9(disablePath, "utf8")));
1575
1635
  } catch {
1576
1636
  throw new Error(`invalid override selection: ${disablePath}`);
1577
1637
  }
@@ -1581,21 +1641,27 @@ function planUpdate(args) {
1581
1641
  packageDir: args.packageDir,
1582
1642
  engines,
1583
1643
  overridesDir: hasOverrides ? overridesDir : void 0,
1644
+ mcpServers: settings.mcpServers,
1584
1645
  withStopHook: false
1585
1646
  });
1647
+ const generated = rendered.output.files.filter(
1648
+ (file) => !SHARED_CONFIG_PATHS.includes(file.path)
1649
+ );
1586
1650
  const config = planUpdateConfigs(root, previous, hookEngines);
1587
- 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(
1588
1653
  (file) => !preserved.includes(file.path)
1589
1654
  );
1590
1655
  const owned = [
1591
- .../* @__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])
1592
1657
  ].filter((file) => !preserved.includes(file)).sort();
1593
1658
  const removals = [
1594
1659
  .../* @__PURE__ */ new Set([
1595
1660
  ...previous.files.filter(
1596
1661
  (file) => !owned.includes(file) && !SHARED_CONFIG_PATHS.includes(file)
1597
1662
  ),
1598
- ...config.removals
1663
+ ...config.removals,
1664
+ ...mcp.removals
1599
1665
  ])
1600
1666
  ].filter((file) => !preserved.includes(file));
1601
1667
  assertManagedPaths(root, [...output.map((file) => file.path), ...removals]);
@@ -1607,6 +1673,7 @@ function planUpdate(args) {
1607
1673
  return [path, hashContent(content)];
1608
1674
  })
1609
1675
  );
1676
+ const opencodeConfig = mcp.plannedOpenCode ? mcp.opencodeConfig : config.opencodeConfig;
1610
1677
  const manifest = {
1611
1678
  version: MANIFEST_VERSION,
1612
1679
  harnessVersion: VERSION,
@@ -1618,7 +1685,8 @@ function planUpdate(args) {
1618
1685
  stopHookEngines: [...hookEngines].sort(),
1619
1686
  ...hasOverrides ? { overridesDir: overridesDir === root ? "." : overridesDir } : {},
1620
1687
  ...preserved.length ? { preservedFiles: preserved } : {},
1621
- ...config.opencodeConfig ? { opencodeConfig: config.opencodeConfig } : {},
1688
+ ...opencodeConfig ? { opencodeConfig } : {},
1689
+ ...mcp.mcpConfig ? { mcpConfig: mcp.mcpConfig } : {},
1622
1690
  ...config.hookConfigs ? { hookConfigs: config.hookConfigs } : {}
1623
1691
  };
1624
1692
  const writes = output.filter((file) => currentContent(root, file.path) !== file.content);
@@ -1628,25 +1696,33 @@ function planUpdate(args) {
1628
1696
  writes.push({ path: MANIFEST_FILENAME, content: `${JSON.stringify(manifest, null, 2)}
1629
1697
  ` });
1630
1698
  }
1631
- 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];
1632
1708
  if (!previous.fileHashes) {
1633
1709
  warnings.push(
1634
1710
  "legacy manifest has no file fingerprints: managed artifacts will be regenerated; backup preserves their previous contents"
1635
1711
  );
1636
1712
  }
1637
- if (preserved.length) warnings.push(`preserved user-owned artifacts: ${preserved.join(", ")}`);
1638
1713
  if (engines.includes("codex-cli"))
1639
1714
  warnings.push("ensure .agents/ is ignored by Git (run harness check)");
1640
1715
  return {
1641
- writes,
1642
- removals: existingRemovals,
1716
+ writes: plan.writes,
1717
+ removals: plan.removals,
1718
+ owned: plan.owned,
1643
1719
  result: {
1644
1720
  version: VERSION,
1645
1721
  fromManifestVersion: sourceVersion,
1646
1722
  dryRun: args.dryRun === true,
1647
- upToDate: writes.length === 0 && existingRemovals.length === 0,
1648
- filesToWrite: writes.map((file) => file.path),
1649
- filesToRemove: existingRemovals,
1723
+ upToDate: plan.writes.length === 0 && plan.removals.length === 0,
1724
+ filesToWrite: plan.writes.map((file) => file.path),
1725
+ filesToRemove: plan.removals,
1650
1726
  warnings
1651
1727
  }
1652
1728
  };
@@ -1662,6 +1738,67 @@ function updateCommand(args) {
1662
1738
  );
1663
1739
  return { ...plan.result, backupDir };
1664
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
+ }
1665
1802
  export {
1666
1803
  VERSION,
1667
1804
  initCommand,