@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/cli/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
- import { dirname as dirname8, resolve as resolve8 } from "path";
4
+ import { dirname as dirname9, resolve as resolve9 } from "path";
5
5
  import { fileURLToPath as fileURLToPath2 } from "url";
6
6
  import { Command } from "commander";
7
7
 
@@ -727,7 +727,7 @@ function formatReportText(report) {
727
727
  }
728
728
 
729
729
  // src/version.ts
730
- var VERSION = "1.3.2";
730
+ var VERSION = "1.4.0";
731
731
 
732
732
  // src/cli/check.ts
733
733
  import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
@@ -768,185 +768,216 @@ function checkCommand(repoRoot) {
768
768
  }
769
769
 
770
770
  // src/cli/init.ts
771
- import { existsSync as existsSync10, mkdirSync, writeFileSync as writeFileSync3 } from "fs";
772
- import { dirname as dirname4, join as join11, resolve as resolve4 } from "path";
773
- import { fileURLToPath } from "url";
774
-
775
- // src/loader.ts
776
- import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
777
- import { join as join5 } from "path";
778
- import fg6 from "fast-glob";
779
- import { z as z2 } from "zod";
780
-
781
- // src/parser/common.ts
782
- import { readFileSync as readFileSync3 } from "fs";
783
- import matter2 from "gray-matter";
784
-
785
- // src/parser/schemas.ts
786
- import { z } from "zod";
787
-
788
- // src/types.ts
789
- var AGENT_IDS = ["coding", "review", "merge", "planning"];
790
- var ENGINES = ["claude-code", "codex-cli", "opencode"];
791
- var CAPABILITIES = ["multi-step", "slash-command", "inline-exec"];
792
- var CAPABILITIES_MATRIX = {
793
- "claude-code": ["multi-step", "slash-command", "inline-exec"],
794
- "codex-cli": ["multi-step", "slash-command", "inline-exec"],
795
- opencode: ["multi-step", "slash-command", "inline-exec"]
796
- };
797
- var DEGRADED_CAPABILITIES = {
798
- "codex-cli": ["multi-step"]
799
- };
771
+ import { existsSync as existsSync13, mkdirSync as mkdirSync2, rmSync, writeFileSync as writeFileSync4 } from "fs";
772
+ import { dirname as dirname6, join as join15, resolve as resolve7 } from "path";
800
773
 
801
- // src/parser/schemas.ts
802
- var IdSchema = z.string().min(1).regex(/^[a-z][a-z0-9-]*$/, "id must be kebab-case (lowercase + digits + hyphens)");
803
- var SkillFrontmatterSchema = z.object({
804
- id: IdSchema,
805
- description: z.string().min(1),
806
- engines: z.array(z.enum(ENGINES)).min(1).optional(),
807
- requires: z.array(z.enum(CAPABILITIES)).optional(),
808
- tags: z.array(z.string()).optional()
809
- }).strict();
810
- var CmdArgEnumSchema = z.object({
811
- name: z.string().min(1),
812
- type: z.literal("enum"),
813
- values: z.array(z.string()).min(1),
814
- default: z.string().optional()
815
- }).strict();
816
- var CmdArgStringSchema = z.object({
817
- name: z.string().min(1),
818
- type: z.literal("string"),
819
- default: z.string().optional()
820
- }).strict();
821
- var CmdArgSchema = z.discriminatedUnion("type", [CmdArgEnumSchema, CmdArgStringSchema]);
822
- var CmdFrontmatterSchema = z.object({
823
- id: IdSchema,
824
- trigger: z.string().regex(/^\/[a-z][a-z0-9-]*$/, "trigger must be /kebab-case"),
825
- invokes_skill: IdSchema,
826
- args: z.array(CmdArgSchema).optional(),
827
- description: z.string().min(1)
828
- }).strict();
829
- var AgentFrontmatterSchema = z.object({
830
- id: z.enum(AGENT_IDS),
831
- description: z.string().min(1)
832
- }).strict();
833
- var Schemas = {
834
- Skill: SkillFrontmatterSchema,
835
- Cmd: CmdFrontmatterSchema,
836
- Agent: AgentFrontmatterSchema
837
- };
838
- var ParseError = class extends Error {
839
- constructor(file, issues) {
840
- const messages = issues.map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
841
- super(`Frontmatter validation failed for ${file}:
842
- ${messages}`);
843
- this.file = file;
844
- this.issues = issues;
845
- this.name = "ParseError";
774
+ // src/cli/managed-paths.ts
775
+ import { lstatSync, realpathSync, statSync as statSync2 } from "fs";
776
+ import { basename as basename2, dirname as dirname2, isAbsolute as isAbsolute2, join as join5, resolve as resolve2, win32 } from "path";
777
+ var MANAGED_FILES = /* @__PURE__ */ new Set([
778
+ "AGENTS.md",
779
+ "CLAUDE.md",
780
+ ".mcp.json",
781
+ ".harness-manifest.json",
782
+ ".claude/settings.json",
783
+ ".codex/config.toml",
784
+ ".codex/hooks.json",
785
+ ".opencode/opencode.json",
786
+ ".opencode/mcp.json"
787
+ ]);
788
+ var MANAGED_DIRECTORIES = [
789
+ ".claude/agents/",
790
+ ".claude/commands/",
791
+ ".claude/skills/",
792
+ ".codex/agents/",
793
+ ".codex/commands/",
794
+ ".codex/skills/",
795
+ ".agents/skills/",
796
+ ".opencode/agents/",
797
+ ".opencode/commands/",
798
+ ".opencode/skills/",
799
+ ".opencode/plugins/"
800
+ ];
801
+ function canonicalTargetRoot(root) {
802
+ let ancestor = resolve2(root);
803
+ const missing = [];
804
+ while (!lstatSync(ancestor, { throwIfNoEntry: false })) {
805
+ missing.unshift(basename2(ancestor));
806
+ ancestor = dirname2(ancestor);
846
807
  }
847
- file;
848
- issues;
849
- };
850
-
851
- // src/parser/common.ts
852
- function parseWithSchema(filePath, source, schema) {
853
- const parsed = matter2(source);
854
- const result = schema.safeParse(parsed.data);
855
- if (!result.success) {
856
- throw new ParseError(filePath, result.error.issues);
808
+ const canonical = realpathSync(ancestor);
809
+ if (!statSync2(canonical).isDirectory()) {
810
+ throw new Error(`harness target is not a directory: ${root}`);
857
811
  }
858
- return {
859
- meta: result.data,
860
- body: parsed.content.trim(),
861
- sourcePath: filePath
862
- };
812
+ return join5(canonical, ...missing);
863
813
  }
864
-
865
- // src/parser/agent.ts
866
- function parseAgent(filePath, source) {
867
- return parseWithSchema(filePath, source, Schemas.Agent);
814
+ function assertManagedLocation(path) {
815
+ const invalidSegment = path.split("/").some((part) => part === "" || part === "." || part === "..");
816
+ if (isAbsolute2(path) || win32.isAbsolute(path) || path.includes("\\") || path.includes("\0") || invalidSegment) {
817
+ throw new Error(`unsafe harness path: ${path}`);
818
+ }
819
+ if (!MANAGED_FILES.has(path) && !MANAGED_DIRECTORIES.some((prefix) => path.startsWith(prefix))) {
820
+ throw new Error(`path is outside harness-managed locations: ${path}`);
821
+ }
868
822
  }
869
-
870
- // src/parser/cmd.ts
871
- function parseCmd(filePath, source) {
872
- return parseWithSchema(filePath, source, Schemas.Cmd);
823
+ function assertNoSymbolicComponents(root, path) {
824
+ const parts = path.split("/");
825
+ let current = root;
826
+ for (const [index, part] of parts.entries()) {
827
+ current = join5(current, part);
828
+ const info = lstatSync(current, { throwIfNoEntry: false });
829
+ if (!info) return;
830
+ if (info.isSymbolicLink()) {
831
+ throw new Error(`symbolic link in harness path: ${path}`);
832
+ }
833
+ const isLeaf = index === parts.length - 1;
834
+ if (isLeaf ? !info.isFile() : !info.isDirectory()) {
835
+ throw new Error(`unexpected file type in harness path: ${path}`);
836
+ }
837
+ }
873
838
  }
874
-
875
- // src/parser/skill.ts
876
- function parseSkill(filePath, source) {
877
- return parseWithSchema(filePath, source, Schemas.Skill);
839
+ function assertManagedPaths(root, files) {
840
+ for (const path of files) assertManagedLocation(path);
841
+ const canonicalRoot2 = canonicalTargetRoot(root);
842
+ for (const path of files) assertNoSymbolicComponents(canonicalRoot2, path);
878
843
  }
879
844
 
880
- // src/util/yaml.ts
881
- import { parse as yamlParse } from "yaml";
882
- function parseYAML(source) {
883
- return yamlParse(source);
884
- }
845
+ // src/cli/manifest.ts
846
+ import { createHash } from "crypto";
847
+ import { chmodSync, existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync } from "fs";
848
+ import { join as join8 } from "path";
849
+ import { z as z3 } from "zod";
885
850
 
886
- // src/loader.ts
887
- var DisableYamlSchema = z2.object({ disable: z2.array(z2.string()).optional() }).passthrough();
888
- function loadHarness(packageDir, opts = {}) {
889
- const agentsDir = opts.agentsDir ?? join5(packageDir, "agents");
890
- const skillsDir = opts.skillsDir ?? join5(packageDir, "skills");
891
- const commandsDir = opts.commandsDir ?? join5(packageDir, "commands");
892
- const agentFiles = fg6.sync("*.agent.md", { cwd: agentsDir, absolute: true });
893
- const skillFiles = fg6.sync("*.skill.md", { cwd: skillsDir, absolute: true });
894
- const cmdFiles = fg6.sync("*.cmd.md", { cwd: commandsDir, absolute: true });
895
- return {
896
- agents: agentFiles.map((f) => parseAgent(f, readFileSync4(f, "utf8"))),
897
- skills: skillFiles.map((f) => parseSkill(f, readFileSync4(f, "utf8"))),
898
- cmds: cmdFiles.map((f) => parseCmd(f, readFileSync4(f, "utf8")))
899
- };
851
+ // src/cli/opencode-config.ts
852
+ import { existsSync as existsSync5, readFileSync as readFileSync3 } from "fs";
853
+ import { join as join6 } from "path";
854
+ import { isDeepStrictEqual } from "util";
855
+ import { z } from "zod";
856
+ var OPENCODE_CONFIG_PATH = ".opencode/opencode.json";
857
+ var CLAUDE_MCP_PATH = ".mcp.json";
858
+ var OPENCODE_SCHEMA = "https://opencode.ai/config.json";
859
+ var ObjectSchema = z.record(z.unknown());
860
+ var OPENCODE_TARGET = {
861
+ path: OPENCODE_CONFIG_PATH,
862
+ key: "mcp",
863
+ label: "OpenCode",
864
+ schema: OPENCODE_SCHEMA
865
+ };
866
+ var CLAUDE_TARGET = {
867
+ path: CLAUDE_MCP_PATH,
868
+ key: "mcpServers",
869
+ label: "Claude Code"
870
+ };
871
+ var OpenCodeConfigOwnershipSchema = z.object({
872
+ createdFile: z.boolean(),
873
+ addedMcp: z.boolean(),
874
+ addedSchema: z.boolean(),
875
+ entries: z.record(ObjectSchema)
876
+ });
877
+ function parseConfig(target, content) {
878
+ try {
879
+ const config = ObjectSchema.parse(JSON.parse(content));
880
+ if (Object.hasOwn(config, target.key)) ObjectSchema.parse(config[target.key]);
881
+ return config;
882
+ } catch {
883
+ throw new Error(`invalid ${target.path}: expected a JSON object with an MCP object`);
884
+ }
900
885
  }
901
- function loadProjectSkills(targetDir) {
902
- const skillsDir = join5(targetDir, "docs", "skills");
903
- if (!existsSync5(skillsDir)) return [];
904
- const skillFiles = fg6.sync("*.skill.md", { cwd: skillsDir, absolute: true });
905
- return skillFiles.map((f) => parseSkill(f, readFileSync4(f, "utf8")));
886
+ function removeOwnedEntries(target, config, ownership, warnings) {
887
+ if (Object.hasOwn(config, target.key)) {
888
+ const mcp = ObjectSchema.parse(config[target.key]);
889
+ for (const [id, installed] of Object.entries(ownership.entries)) {
890
+ if (!Object.hasOwn(mcp, id)) continue;
891
+ if (isDeepStrictEqual(mcp[id], installed)) delete mcp[id];
892
+ else warnings.push(`preserved modified ${target.label} MCP server: ${id}`);
893
+ }
894
+ config[target.key] = mcp;
895
+ if (ownership.addedMcp && Object.keys(mcp).length === 0)
896
+ Reflect.deleteProperty(config, target.key);
897
+ }
898
+ if (target.schema && ownership.addedSchema && config.$schema === target.schema)
899
+ Reflect.deleteProperty(config, "$schema");
906
900
  }
907
- function loadOverrides(worktreeDir) {
908
- const overridesDir = join5(worktreeDir, ".harness-overrides");
909
- if (!existsSync5(overridesDir)) return void 0;
910
- const added = loadHarness(overridesDir, {
911
- agentsDir: join5(overridesDir, "agents"),
912
- skillsDir: join5(overridesDir, "skills"),
913
- commandsDir: join5(overridesDir, "commands")
914
- });
915
- const disablePath = join5(overridesDir, "disable.yaml");
916
- let disable = [];
917
- if (existsSync5(disablePath)) {
918
- try {
919
- const parsed = DisableYamlSchema.safeParse(parseYAML(readFileSync4(disablePath, "utf8")));
920
- if (parsed.success && parsed.data.disable) {
921
- disable = parsed.data.disable;
901
+ function addMissingEntries(target, config, desired, createdFile, warnings) {
902
+ const requested = ObjectSchema.parse(parseConfig(target, desired.content)[target.key]);
903
+ const mcp = ObjectSchema.parse(config[target.key] ?? {});
904
+ const entries = {};
905
+ for (const [id, entry] of Object.entries(requested)) {
906
+ if (Object.hasOwn(mcp, id)) {
907
+ if (!isDeepStrictEqual(mcp[id], entry)) {
908
+ warnings.push(
909
+ `preserved existing ${target.label} MCP server instead of replacing it: ${id}`
910
+ );
922
911
  }
923
- } catch {
912
+ continue;
924
913
  }
914
+ entries[id] = ObjectSchema.parse(entry);
915
+ mcp[id] = entry;
925
916
  }
926
- return { add: added, disable };
917
+ if (Object.keys(entries).length === 0) return void 0;
918
+ const ownership = {
919
+ createdFile,
920
+ addedMcp: !Object.hasOwn(config, target.key),
921
+ addedSchema: target.schema !== void 0 && !Object.hasOwn(config, "$schema"),
922
+ entries
923
+ };
924
+ config[target.key] = mcp;
925
+ if (target.schema && ownership.addedSchema) config.$schema = target.schema;
926
+ return ownership;
927
927
  }
928
-
929
- // src/mcp.ts
930
- var MCP_SERVER_IDS = ["context7"];
931
- var MCP_SERVERS = {
932
- context7: {
933
- id: "context7",
934
- description: "Upstash Context7 \u2014 fetches up-to-date library docs via resolve-library-id + get-library-docs.",
935
- command: "npx",
936
- args: ["-y", "@upstash/context7-mcp"]
928
+ function planMcpJson(target, root, desired, previous) {
929
+ const warnings = [];
930
+ if (!desired && !previous) return { remove: false, warnings };
931
+ const path = join6(root, target.path);
932
+ const existed = existsSync5(path);
933
+ if (!existed && !desired) return { remove: false, warnings };
934
+ const original = existed ? readFileSync3(path, "utf8") : void 0;
935
+ const config = original === void 0 ? {} : parseConfig(target, original);
936
+ const before = structuredClone(config);
937
+ if (previous) removeOwnedEntries(target, config, previous, warnings);
938
+ const createdFile = !existed || previous?.createdFile === true;
939
+ const ownership = desired ? addMissingEntries(target, config, desired, createdFile, warnings) : void 0;
940
+ if (!ownership && createdFile && Object.keys(config).length === 0) {
941
+ return { remove: existed, warnings };
937
942
  }
938
- };
939
- function resolveMcpIds(requested) {
940
- return requested.map((id) => {
941
- if (!MCP_SERVER_IDS.includes(id)) {
942
- throw new Error(`unknown MCP server: ${id} (expected one of ${MCP_SERVER_IDS.join(", ")})`);
943
- }
944
- return id;
945
- });
943
+ return {
944
+ remove: false,
945
+ ownership,
946
+ warnings,
947
+ ...!existed || !isDeepStrictEqual(before, config) ? { write: { path: target.path, content: `${JSON.stringify(config, null, 2)}
948
+ ` } } : {}
949
+ };
950
+ }
951
+ function planOpenCodeConfig(root, desired, previous) {
952
+ return planMcpJson(OPENCODE_TARGET, root, desired, previous);
953
+ }
954
+ function planClaudeMcpConfig(root, desired, previous) {
955
+ return planMcpJson(CLAUDE_TARGET, root, desired, previous);
946
956
  }
947
957
 
958
+ // src/cli/update-config.ts
959
+ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "fs";
960
+ import { join as join7 } from "path";
961
+ import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
962
+ import { z as z2 } from "zod";
963
+
948
964
  // src/renderers/common.ts
949
965
  import { stringify as yamlStringify } from "yaml";
966
+
967
+ // src/types.ts
968
+ var AGENT_IDS = ["coding", "review", "merge", "planning"];
969
+ var ENGINES = ["claude-code", "codex-cli", "opencode"];
970
+ var CAPABILITIES = ["multi-step", "slash-command", "inline-exec"];
971
+ var CAPABILITIES_MATRIX = {
972
+ "claude-code": ["multi-step", "slash-command", "inline-exec"],
973
+ "codex-cli": ["multi-step", "slash-command", "inline-exec"],
974
+ opencode: ["multi-step", "slash-command", "inline-exec"]
975
+ };
976
+ var DEGRADED_CAPABILITIES = {
977
+ "codex-cli": ["multi-step"]
978
+ };
979
+
980
+ // src/renderers/common.ts
950
981
  var STOP_HOOK_PACKAGE = "@vaubaan/harness";
951
982
  function checkSkillCapabilities(skill, engine) {
952
983
  const blockingWarnings = [];
@@ -1390,274 +1421,7 @@ function getRenderer(engine) {
1390
1421
  return RENDERERS[engine];
1391
1422
  }
1392
1423
 
1393
- // src/render.ts
1394
- var RenderError = class extends Error {
1395
- constructor(message) {
1396
- super(message);
1397
- this.name = "RenderError";
1398
- }
1399
- };
1400
- function mergeById(stock, added) {
1401
- const result = /* @__PURE__ */ new Map();
1402
- for (const item of stock) result.set(item.meta.id, item);
1403
- if (added) {
1404
- for (const item of added) result.set(item.meta.id, item);
1405
- }
1406
- return Array.from(result.values());
1407
- }
1408
- function removeByIds(items, disabled) {
1409
- if (disabled.length === 0) return [...items];
1410
- const disabledSet = new Set(disabled);
1411
- return items.filter((item) => !disabledSet.has(item.meta.id));
1412
- }
1413
- function applyOverrides(harness, overrides) {
1414
- const disabled = overrides?.disable ?? [];
1415
- return {
1416
- agents: removeByIds(mergeById(harness.agents, overrides?.add?.agents), disabled),
1417
- skills: removeByIds(mergeById(harness.skills, overrides?.add?.skills), disabled),
1418
- cmds: removeByIds(mergeById(harness.cmds, overrides?.add?.cmds), disabled)
1419
- };
1420
- }
1421
- var CLAUDE_MD_STUB = "@AGENTS.md\n";
1422
- function opencodeLast(engines) {
1423
- return [...engines].sort((a, b) => Number(a === "opencode") - Number(b === "opencode"));
1424
- }
1425
- function render(input) {
1426
- const { engines } = input;
1427
- if (engines.length === 0) {
1428
- throw new RenderError("at least one engine must be selected");
1429
- }
1430
- const base = input.projectSkills?.length ? { ...input.harness, skills: mergeById(input.harness.skills, input.projectSkills) } : input.harness;
1431
- const harness = applyOverrides(base, input.overrides);
1432
- if (harness.agents.length === 0) {
1433
- throw new RenderError("No agents found in harness");
1434
- }
1435
- const files = [];
1436
- const warnings = [];
1437
- const exposedSkills = /* @__PURE__ */ new Map();
1438
- const exposedCmds = /* @__PURE__ */ new Map();
1439
- for (const engine of opencodeLast(engines)) {
1440
- const renderer = getRenderer(engine);
1441
- const renderedSkills = [];
1442
- for (const skill of harness.skills) {
1443
- const result = renderer.renderSkill(skill);
1444
- warnings.push(...result.warnings);
1445
- if (result.files.length > 0) {
1446
- const alreadyVisible = engine === "opencode" && exposedSkills.has(skill.meta.id);
1447
- if (!alreadyVisible) files.push(...result.files);
1448
- renderedSkills.push(skill);
1449
- exposedSkills.set(skill.meta.id, skill);
1450
- }
1451
- }
1452
- const renderedSkillIds = new Set(renderedSkills.map((s) => s.meta.id));
1453
- const renderedCmds = [];
1454
- for (const cmd of harness.cmds) {
1455
- if (!renderedSkillIds.has(cmd.meta.invokes_skill)) continue;
1456
- const result = renderer.renderCmd(cmd);
1457
- warnings.push(...result.warnings);
1458
- if (result.files.length > 0) {
1459
- files.push(...result.files);
1460
- renderedCmds.push(cmd);
1461
- exposedCmds.set(cmd.meta.id, cmd);
1462
- }
1463
- }
1464
- const agentsResult = renderer.renderAgents(harness.agents);
1465
- warnings.push(...agentsResult.warnings);
1466
- files.push(...agentsResult.files);
1467
- if (input.mcpServers && input.mcpServers.length > 0) {
1468
- const specs = input.mcpServers.map((id) => MCP_SERVERS[id]);
1469
- const mcpResult = renderer.renderMcp(specs);
1470
- warnings.push(...mcpResult.warnings);
1471
- files.push(...mcpResult.files);
1472
- }
1473
- if (input.withStopHook !== false) {
1474
- const hookResult = renderer.renderStopHook();
1475
- warnings.push(...hookResult.warnings);
1476
- files.push(...hookResult.files);
1477
- }
1478
- }
1479
- const hasProjectSkills = (input.projectSkills?.length ?? 0) > 0;
1480
- const umbrellaContent = buildUmbrellaBody(harness.agents, {
1481
- availableSkills: Array.from(exposedSkills.values()),
1482
- availableCommands: Array.from(exposedCmds.values()),
1483
- hasProjectSkills,
1484
- hasQualityDoc: input.hasQualityDoc === true
1485
- });
1486
- files.push({ path: "AGENTS.md", content: umbrellaContent });
1487
- if (engines.includes("claude-code")) {
1488
- files.push({ path: "CLAUDE.md", content: CLAUDE_MD_STUB });
1489
- }
1490
- return { files, warnings };
1491
- }
1492
-
1493
- // src/cli/managed-paths.ts
1494
- import { lstatSync, realpathSync, statSync as statSync2 } from "fs";
1495
- import { basename as basename2, dirname as dirname2, isAbsolute as isAbsolute2, join as join6, resolve as resolve2, win32 } from "path";
1496
- var MANAGED_FILES = /* @__PURE__ */ new Set([
1497
- "AGENTS.md",
1498
- "CLAUDE.md",
1499
- ".mcp.json",
1500
- ".harness-manifest.json",
1501
- ".claude/settings.json",
1502
- ".codex/config.toml",
1503
- ".codex/hooks.json",
1504
- ".opencode/opencode.json",
1505
- ".opencode/mcp.json"
1506
- ]);
1507
- var MANAGED_DIRECTORIES = [
1508
- ".claude/agents/",
1509
- ".claude/commands/",
1510
- ".claude/skills/",
1511
- ".codex/agents/",
1512
- ".codex/commands/",
1513
- ".codex/skills/",
1514
- ".agents/skills/",
1515
- ".opencode/agents/",
1516
- ".opencode/commands/",
1517
- ".opencode/skills/",
1518
- ".opencode/plugins/"
1519
- ];
1520
- function canonicalTargetRoot(root) {
1521
- let ancestor = resolve2(root);
1522
- const missing = [];
1523
- while (!lstatSync(ancestor, { throwIfNoEntry: false })) {
1524
- missing.unshift(basename2(ancestor));
1525
- ancestor = dirname2(ancestor);
1526
- }
1527
- const canonical = realpathSync(ancestor);
1528
- if (!statSync2(canonical).isDirectory()) {
1529
- throw new Error(`harness target is not a directory: ${root}`);
1530
- }
1531
- return join6(canonical, ...missing);
1532
- }
1533
- function assertManagedLocation(path) {
1534
- const invalidSegment = path.split("/").some((part) => part === "" || part === "." || part === "..");
1535
- if (isAbsolute2(path) || win32.isAbsolute(path) || path.includes("\\") || path.includes("\0") || invalidSegment) {
1536
- throw new Error(`unsafe harness path: ${path}`);
1537
- }
1538
- if (!MANAGED_FILES.has(path) && !MANAGED_DIRECTORIES.some((prefix) => path.startsWith(prefix))) {
1539
- throw new Error(`path is outside harness-managed locations: ${path}`);
1540
- }
1541
- }
1542
- function assertNoSymbolicComponents(root, path) {
1543
- const parts = path.split("/");
1544
- let current = root;
1545
- for (const [index, part] of parts.entries()) {
1546
- current = join6(current, part);
1547
- const info = lstatSync(current, { throwIfNoEntry: false });
1548
- if (!info) return;
1549
- if (info.isSymbolicLink()) {
1550
- throw new Error(`symbolic link in harness path: ${path}`);
1551
- }
1552
- const isLeaf = index === parts.length - 1;
1553
- if (isLeaf ? !info.isFile() : !info.isDirectory()) {
1554
- throw new Error(`unexpected file type in harness path: ${path}`);
1555
- }
1556
- }
1557
- }
1558
- function assertManagedPaths(root, files) {
1559
- for (const path of files) assertManagedLocation(path);
1560
- const canonicalRoot2 = canonicalTargetRoot(root);
1561
- for (const path of files) assertNoSymbolicComponents(canonicalRoot2, path);
1562
- }
1563
-
1564
- // src/cli/manifest.ts
1565
- import { createHash } from "crypto";
1566
- import { chmodSync, existsSync as existsSync8, readFileSync as readFileSync7, writeFileSync } from "fs";
1567
- import { join as join9 } from "path";
1568
- import { z as z5 } from "zod";
1569
-
1570
- // src/cli/opencode-config.ts
1571
- import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
1572
- import { join as join7 } from "path";
1573
- import { isDeepStrictEqual } from "util";
1574
- import { z as z3 } from "zod";
1575
- var OPENCODE_CONFIG_PATH = ".opencode/opencode.json";
1576
- var OPENCODE_SCHEMA = "https://opencode.ai/config.json";
1577
- var ObjectSchema = z3.record(z3.unknown());
1578
- var OpenCodeConfigOwnershipSchema = z3.object({
1579
- createdFile: z3.boolean(),
1580
- addedMcp: z3.boolean(),
1581
- addedSchema: z3.boolean(),
1582
- entries: z3.record(ObjectSchema)
1583
- });
1584
- function parseConfig(content) {
1585
- try {
1586
- const config = ObjectSchema.parse(JSON.parse(content));
1587
- if (Object.hasOwn(config, "mcp")) ObjectSchema.parse(config.mcp);
1588
- return config;
1589
- } catch {
1590
- throw new Error(`invalid ${OPENCODE_CONFIG_PATH}: expected a JSON object with an MCP object`);
1591
- }
1592
- }
1593
- function removeOwnedEntries(config, ownership, warnings) {
1594
- if (Object.hasOwn(config, "mcp")) {
1595
- const mcp = ObjectSchema.parse(config.mcp);
1596
- for (const [id, installed] of Object.entries(ownership.entries)) {
1597
- if (!Object.hasOwn(mcp, id)) continue;
1598
- if (isDeepStrictEqual(mcp[id], installed)) delete mcp[id];
1599
- else warnings.push(`preserved modified OpenCode MCP server: ${id}`);
1600
- }
1601
- config.mcp = mcp;
1602
- if (ownership.addedMcp && Object.keys(mcp).length === 0) Reflect.deleteProperty(config, "mcp");
1603
- }
1604
- if (ownership.addedSchema && config.$schema === OPENCODE_SCHEMA)
1605
- Reflect.deleteProperty(config, "$schema");
1606
- }
1607
- function addMissingEntries(config, desired, createdFile, warnings) {
1608
- const requested = ObjectSchema.parse(parseConfig(desired.content).mcp);
1609
- const mcp = ObjectSchema.parse(config.mcp ?? {});
1610
- const entries = {};
1611
- for (const [id, entry] of Object.entries(requested)) {
1612
- if (Object.hasOwn(mcp, id)) {
1613
- if (!isDeepStrictEqual(mcp[id], entry)) {
1614
- warnings.push(`preserved existing OpenCode MCP server instead of replacing it: ${id}`);
1615
- }
1616
- continue;
1617
- }
1618
- entries[id] = ObjectSchema.parse(entry);
1619
- mcp[id] = entry;
1620
- }
1621
- if (Object.keys(entries).length === 0) return void 0;
1622
- const ownership = {
1623
- createdFile,
1624
- addedMcp: !Object.hasOwn(config, "mcp"),
1625
- addedSchema: !Object.hasOwn(config, "$schema"),
1626
- entries
1627
- };
1628
- config.mcp = mcp;
1629
- if (ownership.addedSchema) config.$schema = OPENCODE_SCHEMA;
1630
- return ownership;
1631
- }
1632
- function planOpenCodeConfig(root, desired, previous) {
1633
- const warnings = [];
1634
- if (!desired && !previous) return { remove: false, warnings };
1635
- const path = join7(root, OPENCODE_CONFIG_PATH);
1636
- const existed = existsSync6(path);
1637
- if (!existed && !desired) return { remove: false, warnings };
1638
- const original = existed ? readFileSync5(path, "utf8") : void 0;
1639
- const config = original === void 0 ? {} : parseConfig(original);
1640
- const before = structuredClone(config);
1641
- if (previous) removeOwnedEntries(config, previous, warnings);
1642
- const createdFile = !existed || previous?.createdFile === true;
1643
- const ownership = desired ? addMissingEntries(config, desired, createdFile, warnings) : void 0;
1644
- if (!ownership && createdFile && Object.keys(config).length === 0) {
1645
- return { remove: existed, warnings };
1646
- }
1647
- return {
1648
- remove: false,
1649
- ownership,
1650
- warnings,
1651
- ...!existed || !isDeepStrictEqual(before, config) ? { write: { path: OPENCODE_CONFIG_PATH, content: `${JSON.stringify(config, null, 2)}
1652
- ` } } : {}
1653
- };
1654
- }
1655
-
1656
1424
  // src/cli/update-config.ts
1657
- import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
1658
- import { join as join8 } from "path";
1659
- import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
1660
- import { z as z4 } from "zod";
1661
1425
  var LEGACY_OPENCODE_CONFIG = ".opencode/mcp.json";
1662
1426
  var SHARED_CONFIG_PATHS = [
1663
1427
  ".mcp.json",
@@ -1667,15 +1431,15 @@ var SHARED_CONFIG_PATHS = [
1667
1431
  ".claude/settings.json",
1668
1432
  ".codex/hooks.json"
1669
1433
  ];
1670
- var ObjectSchema2 = z4.record(z4.unknown());
1671
- var HookEntrySchema = z4.object({ group: ObjectSchema2, hook: ObjectSchema2 });
1672
- var HookConfigOwnershipSchema = z4.object({
1673
- createdFile: z4.boolean(),
1674
- addedHooks: z4.boolean(),
1675
- addedStop: z4.boolean(),
1676
- entries: z4.array(HookEntrySchema)
1434
+ var ObjectSchema2 = z2.record(z2.unknown());
1435
+ var HookEntrySchema = z2.object({ group: ObjectSchema2, hook: ObjectSchema2 });
1436
+ var HookConfigOwnershipSchema = z2.object({
1437
+ createdFile: z2.boolean(),
1438
+ addedHooks: z2.boolean(),
1439
+ addedStop: z2.boolean(),
1440
+ entries: z2.array(HookEntrySchema)
1677
1441
  });
1678
- var HookConfigsOwnershipSchema = z4.object({
1442
+ var HookConfigsOwnershipSchema = z2.object({
1679
1443
  ".claude/settings.json": HookConfigOwnershipSchema.optional(),
1680
1444
  ".codex/hooks.json": HookConfigOwnershipSchema.optional()
1681
1445
  }).strict();
@@ -1799,8 +1563,8 @@ function planHookConfigCleanup(root, ownership) {
1799
1563
  assertManagedPaths(root, Object.keys(validated));
1800
1564
  for (const path of Object.keys(HOOK_ENGINES)) {
1801
1565
  const owned = validated[path];
1802
- if (!owned || !existsSync7(join8(root, path))) continue;
1803
- const before = parseObject(readFileSync6(join8(root, path), "utf8"), path);
1566
+ if (!owned || !existsSync6(join7(root, path))) continue;
1567
+ const before = parseObject(readFileSync4(join7(root, path), "utf8"), path);
1804
1568
  const config = structuredClone(before);
1805
1569
  removeOwnedHooks(config, owned, path, plan.warnings);
1806
1570
  recordMutation(plan, path, before, config, owned.createdFile);
@@ -1809,12 +1573,12 @@ function planHookConfigCleanup(root, ownership) {
1809
1573
  }
1810
1574
  function planActiveHook(root, path, previous, claimExisting, plan) {
1811
1575
  assertManagedPaths(root, [path]);
1812
- const existed = existsSync7(join8(root, path));
1576
+ const existed = existsSync6(join7(root, path));
1813
1577
  if (!existed && previous?.entries.length) {
1814
1578
  plan.warnings.push(`preserved removed harness Stop hook configuration: ${path}`);
1815
1579
  return previous;
1816
1580
  }
1817
- const before = existed ? parseObject(readFileSync6(join8(root, path), "utf8"), path) : void 0;
1581
+ const before = existed ? parseObject(readFileSync4(join7(root, path), "utf8"), path) : void 0;
1818
1582
  const config = structuredClone(before ?? {});
1819
1583
  const groups = stopHooks(config, path);
1820
1584
  const hooks = asObject(config.hooks) ?? {};
@@ -1826,10 +1590,11 @@ function planActiveHook(root, path, previous, claimExisting, plan) {
1826
1590
  plan.warnings,
1827
1591
  path
1828
1592
  );
1593
+ const legacyWholeFile = !previous && claimExisting && existed;
1829
1594
  const ownership = {
1830
- createdFile: !existed || previous?.createdFile === true,
1831
- addedHooks: previous?.addedHooks ?? !Object.hasOwn(config, "hooks"),
1832
- addedStop: previous?.addedStop ?? !Object.hasOwn(hooks, "Stop"),
1595
+ createdFile: !existed || previous?.createdFile === true || legacyWholeFile,
1596
+ addedHooks: previous?.addedHooks ?? (legacyWholeFile || !Object.hasOwn(config, "hooks")),
1597
+ addedStop: previous?.addedStop ?? (legacyWholeFile || !Object.hasOwn(hooks, "Stop")),
1833
1598
  entries: normalized.entries
1834
1599
  };
1835
1600
  if (!isDeepStrictEqual2(groups, normalized.groups)) {
@@ -1840,10 +1605,10 @@ function planActiveHook(root, path, previous, claimExisting, plan) {
1840
1605
  }
1841
1606
  function planLegacyOpenCode(root, previous, plan) {
1842
1607
  if (!previous.files.includes(LEGACY_OPENCODE_CONFIG)) return;
1843
- const legacyPath = join8(root, LEGACY_OPENCODE_CONFIG);
1844
- if (!existsSync7(legacyPath)) return;
1608
+ const legacyPath = join7(root, LEGACY_OPENCODE_CONFIG);
1609
+ if (!existsSync6(legacyPath)) return;
1845
1610
  assertManagedPaths(root, [LEGACY_OPENCODE_CONFIG, OPENCODE_CONFIG_PATH]);
1846
- const legacy = parseObject(readFileSync6(legacyPath, "utf8"), LEGACY_OPENCODE_CONFIG);
1611
+ const legacy = parseObject(readFileSync4(legacyPath, "utf8"), LEGACY_OPENCODE_CONFIG);
1847
1612
  if (!asObject(legacy.mcp)) {
1848
1613
  throw new Error(
1849
1614
  `invalid shared configuration ${LEGACY_OPENCODE_CONFIG}: expected an MCP object`
@@ -1907,45 +1672,24 @@ function planUpdateConfigs(root, previous, hookEngines) {
1907
1672
  // src/cli/manifest.ts
1908
1673
  var MANIFEST_FILENAME = ".harness-manifest.json";
1909
1674
  var MANIFEST_VERSION = "6";
1910
- var ManifestSchema = z5.object({
1911
- version: z5.enum(["4", "5", MANIFEST_VERSION]),
1912
- engines: z5.array(z5.string()),
1913
- renderedAt: z5.string(),
1914
- files: z5.array(z5.string()),
1915
- stopHookInstalled: z5.boolean().optional(),
1916
- stopHookEngines: z5.array(z5.string()).optional(),
1675
+ var ManifestSchema = z3.object({
1676
+ version: z3.enum(["4", "5", MANIFEST_VERSION]),
1677
+ engines: z3.array(z3.string()),
1678
+ renderedAt: z3.string(),
1679
+ files: z3.array(z3.string()),
1680
+ stopHookInstalled: z3.boolean().optional(),
1681
+ stopHookEngines: z3.array(z3.string()).optional(),
1917
1682
  opencodeConfig: OpenCodeConfigOwnershipSchema.optional(),
1683
+ /** Entries the harness added to `.mcp.json` (Claude Code). */
1684
+ mcpConfig: OpenCodeConfigOwnershipSchema.optional(),
1918
1685
  hookConfigs: HookConfigsOwnershipSchema.optional(),
1919
- harnessVersion: z5.string().optional(),
1920
- overridesDir: z5.string().optional(),
1921
- fileHashes: z5.record(z5.string().regex(/^[a-f0-9]{64}$/)).optional(),
1922
- preservedFiles: z5.array(z5.string()).optional()
1686
+ harnessVersion: z3.string().optional(),
1687
+ overridesDir: z3.string().optional(),
1688
+ fileHashes: z3.record(z3.string().regex(/^[a-f0-9]{64}$/)).optional(),
1689
+ preservedFiles: z3.array(z3.string()).optional()
1923
1690
  });
1924
1691
  function manifestPath(targetDir) {
1925
- return join9(targetDir, MANIFEST_FILENAME);
1926
- }
1927
- function writeManifest(targetDir, engines, files, extras = {}) {
1928
- assertManagedPaths(targetDir, [MANIFEST_FILENAME]);
1929
- const manifest = {
1930
- version: MANIFEST_VERSION,
1931
- harnessVersion: VERSION,
1932
- engines: [...engines].sort(),
1933
- renderedAt: (/* @__PURE__ */ new Date()).toISOString(),
1934
- files: [...files].sort(),
1935
- fileHashes: Object.fromEntries(
1936
- files.filter((file) => existsSync8(join9(targetDir, file))).sort().map((file) => [file, hashContent(readFileSync7(join9(targetDir, file)))])
1937
- ),
1938
- ...extras.overridesDir ? { overridesDir: extras.overridesDir } : {},
1939
- ...extras.preservedFiles ? { preservedFiles: extras.preservedFiles } : {},
1940
- ...extras.hookConfigs ? { hookConfigs: extras.hookConfigs } : {},
1941
- ...extras.opencodeConfig ? { opencodeConfig: extras.opencodeConfig } : {},
1942
- ...extras.stopHookInstalled !== void 0 ? { stopHookInstalled: extras.stopHookInstalled } : {},
1943
- ...extras.stopHookEngines !== void 0 ? { stopHookEngines: [...extras.stopHookEngines].sort() } : {}
1944
- };
1945
- const path = manifestPath(targetDir);
1946
- if (existsSync8(path)) chmodSync(path, 384);
1947
- writeFileSync(path, `${JSON.stringify(manifest, null, 2)}
1948
- `, { mode: 384 });
1692
+ return join8(targetDir, MANIFEST_FILENAME);
1949
1693
  }
1950
1694
  function hashContent(content) {
1951
1695
  return createHash("sha256").update(content).digest("hex");
@@ -1953,99 +1697,291 @@ function hashContent(content) {
1953
1697
  function readManifest(targetDir) {
1954
1698
  assertManagedPaths(targetDir, [MANIFEST_FILENAME]);
1955
1699
  const path = manifestPath(targetDir);
1956
- if (!existsSync8(path)) return null;
1957
- const raw = JSON.parse(readFileSync7(path, "utf8"));
1700
+ if (!existsSync7(path)) return null;
1701
+ const raw = JSON.parse(readFileSync5(path, "utf8"));
1958
1702
  return ManifestSchema.parse(raw);
1959
1703
  }
1960
1704
 
1961
- // src/cli/uninstall.ts
1962
- import { existsSync as existsSync9, readdirSync, rmdirSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
1963
- import { dirname as dirname3, join as join10, relative as relative2, resolve as resolve3 } from "path";
1964
- function uninstallCommand(args) {
1965
- const root = resolve3(args.targetDir);
1966
- const warnings = [];
1967
- assertManagedPaths(root, [MANIFEST_FILENAME]);
1968
- const manifest = readManifest(root);
1969
- if (!manifest) {
1970
- warnings.push(`no manifest found at ${manifestPath(root)} \u2014 nothing to uninstall`);
1971
- return { filesRemoved: [], dirsPruned: [], warnings };
1972
- }
1973
- assertManagedPaths(root, [
1974
- ...manifest.files,
1975
- ...manifest.opencodeConfig ? [OPENCODE_CONFIG_PATH] : [],
1976
- ...Object.keys(manifest.hookConfigs ?? {})
1977
- ]);
1978
- const config = planOpenCodeConfig(root, void 0, manifest.opencodeConfig);
1979
- const hooks = planHookConfigCleanup(root, manifest.hookConfigs);
1980
- const sharedHooks = new Set(Object.keys(manifest.hookConfigs ?? {}));
1981
- const ownedFiles = manifest.files.filter(
1982
- (file) => file !== OPENCODE_CONFIG_PATH && !sharedHooks.has(file)
1983
- );
1984
- if (config.remove) ownedFiles.push(OPENCODE_CONFIG_PATH);
1985
- ownedFiles.push(...hooks.removals);
1986
- const { filesRemoved, dirsPruned, missing } = removeRenderedFiles(root, ownedFiles);
1987
- if (config.write) writeFileSync2(join10(root, config.write.path), config.write.content);
1988
- for (const file of hooks.writes) writeFileSync2(join10(root, file.path), file.content);
1989
- warnings.push(...config.warnings, ...hooks.warnings);
1990
- if (manifest.files.includes(OPENCODE_CONFIG_PATH) && !manifest.opencodeConfig) {
1991
- warnings.push(`preserved legacy shared configuration: ${OPENCODE_CONFIG_PATH}`);
1992
- }
1993
- for (const rel of missing) warnings.push(`file in manifest is already missing: ${rel}`);
1994
- unlinkSync(manifestPath(root));
1995
- return { filesRemoved, dirsPruned, warnings };
1705
+ // src/cli/plan-render.ts
1706
+ import { existsSync as existsSync9 } from "fs";
1707
+ import { dirname as dirname3, join as join10, resolve as resolve3 } from "path";
1708
+ import { fileURLToPath } from "url";
1709
+
1710
+ // src/loader.ts
1711
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
1712
+ import { join as join9 } from "path";
1713
+ import fg6 from "fast-glob";
1714
+ import { z as z5 } from "zod";
1715
+
1716
+ // src/parser/common.ts
1717
+ import { readFileSync as readFileSync6 } from "fs";
1718
+ import matter2 from "gray-matter";
1719
+
1720
+ // src/parser/schemas.ts
1721
+ import { z as z4 } from "zod";
1722
+ var IdSchema = z4.string().min(1).regex(/^[a-z][a-z0-9-]*$/, "id must be kebab-case (lowercase + digits + hyphens)");
1723
+ var SkillFrontmatterSchema = z4.object({
1724
+ id: IdSchema,
1725
+ description: z4.string().min(1),
1726
+ engines: z4.array(z4.enum(ENGINES)).min(1).optional(),
1727
+ requires: z4.array(z4.enum(CAPABILITIES)).optional(),
1728
+ tags: z4.array(z4.string()).optional()
1729
+ }).strict();
1730
+ var CmdArgEnumSchema = z4.object({
1731
+ name: z4.string().min(1),
1732
+ type: z4.literal("enum"),
1733
+ values: z4.array(z4.string()).min(1),
1734
+ default: z4.string().optional()
1735
+ }).strict();
1736
+ var CmdArgStringSchema = z4.object({
1737
+ name: z4.string().min(1),
1738
+ type: z4.literal("string"),
1739
+ default: z4.string().optional()
1740
+ }).strict();
1741
+ var CmdArgSchema = z4.discriminatedUnion("type", [CmdArgEnumSchema, CmdArgStringSchema]);
1742
+ var CmdFrontmatterSchema = z4.object({
1743
+ id: IdSchema,
1744
+ trigger: z4.string().regex(/^\/[a-z][a-z0-9-]*$/, "trigger must be /kebab-case"),
1745
+ invokes_skill: IdSchema,
1746
+ args: z4.array(CmdArgSchema).optional(),
1747
+ description: z4.string().min(1)
1748
+ }).strict();
1749
+ var AgentFrontmatterSchema = z4.object({
1750
+ id: z4.enum(AGENT_IDS),
1751
+ description: z4.string().min(1)
1752
+ }).strict();
1753
+ var Schemas = {
1754
+ Skill: SkillFrontmatterSchema,
1755
+ Cmd: CmdFrontmatterSchema,
1756
+ Agent: AgentFrontmatterSchema
1757
+ };
1758
+ var ParseError = class extends Error {
1759
+ constructor(file, issues) {
1760
+ const messages = issues.map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
1761
+ super(`Frontmatter validation failed for ${file}:
1762
+ ${messages}`);
1763
+ this.file = file;
1764
+ this.issues = issues;
1765
+ this.name = "ParseError";
1766
+ }
1767
+ file;
1768
+ issues;
1769
+ };
1770
+
1771
+ // src/parser/common.ts
1772
+ function parseWithSchema(filePath, source, schema) {
1773
+ const parsed = matter2(source);
1774
+ const result = schema.safeParse(parsed.data);
1775
+ if (!result.success) {
1776
+ throw new ParseError(filePath, result.error.issues);
1777
+ }
1778
+ return {
1779
+ meta: result.data,
1780
+ body: parsed.content.trim(),
1781
+ sourcePath: filePath
1782
+ };
1996
1783
  }
1997
- function removeRenderedFiles(root, files) {
1998
- assertManagedPaths(root, files);
1999
- const filesRemoved = [];
2000
- const missing = [];
2001
- const dirsToConsider = /* @__PURE__ */ new Set();
2002
- for (const rel of files) {
2003
- const full = join10(root, rel);
2004
- if (existsSync9(full)) {
2005
- unlinkSync(full);
2006
- filesRemoved.push(rel);
2007
- dirsToConsider.add(dirname3(full));
2008
- } else {
2009
- missing.push(rel);
1784
+
1785
+ // src/parser/agent.ts
1786
+ function parseAgent(filePath, source) {
1787
+ return parseWithSchema(filePath, source, Schemas.Agent);
1788
+ }
1789
+
1790
+ // src/parser/cmd.ts
1791
+ function parseCmd(filePath, source) {
1792
+ return parseWithSchema(filePath, source, Schemas.Cmd);
1793
+ }
1794
+
1795
+ // src/parser/skill.ts
1796
+ function parseSkill(filePath, source) {
1797
+ return parseWithSchema(filePath, source, Schemas.Skill);
1798
+ }
1799
+
1800
+ // src/util/yaml.ts
1801
+ import { parse as yamlParse } from "yaml";
1802
+ function parseYAML(source) {
1803
+ return yamlParse(source);
1804
+ }
1805
+
1806
+ // src/loader.ts
1807
+ var DisableYamlSchema = z5.object({ disable: z5.array(z5.string()).optional() }).passthrough();
1808
+ function loadHarness(packageDir, opts = {}) {
1809
+ const agentsDir = opts.agentsDir ?? join9(packageDir, "agents");
1810
+ const skillsDir = opts.skillsDir ?? join9(packageDir, "skills");
1811
+ const commandsDir = opts.commandsDir ?? join9(packageDir, "commands");
1812
+ const agentFiles = fg6.sync("*.agent.md", { cwd: agentsDir, absolute: true });
1813
+ const skillFiles = fg6.sync("*.skill.md", { cwd: skillsDir, absolute: true });
1814
+ const cmdFiles = fg6.sync("*.cmd.md", { cwd: commandsDir, absolute: true });
1815
+ return {
1816
+ agents: agentFiles.map((f) => parseAgent(f, readFileSync7(f, "utf8"))),
1817
+ skills: skillFiles.map((f) => parseSkill(f, readFileSync7(f, "utf8"))),
1818
+ cmds: cmdFiles.map((f) => parseCmd(f, readFileSync7(f, "utf8")))
1819
+ };
1820
+ }
1821
+ function loadProjectSkills(targetDir) {
1822
+ const skillsDir = join9(targetDir, "docs", "skills");
1823
+ if (!existsSync8(skillsDir)) return [];
1824
+ const skillFiles = fg6.sync("*.skill.md", { cwd: skillsDir, absolute: true });
1825
+ return skillFiles.map((f) => parseSkill(f, readFileSync7(f, "utf8")));
1826
+ }
1827
+ function loadOverrides(worktreeDir) {
1828
+ const overridesDir = join9(worktreeDir, ".harness-overrides");
1829
+ if (!existsSync8(overridesDir)) return void 0;
1830
+ const added = loadHarness(overridesDir, {
1831
+ agentsDir: join9(overridesDir, "agents"),
1832
+ skillsDir: join9(overridesDir, "skills"),
1833
+ commandsDir: join9(overridesDir, "commands")
1834
+ });
1835
+ const disablePath = join9(overridesDir, "disable.yaml");
1836
+ let disable = [];
1837
+ if (existsSync8(disablePath)) {
1838
+ try {
1839
+ const parsed = DisableYamlSchema.safeParse(parseYAML(readFileSync7(disablePath, "utf8")));
1840
+ if (parsed.success && parsed.data.disable) {
1841
+ disable = parsed.data.disable;
1842
+ }
1843
+ } catch {
2010
1844
  }
2011
1845
  }
2012
- return { filesRemoved, dirsPruned: pruneEmptyDirs(root, dirsToConsider), missing };
1846
+ return { add: added, disable };
2013
1847
  }
2014
- function pruneEmptyDirs(root, leaves) {
2015
- const candidates = /* @__PURE__ */ new Set();
2016
- for (const leaf of leaves) {
2017
- let cur = leaf;
2018
- while (cur !== root && cur.startsWith(`${root}/`)) {
2019
- candidates.add(cur);
2020
- cur = dirname3(cur);
1848
+
1849
+ // src/mcp.ts
1850
+ var MCP_SERVER_IDS = ["context7"];
1851
+ var MCP_SERVERS = {
1852
+ context7: {
1853
+ id: "context7",
1854
+ description: "Upstash Context7 \u2014 fetches up-to-date library docs via resolve-library-id + get-library-docs.",
1855
+ command: "npx",
1856
+ args: ["-y", "@upstash/context7-mcp"]
1857
+ }
1858
+ };
1859
+ function resolveMcpIds(requested) {
1860
+ return requested.map((id) => {
1861
+ if (!MCP_SERVER_IDS.includes(id)) {
1862
+ throw new Error(`unknown MCP server: ${id} (expected one of ${MCP_SERVER_IDS.join(", ")})`);
1863
+ }
1864
+ return id;
1865
+ });
1866
+ }
1867
+
1868
+ // src/render.ts
1869
+ var RenderError = class extends Error {
1870
+ constructor(message) {
1871
+ super(message);
1872
+ this.name = "RenderError";
1873
+ }
1874
+ };
1875
+ function mergeById(stock, added) {
1876
+ const result = /* @__PURE__ */ new Map();
1877
+ for (const item of stock) result.set(item.meta.id, item);
1878
+ if (added) {
1879
+ for (const item of added) result.set(item.meta.id, item);
1880
+ }
1881
+ return Array.from(result.values());
1882
+ }
1883
+ function removeByIds(items, disabled) {
1884
+ if (disabled.length === 0) return [...items];
1885
+ const disabledSet = new Set(disabled);
1886
+ return items.filter((item) => !disabledSet.has(item.meta.id));
1887
+ }
1888
+ function applyOverrides(harness, overrides) {
1889
+ const disabled = overrides?.disable ?? [];
1890
+ return {
1891
+ agents: removeByIds(mergeById(harness.agents, overrides?.add?.agents), disabled),
1892
+ skills: removeByIds(mergeById(harness.skills, overrides?.add?.skills), disabled),
1893
+ cmds: removeByIds(mergeById(harness.cmds, overrides?.add?.cmds), disabled)
1894
+ };
1895
+ }
1896
+ var CLAUDE_MD_STUB = "@AGENTS.md\n";
1897
+ function opencodeLast(engines) {
1898
+ return [...engines].sort((a, b) => Number(a === "opencode") - Number(b === "opencode"));
1899
+ }
1900
+ function render(input) {
1901
+ const { engines } = input;
1902
+ if (engines.length === 0) {
1903
+ throw new RenderError("at least one engine must be selected");
1904
+ }
1905
+ const base = input.projectSkills?.length ? { ...input.harness, skills: mergeById(input.harness.skills, input.projectSkills) } : input.harness;
1906
+ const harness = applyOverrides(base, input.overrides);
1907
+ if (harness.agents.length === 0) {
1908
+ throw new RenderError("No agents found in harness");
1909
+ }
1910
+ const files = [];
1911
+ const warnings = [];
1912
+ const exposedSkills = /* @__PURE__ */ new Map();
1913
+ const exposedCmds = /* @__PURE__ */ new Map();
1914
+ for (const engine of opencodeLast(engines)) {
1915
+ const renderer = getRenderer(engine);
1916
+ const renderedSkills = [];
1917
+ for (const skill of harness.skills) {
1918
+ const result = renderer.renderSkill(skill);
1919
+ warnings.push(...result.warnings);
1920
+ if (result.files.length > 0) {
1921
+ const alreadyVisible = engine === "opencode" && exposedSkills.has(skill.meta.id);
1922
+ if (!alreadyVisible) files.push(...result.files);
1923
+ renderedSkills.push(skill);
1924
+ exposedSkills.set(skill.meta.id, skill);
1925
+ }
1926
+ }
1927
+ const renderedSkillIds = new Set(renderedSkills.map((s) => s.meta.id));
1928
+ const renderedCmds = [];
1929
+ for (const cmd of harness.cmds) {
1930
+ if (!renderedSkillIds.has(cmd.meta.invokes_skill)) continue;
1931
+ const result = renderer.renderCmd(cmd);
1932
+ warnings.push(...result.warnings);
1933
+ if (result.files.length > 0) {
1934
+ files.push(...result.files);
1935
+ renderedCmds.push(cmd);
1936
+ exposedCmds.set(cmd.meta.id, cmd);
1937
+ }
1938
+ }
1939
+ const agentsResult = renderer.renderAgents(harness.agents);
1940
+ warnings.push(...agentsResult.warnings);
1941
+ files.push(...agentsResult.files);
1942
+ if (input.mcpServers && input.mcpServers.length > 0) {
1943
+ const specs = input.mcpServers.map((id) => MCP_SERVERS[id]);
1944
+ const mcpResult = renderer.renderMcp(specs);
1945
+ warnings.push(...mcpResult.warnings);
1946
+ files.push(...mcpResult.files);
1947
+ }
1948
+ if (input.withStopHook !== false) {
1949
+ const hookResult = renderer.renderStopHook();
1950
+ warnings.push(...hookResult.warnings);
1951
+ files.push(...hookResult.files);
2021
1952
  }
2022
1953
  }
2023
- const pruned = [];
2024
- const sorted = [...candidates].sort((a, b) => b.length - a.length);
2025
- for (const dir of sorted) {
2026
- if (!existsSync9(dir)) continue;
2027
- if (readdirSync(dir).length > 0) continue;
2028
- rmdirSync(dir);
2029
- pruned.push(relative2(root, dir));
1954
+ const hasProjectSkills = (input.projectSkills?.length ?? 0) > 0;
1955
+ const umbrellaContent = buildUmbrellaBody(harness.agents, {
1956
+ availableSkills: Array.from(exposedSkills.values()),
1957
+ availableCommands: Array.from(exposedCmds.values()),
1958
+ hasProjectSkills,
1959
+ hasQualityDoc: input.hasQualityDoc === true
1960
+ });
1961
+ files.push({ path: "AGENTS.md", content: umbrellaContent });
1962
+ if (engines.includes("claude-code")) {
1963
+ files.push({ path: "CLAUDE.md", content: CLAUDE_MD_STUB });
2030
1964
  }
2031
- return pruned;
1965
+ return { files, warnings };
2032
1966
  }
2033
1967
 
2034
- // src/cli/init.ts
1968
+ // src/cli/plan-render.ts
2035
1969
  var QUALITY_DOC_PATH = "docs/quality/code-quality.md";
2036
1970
  function resolveEngineList(requested) {
2037
- if (!requested || requested.length === 0) return [...ENGINES];
2038
- return requested.map((e) => {
1971
+ if (!requested) return [...ENGINES];
1972
+ if (requested.length === 0) throw new Error("no engine selected");
1973
+ const engines = requested.map((e) => {
2039
1974
  if (!ENGINES.includes(e)) {
2040
1975
  throw new Error(`unknown engine: ${e} (expected one of ${ENGINES.join(", ")})`);
2041
1976
  }
2042
1977
  return e;
2043
1978
  });
1979
+ return [...new Set(engines)];
2044
1980
  }
2045
1981
  function defaultPackageDir() {
2046
- const here = dirname4(fileURLToPath(import.meta.url));
2047
- if (existsSync10(join11(here, "agents"))) return here;
2048
- return resolve4(here, "..");
1982
+ const here = dirname3(fileURLToPath(import.meta.url));
1983
+ if (existsSync9(join10(here, "agents"))) return here;
1984
+ return resolve3(here, "..");
2049
1985
  }
2050
1986
  function planInit(args) {
2051
1987
  const engines = resolveEngineList(args.engines);
@@ -2053,7 +1989,7 @@ function planInit(args) {
2053
1989
  const harness = loadHarness(packageDir);
2054
1990
  const overrides = args.overridesDir ? loadOverrides(args.overridesDir) : void 0;
2055
1991
  const projectSkills = loadProjectSkills(args.targetDir);
2056
- const hasQualityDoc = existsSync10(join11(args.targetDir, QUALITY_DOC_PATH));
1992
+ const hasQualityDoc = existsSync9(join10(args.targetDir, QUALITY_DOC_PATH));
2057
1993
  const mcpServers = args.mcpServers ? resolveMcpIds(args.mcpServers) : void 0;
2058
1994
  const stopHookInstalled = args.withStopHook !== false;
2059
1995
  const output = render({
@@ -2067,338 +2003,302 @@ function planInit(args) {
2067
2003
  });
2068
2004
  return { engines, output, stopHookInstalled };
2069
2005
  }
2070
- function writeFilesToTarget(files, targetDir) {
2071
- assertManagedPaths(
2072
- targetDir,
2073
- files.map((file) => file.path)
2074
- );
2075
- const written = [];
2076
- for (const f of files) {
2077
- const full = join11(targetDir, f.path);
2078
- mkdirSync(dirname4(full), { recursive: true });
2079
- writeFileSync3(full, f.content);
2080
- written.push(f.path);
2006
+
2007
+ // src/cli/uninstall.ts
2008
+ import {
2009
+ existsSync as existsSync10,
2010
+ readFileSync as readFileSync8,
2011
+ readdirSync,
2012
+ rmdirSync,
2013
+ unlinkSync,
2014
+ writeFileSync as writeFileSync2
2015
+ } from "fs";
2016
+ import { dirname as dirname4, join as join11, relative as relative2, resolve as resolve4 } from "path";
2017
+ function uninstallCommand(args) {
2018
+ const root = resolve4(args.targetDir);
2019
+ const warnings = [];
2020
+ assertManagedPaths(root, [MANIFEST_FILENAME]);
2021
+ const manifest = readManifest(root);
2022
+ if (!manifest) {
2023
+ warnings.push(`no manifest found at ${manifestPath(root)} \u2014 nothing to uninstall`);
2024
+ return { filesRemoved: [], dirsPruned: [], warnings };
2081
2025
  }
2082
- return written;
2083
- }
2084
- function initCommand(args) {
2085
- const { engines, output, stopHookInstalled } = planInit(args);
2086
- const previous = readManifest(args.targetDir);
2087
- assertManagedPaths(args.targetDir, [
2088
- MANIFEST_FILENAME,
2089
- ...previous?.files ?? [],
2090
- ...output.files.map((file) => file.path),
2091
- ...previous?.opencodeConfig ? [OPENCODE_CONFIG_PATH] : []
2026
+ assertManagedPaths(root, [
2027
+ ...manifest.files,
2028
+ ...manifest.opencodeConfig ? [OPENCODE_CONFIG_PATH] : [],
2029
+ ...manifest.mcpConfig ? [CLAUDE_MCP_PATH] : [],
2030
+ ...Object.keys(manifest.hookConfigs ?? {})
2092
2031
  ]);
2093
- const desiredConfig = output.files.find((file) => file.path === OPENCODE_CONFIG_PATH);
2094
- const config = planOpenCodeConfig(args.targetDir, desiredConfig, previous?.opencodeConfig);
2095
- const next = new Set(output.files.map((f) => f.path));
2096
- const stale = (previous?.files ?? []).filter((f) => !next.has(f) && f !== OPENCODE_CONFIG_PATH);
2097
- if (config.remove) stale.push(OPENCODE_CONFIG_PATH);
2098
- const staleFilesRemoved = removeRenderedFiles(resolve4(args.targetDir), stale).filesRemoved;
2099
- const ownedFiles = output.files.filter((file) => file.path !== OPENCODE_CONFIG_PATH);
2100
- const filesWritten = writeFilesToTarget(ownedFiles, args.targetDir);
2101
- if (config.write) writeFilesToTarget([config.write], args.targetDir);
2102
- writeManifest(args.targetDir, engines, filesWritten, {
2103
- stopHookInstalled,
2104
- stopHookEngines: stopHookInstalled ? engines : void 0,
2105
- opencodeConfig: config.ownership,
2106
- overridesDir: args.overridesDir ? resolve4(args.overridesDir) === resolve4(args.targetDir) ? "." : resolve4(args.overridesDir) : void 0
2032
+ const config = planOpenCodeConfig(root, void 0, manifest.opencodeConfig);
2033
+ const mcp = planClaudeMcpConfig(root, void 0, manifest.mcpConfig);
2034
+ const hooks = planHookConfigCleanup(root, manifest.hookConfigs);
2035
+ const sharedHooks = new Set(Object.keys(manifest.hookConfigs ?? {}));
2036
+ const modified = manifest.files.filter((file) => {
2037
+ const hash2 = manifest.fileHashes?.[file];
2038
+ const full = join11(root, file);
2039
+ return hash2 !== void 0 && existsSync10(full) && hashContent(readFileSync8(full)) !== hash2;
2107
2040
  });
2108
- if (config.write) filesWritten.push(OPENCODE_CONFIG_PATH);
2109
- const warnings = [...output.warnings, ...config.warnings];
2110
- if (previous?.files.includes(OPENCODE_CONFIG_PATH) && !previous.opencodeConfig) {
2041
+ const ownedFiles = manifest.files.filter(
2042
+ (file) => file !== OPENCODE_CONFIG_PATH && !(manifest.mcpConfig && file === CLAUDE_MCP_PATH) && !sharedHooks.has(file) && !modified.includes(file)
2043
+ );
2044
+ if (config.remove) ownedFiles.push(OPENCODE_CONFIG_PATH);
2045
+ if (mcp.remove) ownedFiles.push(CLAUDE_MCP_PATH);
2046
+ ownedFiles.push(...hooks.removals);
2047
+ const { filesRemoved, dirsPruned, missing } = removeRenderedFiles(root, ownedFiles);
2048
+ for (const file of [config.write, mcp.write, ...hooks.writes]) {
2049
+ if (file) writeFileSync2(join11(root, file.path), file.content);
2050
+ }
2051
+ warnings.push(...config.warnings, ...mcp.warnings, ...hooks.warnings);
2052
+ for (const file of modified) warnings.push(`kept modified harness file: ${file}`);
2053
+ if (manifest.files.includes(OPENCODE_CONFIG_PATH) && !manifest.opencodeConfig) {
2111
2054
  warnings.push(`preserved legacy shared configuration: ${OPENCODE_CONFIG_PATH}`);
2112
2055
  }
2113
- return { filesWritten, staleFilesRemoved, warnings, stopHookInstalled };
2056
+ for (const rel of missing) warnings.push(`file in manifest is already missing: ${rel}`);
2057
+ unlinkSync(manifestPath(root));
2058
+ return { filesRemoved, dirsPruned, warnings };
2114
2059
  }
2115
-
2116
- // src/cli/render.ts
2117
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
2118
- import { dirname as dirname5, join as join12 } from "path";
2119
- function renderCommand(args) {
2120
- const engineList = args.engines && args.engines.length > 0 ? args.engines.map((e) => {
2121
- if (!ENGINES.includes(e)) {
2122
- throw new Error(`unknown engine: ${e}`);
2060
+ function removeRenderedFiles(root, files) {
2061
+ assertManagedPaths(root, files);
2062
+ const filesRemoved = [];
2063
+ const missing = [];
2064
+ const dirsToConsider = /* @__PURE__ */ new Set();
2065
+ for (const rel of files) {
2066
+ const full = join11(root, rel);
2067
+ if (existsSync10(full)) {
2068
+ unlinkSync(full);
2069
+ filesRemoved.push(rel);
2070
+ dirsToConsider.add(dirname4(full));
2071
+ } else {
2072
+ missing.push(rel);
2123
2073
  }
2124
- return e;
2125
- }) : [...ENGINES];
2126
- const harness = loadHarness(args.packageDir);
2127
- const mcpServers = args.mcpServers ? resolveMcpIds(args.mcpServers) : void 0;
2128
- const output = render({
2129
- harness,
2130
- engines: engineList,
2131
- mcpServers,
2132
- withStopHook: args.withStopHook !== false
2133
- });
2134
- const filesWritten = [];
2135
- for (const f of output.files) {
2136
- const full = join12(args.outDir, f.path);
2137
- mkdirSync2(dirname5(full), { recursive: true });
2138
- writeFileSync4(full, f.content);
2139
- filesWritten.push(f.path);
2140
2074
  }
2141
- return { filesWritten, warnings: output.warnings };
2075
+ return { filesRemoved, dirsPruned: pruneEmptyDirs(root, dirsToConsider), missing };
2142
2076
  }
2143
-
2144
- // src/cli/stop-hook.ts
2145
- import { readFileSync as readFileSync9 } from "fs";
2146
- import { resolve as resolve5 } from "path";
2147
-
2148
- // src/stop-hook/adapters/claude.ts
2149
- var claudeAdapter = {
2150
- parseInput(stdin) {
2151
- const parsed = stdin.trim() === "" ? {} : JSON.parse(stdin);
2152
- return {
2153
- sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "default",
2154
- stopHookActive: parsed.stop_hook_active === true
2155
- };
2156
- },
2157
- respond(decision) {
2158
- if (decision.kind === "inject") {
2159
- return {
2160
- stdout: `${JSON.stringify({ decision: "block", reason: decision.reason ?? "" })}
2161
- `,
2162
- stderr: "",
2163
- exitCode: 0
2164
- };
2165
- }
2166
- if (decision.kind === "terminate") {
2167
- return {
2168
- stdout: "",
2169
- stderr: `${decision.reason ?? ""}
2170
- `,
2171
- exitCode: 0
2172
- };
2173
- }
2174
- return { stdout: "", stderr: "", exitCode: 0 };
2175
- }
2176
- };
2177
-
2178
- // src/stop-hook/adapters/codex.ts
2179
- var codexAdapter = {
2180
- parseInput(stdin) {
2181
- const parsed = stdin.trim() === "" ? {} : JSON.parse(stdin);
2182
- return {
2183
- sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "default",
2184
- stopHookActive: parsed.stop_hook_active === true
2185
- };
2186
- },
2187
- respond(decision) {
2188
- if (decision.kind === "inject") {
2189
- return {
2190
- stdout: `${JSON.stringify({ decision: "block", reason: decision.reason ?? "" })}
2191
- `,
2192
- stderr: "",
2193
- exitCode: 0
2194
- };
2195
- }
2196
- if (decision.kind === "terminate") {
2197
- return {
2198
- stdout: "",
2199
- stderr: `${decision.reason ?? ""}
2200
- `,
2201
- exitCode: 0
2202
- };
2077
+ function pruneEmptyDirs(root, leaves) {
2078
+ const candidates = /* @__PURE__ */ new Set();
2079
+ for (const leaf of leaves) {
2080
+ let cur = leaf;
2081
+ while (cur !== root && cur.startsWith(`${root}/`)) {
2082
+ candidates.add(cur);
2083
+ cur = dirname4(cur);
2203
2084
  }
2204
- return { stdout: "", stderr: "", exitCode: 0 };
2205
2085
  }
2206
- };
2207
-
2208
- // src/stop-hook/adapters/opencode.ts
2209
- var opencodeAdapter = {
2210
- parseInput(stdin) {
2211
- const parsed = stdin.trim() === "" ? {} : JSON.parse(stdin);
2212
- const sessionId = typeof parsed.sessionID === "string" ? parsed.sessionID : typeof parsed.session_id === "string" ? parsed.session_id : "default";
2213
- return { sessionId, stopHookActive: false };
2214
- },
2215
- respond(decision) {
2216
- if (decision.kind === "inject") {
2217
- return { stdout: "", stderr: `${decision.reason ?? ""}
2218
- `, exitCode: 2 };
2219
- }
2220
- if (decision.kind === "terminate") {
2221
- return { stdout: "", stderr: `${decision.reason ?? ""}
2222
- `, exitCode: 0 };
2223
- }
2224
- return { stdout: "", stderr: "", exitCode: 0 };
2086
+ const pruned = [];
2087
+ const sorted = [...candidates].sort((a, b) => b.length - a.length);
2088
+ for (const dir of sorted) {
2089
+ if (!existsSync10(dir)) continue;
2090
+ if (readdirSync(dir).length > 0) continue;
2091
+ rmdirSync(dir);
2092
+ pruned.push(relative2(root, dir));
2225
2093
  }
2226
- };
2227
-
2228
- // src/stop-hook/adapters/index.ts
2229
- var ADAPTERS = {
2230
- claude: claudeAdapter,
2231
- codex: codexAdapter,
2232
- opencode: opencodeAdapter
2233
- };
2234
- function getAdapter(engine) {
2235
- return ADAPTERS[engine];
2094
+ return pruned;
2236
2095
  }
2237
2096
 
2238
- // src/stop-hook/core.ts
2239
- import { execFileSync as execFileSync2 } from "child_process";
2240
- import { createHash as createHash2 } from "crypto";
2241
-
2242
- // src/stop-hook/state.ts
2243
- import { existsSync as existsSync11, mkdirSync as mkdirSync3, readFileSync as readFileSync8, rmSync, writeFileSync as writeFileSync5 } from "fs";
2244
- import { dirname as dirname6, join as join13 } from "path";
2245
- import { z as z6 } from "zod";
2246
- var LintStateSchema = z6.object({
2247
- attempts: z6.number().int().nonnegative(),
2248
- lastLintHash: z6.string(),
2249
- updatedAt: z6.string()
2250
- });
2251
- var STATE_DIR = ".harness/state";
2252
- function safeSessionId(sessionId) {
2253
- return sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80) || "default";
2254
- }
2255
- function statePath(cwd, sessionId) {
2256
- return join13(cwd, STATE_DIR, `lint-${safeSessionId(sessionId)}.json`);
2257
- }
2258
- function readState(cwd, sessionId) {
2259
- const path = statePath(cwd, sessionId);
2260
- if (!existsSync11(path)) return null;
2261
- try {
2262
- const raw = JSON.parse(readFileSync8(path, "utf8"));
2263
- return LintStateSchema.parse(raw);
2264
- } catch {
2265
- return null;
2097
+ // src/cli/update-transaction.ts
2098
+ import { createHash as createHash2, randomUUID } from "crypto";
2099
+ import {
2100
+ chmodSync as chmodSync2,
2101
+ closeSync,
2102
+ fsyncSync,
2103
+ lstatSync as lstatSync2,
2104
+ mkdirSync,
2105
+ openSync,
2106
+ readFileSync as readFileSync9,
2107
+ readdirSync as readdirSync2,
2108
+ realpathSync as realpathSync2,
2109
+ rmdirSync as rmdirSync2,
2110
+ unlinkSync as unlinkSync2,
2111
+ writeFileSync as writeFileSync3
2112
+ } from "fs";
2113
+ import { homedir } from "os";
2114
+ import { basename as basename3, dirname as dirname5, isAbsolute as isAbsolute3, join as join12, relative as relative3, resolve as resolve5, sep } from "path";
2115
+ var MANIFEST_PATH = ".harness-manifest.json";
2116
+ var PRIVATE_FILES = /* @__PURE__ */ new Set([
2117
+ MANIFEST_PATH,
2118
+ ".mcp.json",
2119
+ ".codex/config.toml",
2120
+ ".codex/hooks.json",
2121
+ ".claude/settings.json",
2122
+ ".opencode/opencode.json",
2123
+ ".opencode/mcp.json"
2124
+ ]);
2125
+ function canonicalRoot(root) {
2126
+ let ancestor = resolve5(root);
2127
+ const missing = [];
2128
+ while (!lstatSync2(ancestor, { throwIfNoEntry: false })) {
2129
+ missing.unshift(basename3(ancestor));
2130
+ ancestor = dirname5(ancestor);
2266
2131
  }
2132
+ return join12(realpathSync2(ancestor), ...missing);
2267
2133
  }
2268
- function writeState(cwd, sessionId, state) {
2269
- const path = statePath(cwd, sessionId);
2270
- mkdirSync3(dirname6(path), { recursive: true });
2271
- writeFileSync5(path, `${JSON.stringify(state, null, 2)}
2272
- `);
2273
- }
2274
- function resetState(cwd, sessionId) {
2275
- const path = statePath(cwd, sessionId);
2276
- if (existsSync11(path)) rmSync(path, { force: true });
2277
- }
2278
-
2279
- // src/stop-hook/types.ts
2280
- var STOP_HOOK_ENGINES = ["claude", "codex", "opencode"];
2281
- var DEFAULT_MAX_ATTEMPTS = 3;
2282
-
2283
- // src/stop-hook/core.ts
2284
- function exhaustedReason(attempts, lintOutput) {
2285
- return `Harness lint: still failing after ${attempts} attempts.
2286
-
2287
- ${lintOutput}`;
2288
- }
2289
- function noProgressReason(lintOutput) {
2290
- return `Harness lint: no progress between attempts.
2291
-
2292
- ${lintOutput}`;
2134
+ function contains(parent, child) {
2135
+ const path = relative3(parent, child);
2136
+ return path === "" || !isAbsolute3(path) && path !== ".." && !path.startsWith(`..${sep}`);
2293
2137
  }
2294
- function hash(s) {
2295
- return createHash2("sha256").update(s).digest("hex").slice(0, 16);
2138
+ function assertBackupLocation(root, backup) {
2139
+ if (contains(root, backup) || contains(backup, root)) {
2140
+ throw new Error(`update backup must be outside the target project: ${backup}`);
2141
+ }
2142
+ const ancestors = [];
2143
+ for (let path = backup; ; path = dirname5(path)) {
2144
+ ancestors.unshift(path);
2145
+ if (dirname5(path) === path) break;
2146
+ }
2147
+ for (const path of ancestors) {
2148
+ const info = lstatSync2(path, { throwIfNoEntry: false });
2149
+ if (!info) continue;
2150
+ if (info.isSymbolicLink() || !info.isDirectory()) {
2151
+ throw new Error(`unsafe update backup directory: ${path}`);
2152
+ }
2153
+ }
2154
+ if (lstatSync2(backup, { throwIfNoEntry: false })) {
2155
+ throw new Error(`update backup directory already exists: ${backup}`);
2156
+ }
2296
2157
  }
2297
- function maxAttempts(env) {
2298
- const raw = env.HARNESS_LINT_MAX_ATTEMPTS;
2299
- if (!raw) return DEFAULT_MAX_ATTEMPTS;
2300
- const n = Number.parseInt(raw, 10);
2301
- return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_ATTEMPTS;
2158
+ function createDirectories(path, created, mode = 511) {
2159
+ const missing = [];
2160
+ for (let current = path; !lstatSync2(current, { throwIfNoEntry: false }); current = dirname5(current)) {
2161
+ missing.unshift(current);
2162
+ }
2163
+ for (const directory of missing) {
2164
+ mkdirSync(directory, { mode });
2165
+ created.push(directory);
2166
+ }
2302
2167
  }
2303
- function defaultGitStatus(cwd) {
2168
+ function writeBackupFile(path, content) {
2169
+ createDirectories(dirname5(path), [], 448);
2170
+ const fd = openSync(path, "wx", 384);
2304
2171
  try {
2305
- return execFileSync2("git", ["status", "--porcelain"], {
2306
- cwd,
2307
- encoding: "utf8",
2308
- stdio: ["ignore", "pipe", "ignore"]
2309
- });
2310
- } catch {
2311
- return "";
2172
+ writeFileSync3(fd, content);
2173
+ fsyncSync(fd);
2174
+ } finally {
2175
+ closeSync(fd);
2312
2176
  }
2313
2177
  }
2314
- function defaultRunLint(cwd) {
2315
- const report = runLinters(cwd);
2316
- return { pass: report.summary.pass, output: formatReportText(report) };
2317
- }
2318
- function decide(payload, coreEnv) {
2319
- if (coreEnv.env.HARNESS_LINT_DISABLE === "1") {
2320
- return { kind: "passthrough" };
2321
- }
2322
- if (payload.stopHookActive) {
2323
- return { kind: "passthrough" };
2324
- }
2325
- const dirty = coreEnv.gitStatus(coreEnv.cwd);
2326
- if (dirty.trim() === "") {
2327
- resetState(coreEnv.cwd, payload.sessionId);
2328
- return { kind: "passthrough" };
2329
- }
2330
- const lintResult = coreEnv.runLint(coreEnv.cwd);
2331
- if (lintResult.pass) {
2332
- resetState(coreEnv.cwd, payload.sessionId);
2333
- return { kind: "passthrough" };
2178
+ function saveSnapshot(root, paths, backup) {
2179
+ const entries = [];
2180
+ createDirectories(dirname5(backup), [], 448);
2181
+ mkdirSync(backup, { mode: 448 });
2182
+ for (const path of paths) {
2183
+ const full = join12(root, path);
2184
+ const info = lstatSync2(full, { throwIfNoEntry: false });
2185
+ if (!info) {
2186
+ entries.push({ path, exists: false });
2187
+ continue;
2188
+ }
2189
+ const content = readFileSync9(full);
2190
+ writeBackupFile(join12(backup, "files", path), content);
2191
+ entries.push({
2192
+ path,
2193
+ exists: true,
2194
+ mode: info.mode & 4095,
2195
+ sha256: createHash2("sha256").update(content).digest("hex")
2196
+ });
2334
2197
  }
2335
- const lintHash = hash(lintResult.output);
2336
- const prev = readState(coreEnv.cwd, payload.sessionId);
2337
- if (prev && prev.attempts > 0 && prev.lastLintHash === lintHash) {
2338
- resetState(coreEnv.cwd, payload.sessionId);
2339
- return { kind: "terminate", reason: noProgressReason(lintResult.output) };
2198
+ writeBackupFile(
2199
+ join12(backup, "snapshot.json"),
2200
+ `${JSON.stringify({ version: 1, root, createdAt: (/* @__PURE__ */ new Date()).toISOString(), entries }, null, 2)}
2201
+ `
2202
+ );
2203
+ return entries;
2204
+ }
2205
+ function errorMessage(error) {
2206
+ return error instanceof Error ? error.message : String(error);
2207
+ }
2208
+ function rollback(root, backup, entries, touched, createdDirectories) {
2209
+ const errors = [];
2210
+ for (const path of [...touched].reverse()) {
2211
+ try {
2212
+ assertManagedPaths(root, [path]);
2213
+ const entry = entries.get(path);
2214
+ const full = join12(root, path);
2215
+ const current = lstatSync2(full, { throwIfNoEntry: false });
2216
+ if (!entry?.exists) {
2217
+ if (current) unlinkSync2(full);
2218
+ continue;
2219
+ }
2220
+ const original = readFileSync9(join12(backup, "files", path));
2221
+ if (!current || !readFileSync9(full).equals(original)) {
2222
+ if (current) unlinkSync2(full);
2223
+ writeFileSync3(full, original, { mode: 384 });
2224
+ }
2225
+ chmodSync2(full, entry.mode);
2226
+ } catch (error) {
2227
+ errors.push(`${path}: ${errorMessage(error)}`);
2228
+ }
2340
2229
  }
2341
- const nextAttempts = (prev?.attempts ?? 0) + 1;
2342
- const limit = maxAttempts(coreEnv.env);
2343
- if (nextAttempts > limit) {
2344
- resetState(coreEnv.cwd, payload.sessionId);
2345
- return { kind: "terminate", reason: exhaustedReason(limit, lintResult.output) };
2230
+ for (const directory of [...createdDirectories].reverse()) {
2231
+ try {
2232
+ if (readdirSync2(directory).length === 0) rmdirSync2(directory);
2233
+ } catch (error) {
2234
+ errors.push(`${directory}: ${errorMessage(error)}`);
2235
+ }
2346
2236
  }
2347
- writeState(coreEnv.cwd, payload.sessionId, {
2348
- attempts: nextAttempts,
2349
- lastLintHash: lintHash,
2350
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2351
- });
2352
- return { kind: "inject", reason: lintResult.output };
2237
+ return errors;
2353
2238
  }
2354
-
2355
- // src/cli/stop-hook.ts
2356
- function readStdinSync() {
2239
+ function applyUpdateTransaction(root, writes, removals, backupDir) {
2240
+ const paths = [.../* @__PURE__ */ new Set([...removals, ...writes.map((file) => file.path)])];
2241
+ assertManagedPaths(root, paths);
2242
+ const target = canonicalRoot(root);
2243
+ const backup = resolve5(
2244
+ backupDir ?? join12(homedir(), ".local", "state", "vaubaan-harness", "backups", randomUUID())
2245
+ );
2246
+ assertBackupLocation(target, backup);
2247
+ let snapshot;
2357
2248
  try {
2358
- return readFileSync9(0, "utf8");
2359
- } catch {
2360
- return "";
2249
+ snapshot = saveSnapshot(target, paths, backup);
2250
+ } catch (error) {
2251
+ throw new Error(
2252
+ `could not prepare update backup at ${backup}; project unchanged: ${errorMessage(error)}`,
2253
+ { cause: error }
2254
+ );
2361
2255
  }
2362
- }
2363
- function assertEngine(engine) {
2364
- if (!STOP_HOOK_ENGINES.includes(engine)) {
2365
- throw new Error(`unknown engine: ${engine} (expected one of ${STOP_HOOK_ENGINES.join(", ")})`);
2256
+ const entries = new Map(snapshot.map((entry) => [entry.path, entry]));
2257
+ const touched = /* @__PURE__ */ new Set();
2258
+ const createdDirectories = [];
2259
+ try {
2260
+ assertManagedPaths(target, paths);
2261
+ for (const path of removals) {
2262
+ if (!lstatSync2(join12(target, path), { throwIfNoEntry: false })) continue;
2263
+ touched.add(path);
2264
+ unlinkSync2(join12(target, path));
2265
+ }
2266
+ for (const file of writes) {
2267
+ const full = join12(target, file.path);
2268
+ createDirectories(dirname5(full), createdDirectories);
2269
+ touched.add(file.path);
2270
+ const entry = entries.get(file.path);
2271
+ const mode = file.path === MANIFEST_PATH ? 384 : entry?.exists ? entry.mode : PRIVATE_FILES.has(file.path) ? 384 : 420;
2272
+ if (entry?.exists && file.path === MANIFEST_PATH) chmodSync2(full, mode);
2273
+ writeFileSync3(full, file.content, { mode });
2274
+ if (entry?.exists) chmodSync2(full, mode);
2275
+ }
2276
+ } catch (error) {
2277
+ const failures = rollback(target, backup, entries, touched, createdDirectories);
2278
+ const restoration = failures.length > 0 ? `rollback errors: ${failures.join("; ")}` : "rollback completed";
2279
+ throw new Error(`update failed; backup: ${backup}; ${restoration}: ${errorMessage(error)}`, {
2280
+ cause: error
2281
+ });
2366
2282
  }
2367
- return engine;
2368
- }
2369
- function stopHookCommand(args, stdin) {
2370
- const engine = assertEngine(args.engine);
2371
- const cwd = resolve5(args.cwd ?? ".");
2372
- const adapter = getAdapter(engine);
2373
- const payload = adapter.parseInput(stdin, process.env);
2374
- const decision = decide(payload, {
2375
- cwd,
2376
- env: process.env,
2377
- gitStatus: defaultGitStatus,
2378
- runLint: defaultRunLint
2379
- });
2380
- return adapter.respond(decision);
2381
- }
2382
- function stopHookCommandFromStdin(args) {
2383
- return stopHookCommand(args, readStdinSync());
2283
+ return { backupDir: backup };
2384
2284
  }
2385
2285
 
2386
2286
  // src/cli/update.ts
2387
- import { existsSync as existsSync13, readFileSync as readFileSync12 } from "fs";
2388
- import { join as join16, resolve as resolve7 } from "path";
2287
+ import { existsSync as existsSync12, readFileSync as readFileSync11 } from "fs";
2288
+ import { join as join14, resolve as resolve6 } from "path";
2389
2289
  import { isDeepStrictEqual as isDeepStrictEqual3 } from "util";
2390
2290
 
2391
2291
  // src/cli/update-manifest.ts
2392
- import { existsSync as existsSync12, readFileSync as readFileSync10 } from "fs";
2393
- import { join as join14 } from "path";
2394
- import { z as z7 } from "zod";
2395
- var EngineSchema = z7.enum(ENGINES);
2396
- var LegacySchema = z7.object({
2397
- version: z7.enum(["1", "2", "3"]),
2292
+ import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs";
2293
+ import { join as join13 } from "path";
2294
+ import { z as z6 } from "zod";
2295
+ var EngineSchema = z6.enum(ENGINES);
2296
+ var LegacySchema = z6.object({
2297
+ version: z6.enum(["1", "2", "3"]),
2398
2298
  engine: EngineSchema.optional(),
2399
- engines: z7.array(EngineSchema).nonempty().optional(),
2400
- renderedAt: z7.string(),
2401
- files: z7.array(z7.string())
2299
+ engines: z6.array(EngineSchema).nonempty().optional(),
2300
+ renderedAt: z6.string(),
2301
+ files: z6.array(z6.string())
2402
2302
  });
2403
2303
  var HOOK_PATHS = {
2404
2304
  "claude-code": [".claude/settings.json"],
@@ -2410,10 +2310,10 @@ var HOOK_PATHS = {
2410
2310
  };
2411
2311
  function readUpdateInstallation(root) {
2412
2312
  assertManagedPaths(root, [MANIFEST_FILENAME]);
2413
- if (!existsSync12(join14(root, MANIFEST_FILENAME))) {
2313
+ if (!existsSync11(join13(root, MANIFEST_FILENAME))) {
2414
2314
  throw new Error("no harness manifest found; use init for a new installation");
2415
2315
  }
2416
- const raw = JSON.parse(readFileSync10(join14(root, MANIFEST_FILENAME), "utf8"));
2316
+ const raw = JSON.parse(readFileSync10(join13(root, MANIFEST_FILENAME), "utf8"));
2417
2317
  const sourceVersion = String(raw?.version);
2418
2318
  let manifest;
2419
2319
  if (["1", "2", "3"].includes(sourceVersion)) {
@@ -2421,7 +2321,7 @@ function readUpdateInstallation(root) {
2421
2321
  const engines2 = legacy.version === "1" ? [EngineSchema.parse(legacy.engine)] : legacy.engines;
2422
2322
  manifest = {
2423
2323
  version: "4",
2424
- engines: z7.array(EngineSchema).nonempty().parse(engines2),
2324
+ engines: z6.array(EngineSchema).nonempty().parse(engines2),
2425
2325
  files: legacy.files,
2426
2326
  renderedAt: legacy.renderedAt
2427
2327
  };
@@ -2430,8 +2330,8 @@ function readUpdateInstallation(root) {
2430
2330
  if (!current) throw new Error("harness manifest disappeared during planning");
2431
2331
  manifest = current;
2432
2332
  }
2433
- const engines = [...new Set(z7.array(EngineSchema).nonempty().parse(manifest.engines))];
2434
- const hookEngines = manifest.stopHookInstalled === false ? [] : manifest.stopHookEngines !== void 0 ? z7.array(EngineSchema).parse(manifest.stopHookEngines) : manifest.stopHookInstalled === true ? engines : engines.filter(
2333
+ const engines = [...new Set(z6.array(EngineSchema).nonempty().parse(manifest.engines))];
2334
+ const hookEngines = manifest.stopHookInstalled === false ? [] : manifest.stopHookEngines !== void 0 ? z6.array(EngineSchema).parse(manifest.stopHookEngines) : manifest.stopHookInstalled === true ? engines : engines.filter(
2435
2335
  (engine) => HOOK_PATHS[engine].some((path) => manifest.files.includes(path))
2436
2336
  );
2437
2337
  if (hookEngines.some((engine) => !engines.includes(engine))) {
@@ -2440,335 +2340,556 @@ function readUpdateInstallation(root) {
2440
2340
  return { manifest, sourceVersion, engines, hookEngines: [...new Set(hookEngines)] };
2441
2341
  }
2442
2342
 
2443
- // src/cli/update-transaction.ts
2444
- import { createHash as createHash3, randomUUID } from "crypto";
2445
- import {
2446
- chmodSync as chmodSync2,
2447
- closeSync,
2448
- fsyncSync,
2449
- lstatSync as lstatSync2,
2450
- mkdirSync as mkdirSync4,
2451
- openSync,
2452
- readFileSync as readFileSync11,
2453
- readdirSync as readdirSync2,
2454
- realpathSync as realpathSync2,
2455
- rmdirSync as rmdirSync2,
2456
- unlinkSync as unlinkSync2,
2457
- writeFileSync as writeFileSync6
2458
- } from "fs";
2459
- import { homedir } from "os";
2460
- import { basename as basename3, dirname as dirname7, isAbsolute as isAbsolute3, join as join15, relative as relative3, resolve as resolve6, sep } from "path";
2461
- var MANIFEST_PATH = ".harness-manifest.json";
2462
- var PRIVATE_FILES = /* @__PURE__ */ new Set([
2463
- MANIFEST_PATH,
2464
- ".mcp.json",
2465
- ".codex/config.toml",
2466
- ".codex/hooks.json",
2467
- ".claude/settings.json",
2468
- ".opencode/opencode.json",
2469
- ".opencode/mcp.json"
2470
- ]);
2471
- function canonicalRoot(root) {
2472
- let ancestor = resolve6(root);
2473
- const missing = [];
2474
- while (!lstatSync2(ancestor, { throwIfNoEntry: false })) {
2475
- missing.unshift(basename3(ancestor));
2476
- ancestor = dirname7(ancestor);
2477
- }
2478
- return join15(realpathSync2(ancestor), ...missing);
2343
+ // src/cli/update.ts
2344
+ var CODEX_CONFIG_PATH = ".codex/config.toml";
2345
+ function currentContent(root, path) {
2346
+ return existsSync12(join14(root, path)) ? readFileSync11(join14(root, path), "utf8") : void 0;
2479
2347
  }
2480
- function contains(parent, child) {
2481
- const path = relative3(parent, child);
2482
- return path === "" || !isAbsolute3(path) && path !== ".." && !path.startsWith(`..${sep}`);
2348
+ function ownedUnchanged(root, previous, path) {
2349
+ const content = currentContent(root, path);
2350
+ if (content === void 0 || !previous.files.includes(path)) return false;
2351
+ const hash2 = previous.fileHashes?.[path];
2352
+ return hash2 === void 0 || hash2 === hashContent(content);
2483
2353
  }
2484
- function assertBackupLocation(root, backup) {
2485
- if (contains(root, backup) || contains(backup, root)) {
2486
- throw new Error(`update backup must be outside the target project: ${backup}`);
2354
+ function checkArtifactConflicts(root, previous, writes, removals) {
2355
+ const desired = new Map(writes.map((file) => [file.path, file.content]));
2356
+ const conflicts = [];
2357
+ for (const path of [...desired.keys(), ...removals]) {
2358
+ if (SHARED_CONFIG_PATHS.includes(path)) continue;
2359
+ const content = currentContent(root, path);
2360
+ if (content === void 0 || content === desired.get(path)) continue;
2361
+ if (!previous.files.includes(path)) {
2362
+ conflicts.push(`existing file outside harness manifest: ${path}`);
2363
+ continue;
2364
+ }
2365
+ const hash2 = previous.fileHashes?.[path];
2366
+ if (hash2 && hash2 !== hashContent(content)) conflicts.push(`modified harness file: ${path}`);
2487
2367
  }
2488
- const ancestors = [];
2489
- for (let path = backup; ; path = dirname7(path)) {
2490
- ancestors.unshift(path);
2491
- if (dirname7(path) === path) break;
2368
+ if (conflicts.length) {
2369
+ throw new Error(
2370
+ `${conflicts.join("\n")}
2371
+ move their content to docs/ or .harness-overrides/, or keep them as yours with --preserve <path>`
2372
+ );
2492
2373
  }
2493
- for (const path of ancestors) {
2494
- const info = lstatSync2(path, { throwIfNoEntry: false });
2495
- if (!info) continue;
2496
- if (info.isSymbolicLink() || !info.isDirectory()) {
2497
- throw new Error(`unsafe update backup directory: ${path}`);
2498
- }
2374
+ }
2375
+ function legacyMcpOwnership(root, previous) {
2376
+ if (!ownedUnchanged(root, previous, CLAUDE_MCP_PATH)) return void 0;
2377
+ try {
2378
+ const servers = JSON.parse(currentContent(root, CLAUDE_MCP_PATH) ?? "").mcpServers;
2379
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) return void 0;
2380
+ return { createdFile: true, addedMcp: true, addedSchema: false, entries: servers };
2381
+ } catch {
2382
+ return void 0;
2499
2383
  }
2500
- if (lstatSync2(backup, { throwIfNoEntry: false })) {
2501
- throw new Error(`update backup directory already exists: ${backup}`);
2384
+ }
2385
+ function planMcp(root, settings, rendered, pendingWrites) {
2386
+ const { previous } = settings;
2387
+ const plan = {
2388
+ writes: [],
2389
+ removals: [],
2390
+ owned: [],
2391
+ plannedOpenCode: false,
2392
+ warnings: []
2393
+ };
2394
+ const claudeOwnership = previous.mcpConfig ?? legacyMcpOwnership(root, previous);
2395
+ const codexOwned = ownedUnchanged(root, previous, CODEX_CONFIG_PATH);
2396
+ if (settings.mcpServers === void 0) {
2397
+ if (codexOwned) plan.owned.push(CODEX_CONFIG_PATH);
2398
+ plan.mcpConfig = claudeOwnership;
2399
+ return plan;
2400
+ }
2401
+ const desired = (path) => rendered.find((file) => file.path === path);
2402
+ if (!pendingWrites.some((file) => file.path === OPENCODE_CONFIG_PATH)) {
2403
+ plan.plannedOpenCode = true;
2404
+ if (previous.files.includes(OPENCODE_CONFIG_PATH) && !previous.opencodeConfig) {
2405
+ plan.warnings.push(`preserved legacy shared configuration: ${OPENCODE_CONFIG_PATH}`);
2406
+ }
2407
+ const opencode = planOpenCodeConfig(
2408
+ root,
2409
+ desired(OPENCODE_CONFIG_PATH),
2410
+ previous.opencodeConfig
2411
+ );
2412
+ if (opencode.write) plan.writes.push(opencode.write);
2413
+ if (opencode.remove) plan.removals.push(OPENCODE_CONFIG_PATH);
2414
+ plan.opencodeConfig = opencode.ownership;
2415
+ plan.warnings.push(...opencode.warnings);
2416
+ }
2417
+ const claude = planClaudeMcpConfig(root, desired(CLAUDE_MCP_PATH), claudeOwnership);
2418
+ if (claude.write) plan.writes.push(claude.write);
2419
+ if (claude.remove) plan.removals.push(CLAUDE_MCP_PATH);
2420
+ plan.mcpConfig = claude.ownership;
2421
+ plan.warnings.push(...claude.warnings);
2422
+ const codex = desired(CODEX_CONFIG_PATH);
2423
+ const codexExists = existsSync12(join14(root, CODEX_CONFIG_PATH));
2424
+ if (codex && (!codexExists || codexOwned)) {
2425
+ plan.writes.push(codex);
2426
+ plan.owned.push(CODEX_CONFIG_PATH);
2427
+ } else if (codex) {
2428
+ plan.warnings.push(
2429
+ `left ${CODEX_CONFIG_PATH} unchanged; add this block to it to enable MCP in Codex:
2430
+ ${codex.content.trimEnd()}`
2431
+ );
2432
+ } else if (codexOwned) {
2433
+ plan.removals.push(CODEX_CONFIG_PATH);
2502
2434
  }
2435
+ return plan;
2503
2436
  }
2504
- function createDirectories(path, created, mode = 511) {
2505
- const missing = [];
2506
- for (let current = path; !lstatSync2(current, { throwIfNoEntry: false }); current = dirname7(current)) {
2507
- missing.unshift(current);
2437
+ function planInstallation(args, settings) {
2438
+ const root = resolve6(args.targetDir);
2439
+ const { previous, engines, hookEngines } = settings;
2440
+ const preserved = [
2441
+ .../* @__PURE__ */ new Set([...previous.preservedFiles ?? [], ...args.preserveFiles ?? []])
2442
+ ].sort();
2443
+ if (preserved.includes(MANIFEST_FILENAME))
2444
+ throw new Error("cannot preserve the harness manifest");
2445
+ if (preserved.some((path) => SHARED_CONFIG_PATHS.includes(path))) {
2446
+ throw new Error(
2447
+ "--preserve applies to generated artifacts; shared configurations use entry-level preservation"
2448
+ );
2508
2449
  }
2509
- for (const directory of missing) {
2510
- mkdirSync4(directory, { mode });
2511
- created.push(directory);
2450
+ assertManagedPaths(root, [...previous.files, ...preserved, ...SHARED_CONFIG_PATHS]);
2451
+ const requestedOverrides = args.overridesDir ?? previous.overridesDir;
2452
+ const overridesDir = args.overridesDir ? resolve6(args.overridesDir) : previous.overridesDir ? resolve6(root, previous.overridesDir) : root;
2453
+ if (requestedOverrides && !existsSync12(join14(overridesDir, ".harness-overrides"))) {
2454
+ throw new Error(`recorded overrides are missing: ${overridesDir}/.harness-overrides`);
2455
+ }
2456
+ const hasOverrides = existsSync12(join14(overridesDir, ".harness-overrides"));
2457
+ const disablePath = join14(overridesDir, ".harness-overrides", "disable.yaml");
2458
+ if (existsSync12(disablePath)) {
2459
+ try {
2460
+ DisableYamlSchema.parse(parseYAML(readFileSync11(disablePath, "utf8")));
2461
+ } catch {
2462
+ throw new Error(`invalid override selection: ${disablePath}`);
2463
+ }
2464
+ }
2465
+ const rendered = planInit({
2466
+ targetDir: root,
2467
+ packageDir: args.packageDir,
2468
+ engines,
2469
+ overridesDir: hasOverrides ? overridesDir : void 0,
2470
+ mcpServers: settings.mcpServers,
2471
+ withStopHook: false
2472
+ });
2473
+ const generated = rendered.output.files.filter(
2474
+ (file) => !SHARED_CONFIG_PATHS.includes(file.path)
2475
+ );
2476
+ const config = planUpdateConfigs(root, previous, hookEngines);
2477
+ const mcp = planMcp(root, settings, rendered.output.files, config.writes);
2478
+ const output = [...generated, ...config.writes, ...mcp.writes].filter(
2479
+ (file) => !preserved.includes(file.path)
2480
+ );
2481
+ const owned = [
2482
+ .../* @__PURE__ */ new Set([...generated.map((file) => file.path), ...config.ownedFiles, ...mcp.owned])
2483
+ ].filter((file) => !preserved.includes(file)).sort();
2484
+ const removals = [
2485
+ .../* @__PURE__ */ new Set([
2486
+ ...previous.files.filter(
2487
+ (file) => !owned.includes(file) && !SHARED_CONFIG_PATHS.includes(file)
2488
+ ),
2489
+ ...config.removals,
2490
+ ...mcp.removals
2491
+ ])
2492
+ ].filter((file) => !preserved.includes(file));
2493
+ assertManagedPaths(root, [...output.map((file) => file.path), ...removals]);
2494
+ checkArtifactConflicts(root, previous, output, removals);
2495
+ const hashes = Object.fromEntries(
2496
+ owned.map((path) => {
2497
+ const content = output.find((file) => file.path === path)?.content ?? currentContent(root, path);
2498
+ if (content === void 0) throw new Error(`missing planned harness file: ${path}`);
2499
+ return [path, hashContent(content)];
2500
+ })
2501
+ );
2502
+ const opencodeConfig = mcp.plannedOpenCode ? mcp.opencodeConfig : config.opencodeConfig;
2503
+ const manifest = {
2504
+ version: MANIFEST_VERSION,
2505
+ harnessVersion: VERSION,
2506
+ engines: [...engines].sort(),
2507
+ renderedAt: previous.renderedAt,
2508
+ files: owned,
2509
+ fileHashes: hashes,
2510
+ stopHookInstalled: hookEngines.length > 0,
2511
+ stopHookEngines: [...hookEngines].sort(),
2512
+ ...hasOverrides ? { overridesDir: overridesDir === root ? "." : overridesDir } : {},
2513
+ ...preserved.length ? { preservedFiles: preserved } : {},
2514
+ ...opencodeConfig ? { opencodeConfig } : {},
2515
+ ...mcp.mcpConfig ? { mcpConfig: mcp.mcpConfig } : {},
2516
+ ...config.hookConfigs ? { hookConfigs: config.hookConfigs } : {}
2517
+ };
2518
+ const writes = output.filter((file) => currentContent(root, file.path) !== file.content);
2519
+ const existingRemovals = removals.filter((path) => existsSync12(join14(root, path)));
2520
+ if (writes.length || existingRemovals.length || !isDeepStrictEqual3(previous, manifest)) {
2521
+ manifest.renderedAt = (/* @__PURE__ */ new Date()).toISOString();
2522
+ writes.push({ path: MANIFEST_FILENAME, content: `${JSON.stringify(manifest, null, 2)}
2523
+ ` });
2512
2524
  }
2525
+ const warnings = [...rendered.output.warnings, ...config.warnings, ...mcp.warnings];
2526
+ if (preserved.length) warnings.push(`preserved user-owned artifacts: ${preserved.join(", ")}`);
2527
+ return { writes, removals: existingRemovals, owned, manifest, warnings };
2513
2528
  }
2514
- function writeBackupFile(path, content) {
2515
- createDirectories(dirname7(path), [], 448);
2516
- const fd = openSync(path, "wx", 384);
2517
- try {
2518
- writeFileSync6(fd, content);
2519
- fsyncSync(fd);
2520
- } finally {
2521
- closeSync(fd);
2529
+ function planUpdate(args) {
2530
+ const root = resolve6(args.targetDir);
2531
+ const { manifest: previous, sourceVersion, engines, hookEngines } = readUpdateInstallation(root);
2532
+ const plan = planInstallation(args, { previous, sourceVersion, engines, hookEngines });
2533
+ const warnings = [...plan.warnings];
2534
+ if (!previous.fileHashes) {
2535
+ warnings.push(
2536
+ "legacy manifest has no file fingerprints: managed artifacts will be regenerated; backup preserves their previous contents"
2537
+ );
2522
2538
  }
2539
+ if (engines.includes("codex-cli"))
2540
+ warnings.push("ensure .agents/ is ignored by Git (run harness check)");
2541
+ return {
2542
+ writes: plan.writes,
2543
+ removals: plan.removals,
2544
+ owned: plan.owned,
2545
+ result: {
2546
+ version: VERSION,
2547
+ fromManifestVersion: sourceVersion,
2548
+ dryRun: args.dryRun === true,
2549
+ upToDate: plan.writes.length === 0 && plan.removals.length === 0,
2550
+ filesToWrite: plan.writes.map((file) => file.path),
2551
+ filesToRemove: plan.removals,
2552
+ warnings
2553
+ }
2554
+ };
2523
2555
  }
2524
- function saveSnapshot(root, paths, backup) {
2525
- const entries = [];
2526
- createDirectories(dirname7(backup), [], 448);
2527
- mkdirSync4(backup, { mode: 448 });
2528
- for (const path of paths) {
2529
- const full = join15(root, path);
2530
- const info = lstatSync2(full, { throwIfNoEntry: false });
2531
- if (!info) {
2532
- entries.push({ path, exists: false });
2533
- continue;
2556
+ function updateCommand(args) {
2557
+ const plan = planUpdate(args);
2558
+ if (args.dryRun || plan.result.upToDate) return plan.result;
2559
+ const { backupDir } = applyUpdateTransaction(
2560
+ resolve6(args.targetDir),
2561
+ plan.writes,
2562
+ plan.removals,
2563
+ args.backupDir
2564
+ );
2565
+ return { ...plan.result, backupDir };
2566
+ }
2567
+
2568
+ // src/cli/init.ts
2569
+ function emptyManifest(engines) {
2570
+ return { version: MANIFEST_VERSION, engines: [...engines], renderedAt: "", files: [] };
2571
+ }
2572
+ function initCommand(args) {
2573
+ const root = resolve7(args.targetDir);
2574
+ const engines = resolveEngineList(args.engines);
2575
+ const stopHookInstalled = args.withStopHook !== false;
2576
+ const previous = readManifest(root) ?? emptyManifest(engines);
2577
+ const plan = planInstallation(
2578
+ {
2579
+ targetDir: root,
2580
+ packageDir: args.packageDir,
2581
+ overridesDir: args.overridesDir,
2582
+ preserveFiles: args.preserveFiles
2583
+ },
2584
+ {
2585
+ previous,
2586
+ sourceVersion: previous.version,
2587
+ engines,
2588
+ hookEngines: stopHookInstalled ? engines : [],
2589
+ mcpServers: args.mcpServers ?? []
2534
2590
  }
2535
- const content = readFileSync11(full);
2536
- writeBackupFile(join15(backup, "files", path), content);
2537
- entries.push({
2538
- path,
2539
- exists: true,
2540
- mode: info.mode & 4095,
2541
- sha256: createHash3("sha256").update(content).digest("hex")
2542
- });
2543
- }
2544
- writeBackupFile(
2545
- join15(backup, "snapshot.json"),
2546
- `${JSON.stringify({ version: 1, root, createdAt: (/* @__PURE__ */ new Date()).toISOString(), entries }, null, 2)}
2547
- `
2548
2591
  );
2549
- return entries;
2592
+ const touchedExisting = [...plan.writes.map((file) => file.path), ...plan.removals].some(
2593
+ (path) => path !== MANIFEST_FILENAME && existsSync13(join15(root, path))
2594
+ );
2595
+ let backupDir;
2596
+ if (plan.writes.length || plan.removals.length) {
2597
+ backupDir = applyUpdateTransaction(root, plan.writes, plan.removals, args.backupDir).backupDir;
2598
+ if (!touchedExisting && !args.backupDir) {
2599
+ rmSync(backupDir, { recursive: true, force: true });
2600
+ backupDir = void 0;
2601
+ }
2602
+ pruneEmptyDirs(root, new Set(plan.removals.map((path) => dirname6(join15(root, path)))));
2603
+ }
2604
+ const sharedWritten = plan.writes.map((file) => file.path).filter((path) => SHARED_CONFIG_PATHS.includes(path) && !plan.owned.includes(path));
2605
+ return {
2606
+ filesWritten: [...plan.owned, ...sharedWritten],
2607
+ staleFilesRemoved: plan.removals,
2608
+ warnings: plan.warnings,
2609
+ stopHookInstalled,
2610
+ engines,
2611
+ ...backupDir ? { backupDir } : {}
2612
+ };
2550
2613
  }
2551
- function errorMessage(error) {
2552
- return error instanceof Error ? error.message : String(error);
2614
+
2615
+ // src/cli/render.ts
2616
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync5 } from "fs";
2617
+ import { dirname as dirname7, join as join16 } from "path";
2618
+ function renderCommand(args) {
2619
+ const engineList = args.engines && args.engines.length > 0 ? args.engines.map((e) => {
2620
+ if (!ENGINES.includes(e)) {
2621
+ throw new Error(`unknown engine: ${e}`);
2622
+ }
2623
+ return e;
2624
+ }) : [...ENGINES];
2625
+ const harness = loadHarness(args.packageDir);
2626
+ const mcpServers = args.mcpServers ? resolveMcpIds(args.mcpServers) : void 0;
2627
+ const output = render({
2628
+ harness,
2629
+ engines: engineList,
2630
+ mcpServers,
2631
+ withStopHook: args.withStopHook !== false
2632
+ });
2633
+ const filesWritten = [];
2634
+ for (const f of output.files) {
2635
+ const full = join16(args.outDir, f.path);
2636
+ mkdirSync3(dirname7(full), { recursive: true });
2637
+ writeFileSync5(full, f.content);
2638
+ filesWritten.push(f.path);
2639
+ }
2640
+ return { filesWritten, warnings: output.warnings };
2553
2641
  }
2554
- function rollback(root, backup, entries, touched, createdDirectories) {
2555
- const errors = [];
2556
- for (const path of [...touched].reverse()) {
2557
- try {
2558
- assertManagedPaths(root, [path]);
2559
- const entry = entries.get(path);
2560
- const full = join15(root, path);
2561
- const current = lstatSync2(full, { throwIfNoEntry: false });
2562
- if (!entry?.exists) {
2563
- if (current) unlinkSync2(full);
2564
- continue;
2565
- }
2566
- const original = readFileSync11(join15(backup, "files", path));
2567
- if (!current || !readFileSync11(full).equals(original)) {
2568
- if (current) unlinkSync2(full);
2569
- writeFileSync6(full, original, { mode: 384 });
2570
- }
2571
- chmodSync2(full, entry.mode);
2572
- } catch (error) {
2573
- errors.push(`${path}: ${errorMessage(error)}`);
2642
+
2643
+ // src/cli/stop-hook.ts
2644
+ import { readFileSync as readFileSync13 } from "fs";
2645
+ import { resolve as resolve8 } from "path";
2646
+
2647
+ // src/stop-hook/adapters/claude.ts
2648
+ var claudeAdapter = {
2649
+ parseInput(stdin) {
2650
+ const parsed = stdin.trim() === "" ? {} : JSON.parse(stdin);
2651
+ return {
2652
+ sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "default",
2653
+ stopHookActive: parsed.stop_hook_active === true
2654
+ };
2655
+ },
2656
+ respond(decision) {
2657
+ if (decision.kind === "inject") {
2658
+ return {
2659
+ stdout: `${JSON.stringify({ decision: "block", reason: decision.reason ?? "" })}
2660
+ `,
2661
+ stderr: "",
2662
+ exitCode: 0
2663
+ };
2574
2664
  }
2665
+ if (decision.kind === "terminate") {
2666
+ return {
2667
+ stdout: "",
2668
+ stderr: `${decision.reason ?? ""}
2669
+ `,
2670
+ exitCode: 0
2671
+ };
2672
+ }
2673
+ return { stdout: "", stderr: "", exitCode: 0 };
2575
2674
  }
2576
- for (const directory of [...createdDirectories].reverse()) {
2577
- try {
2578
- if (readdirSync2(directory).length === 0) rmdirSync2(directory);
2579
- } catch (error) {
2580
- errors.push(`${directory}: ${errorMessage(error)}`);
2675
+ };
2676
+
2677
+ // src/stop-hook/adapters/codex.ts
2678
+ var codexAdapter = {
2679
+ parseInput(stdin) {
2680
+ const parsed = stdin.trim() === "" ? {} : JSON.parse(stdin);
2681
+ return {
2682
+ sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "default",
2683
+ stopHookActive: parsed.stop_hook_active === true
2684
+ };
2685
+ },
2686
+ respond(decision) {
2687
+ if (decision.kind === "inject") {
2688
+ return {
2689
+ stdout: `${JSON.stringify({ decision: "block", reason: decision.reason ?? "" })}
2690
+ `,
2691
+ stderr: "",
2692
+ exitCode: 0
2693
+ };
2581
2694
  }
2695
+ if (decision.kind === "terminate") {
2696
+ return {
2697
+ stdout: "",
2698
+ stderr: `${decision.reason ?? ""}
2699
+ `,
2700
+ exitCode: 0
2701
+ };
2702
+ }
2703
+ return { stdout: "", stderr: "", exitCode: 0 };
2582
2704
  }
2583
- return errors;
2705
+ };
2706
+
2707
+ // src/stop-hook/adapters/opencode.ts
2708
+ var opencodeAdapter = {
2709
+ parseInput(stdin) {
2710
+ const parsed = stdin.trim() === "" ? {} : JSON.parse(stdin);
2711
+ const sessionId = typeof parsed.sessionID === "string" ? parsed.sessionID : typeof parsed.session_id === "string" ? parsed.session_id : "default";
2712
+ return { sessionId, stopHookActive: false };
2713
+ },
2714
+ respond(decision) {
2715
+ if (decision.kind === "inject") {
2716
+ return { stdout: "", stderr: `${decision.reason ?? ""}
2717
+ `, exitCode: 2 };
2718
+ }
2719
+ if (decision.kind === "terminate") {
2720
+ return { stdout: "", stderr: `${decision.reason ?? ""}
2721
+ `, exitCode: 0 };
2722
+ }
2723
+ return { stdout: "", stderr: "", exitCode: 0 };
2724
+ }
2725
+ };
2726
+
2727
+ // src/stop-hook/adapters/index.ts
2728
+ var ADAPTERS = {
2729
+ claude: claudeAdapter,
2730
+ codex: codexAdapter,
2731
+ opencode: opencodeAdapter
2732
+ };
2733
+ function getAdapter(engine) {
2734
+ return ADAPTERS[engine];
2584
2735
  }
2585
- function applyUpdateTransaction(root, writes, removals, backupDir) {
2586
- const paths = [.../* @__PURE__ */ new Set([...removals, ...writes.map((file) => file.path)])];
2587
- assertManagedPaths(root, paths);
2588
- const target = canonicalRoot(root);
2589
- const backup = resolve6(
2590
- backupDir ?? join15(homedir(), ".local", "state", "vaubaan-harness", "backups", randomUUID())
2591
- );
2592
- assertBackupLocation(target, backup);
2593
- let snapshot;
2736
+
2737
+ // src/stop-hook/core.ts
2738
+ import { execFileSync as execFileSync2 } from "child_process";
2739
+ import { createHash as createHash3 } from "crypto";
2740
+
2741
+ // src/stop-hook/state.ts
2742
+ import { existsSync as existsSync14, mkdirSync as mkdirSync4, readFileSync as readFileSync12, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
2743
+ import { dirname as dirname8, join as join17 } from "path";
2744
+ import { z as z7 } from "zod";
2745
+ var LintStateSchema = z7.object({
2746
+ attempts: z7.number().int().nonnegative(),
2747
+ lastLintHash: z7.string(),
2748
+ updatedAt: z7.string()
2749
+ });
2750
+ var STATE_DIR = ".harness/state";
2751
+ function safeSessionId(sessionId) {
2752
+ return sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80) || "default";
2753
+ }
2754
+ function statePath(cwd, sessionId) {
2755
+ return join17(cwd, STATE_DIR, `lint-${safeSessionId(sessionId)}.json`);
2756
+ }
2757
+ function readState(cwd, sessionId) {
2758
+ const path = statePath(cwd, sessionId);
2759
+ if (!existsSync14(path)) return null;
2594
2760
  try {
2595
- snapshot = saveSnapshot(target, paths, backup);
2596
- } catch (error) {
2597
- throw new Error(
2598
- `could not prepare update backup at ${backup}; project unchanged: ${errorMessage(error)}`,
2599
- { cause: error }
2600
- );
2761
+ const raw = JSON.parse(readFileSync12(path, "utf8"));
2762
+ return LintStateSchema.parse(raw);
2763
+ } catch {
2764
+ return null;
2601
2765
  }
2602
- const entries = new Map(snapshot.map((entry) => [entry.path, entry]));
2603
- const touched = /* @__PURE__ */ new Set();
2604
- const createdDirectories = [];
2766
+ }
2767
+ function writeState(cwd, sessionId, state) {
2768
+ const path = statePath(cwd, sessionId);
2769
+ mkdirSync4(dirname8(path), { recursive: true });
2770
+ writeFileSync6(path, `${JSON.stringify(state, null, 2)}
2771
+ `);
2772
+ }
2773
+ function resetState(cwd, sessionId) {
2774
+ const path = statePath(cwd, sessionId);
2775
+ if (existsSync14(path)) rmSync2(path, { force: true });
2776
+ }
2777
+
2778
+ // src/stop-hook/types.ts
2779
+ var STOP_HOOK_ENGINES = ["claude", "codex", "opencode"];
2780
+ var DEFAULT_MAX_ATTEMPTS = 3;
2781
+
2782
+ // src/stop-hook/core.ts
2783
+ function exhaustedReason(attempts, lintOutput) {
2784
+ return `Harness lint: still failing after ${attempts} attempts.
2785
+
2786
+ ${lintOutput}`;
2787
+ }
2788
+ function noProgressReason(lintOutput) {
2789
+ return `Harness lint: no progress between attempts.
2790
+
2791
+ ${lintOutput}`;
2792
+ }
2793
+ function hash(s) {
2794
+ return createHash3("sha256").update(s).digest("hex").slice(0, 16);
2795
+ }
2796
+ function maxAttempts(env) {
2797
+ const raw = env.HARNESS_LINT_MAX_ATTEMPTS;
2798
+ if (!raw) return DEFAULT_MAX_ATTEMPTS;
2799
+ const n = Number.parseInt(raw, 10);
2800
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_ATTEMPTS;
2801
+ }
2802
+ function defaultGitStatus(cwd) {
2605
2803
  try {
2606
- assertManagedPaths(target, paths);
2607
- for (const path of removals) {
2608
- if (!lstatSync2(join15(target, path), { throwIfNoEntry: false })) continue;
2609
- touched.add(path);
2610
- unlinkSync2(join15(target, path));
2611
- }
2612
- for (const file of writes) {
2613
- const full = join15(target, file.path);
2614
- createDirectories(dirname7(full), createdDirectories);
2615
- touched.add(file.path);
2616
- const entry = entries.get(file.path);
2617
- const mode = file.path === MANIFEST_PATH ? 384 : entry?.exists ? entry.mode : PRIVATE_FILES.has(file.path) ? 384 : 420;
2618
- if (entry?.exists && file.path === MANIFEST_PATH) chmodSync2(full, mode);
2619
- writeFileSync6(full, file.content, { mode });
2620
- if (entry?.exists) chmodSync2(full, mode);
2621
- }
2622
- } catch (error) {
2623
- const failures = rollback(target, backup, entries, touched, createdDirectories);
2624
- const restoration = failures.length > 0 ? `rollback errors: ${failures.join("; ")}` : "rollback completed";
2625
- throw new Error(`update failed; backup: ${backup}; ${restoration}: ${errorMessage(error)}`, {
2626
- cause: error
2804
+ return execFileSync2("git", ["status", "--porcelain"], {
2805
+ cwd,
2806
+ encoding: "utf8",
2807
+ stdio: ["ignore", "pipe", "ignore"]
2627
2808
  });
2809
+ } catch {
2810
+ return "";
2628
2811
  }
2629
- return { backupDir: backup };
2630
2812
  }
2631
-
2632
- // src/cli/update.ts
2633
- function currentContent(root, path) {
2634
- return existsSync13(join16(root, path)) ? readFileSync12(join16(root, path), "utf8") : void 0;
2813
+ function defaultRunLint(cwd) {
2814
+ const report = runLinters(cwd);
2815
+ return { pass: report.summary.pass, output: formatReportText(report) };
2635
2816
  }
2636
- function checkArtifactConflicts(root, previous, writes, removals) {
2637
- const desired = new Map(writes.map((file) => [file.path, file.content]));
2638
- for (const path of [...desired.keys(), ...removals]) {
2639
- if (SHARED_CONFIG_PATHS.includes(path)) continue;
2640
- const content = currentContent(root, path);
2641
- if (content === void 0) continue;
2642
- if (!previous.files.includes(path)) {
2643
- throw new Error(
2644
- `existing file outside harness manifest: ${path}; use overrides or --preserve`
2645
- );
2646
- }
2647
- const hash2 = previous.fileHashes?.[path];
2648
- if (hash2 && hash2 !== hashContent(content) && content !== desired.get(path)) {
2649
- throw new Error(`modified harness file: ${path}; move edits to overrides or use --preserve`);
2650
- }
2817
+ function decide(payload, coreEnv) {
2818
+ if (coreEnv.env.HARNESS_LINT_DISABLE === "1") {
2819
+ return { kind: "passthrough" };
2651
2820
  }
2652
- }
2653
- function planUpdate(args) {
2654
- const root = resolve7(args.targetDir);
2655
- const { manifest: previous, sourceVersion, engines, hookEngines } = readUpdateInstallation(root);
2656
- const preserved = [
2657
- .../* @__PURE__ */ new Set([...previous.preservedFiles ?? [], ...args.preserveFiles ?? []])
2658
- ].sort();
2659
- if (preserved.includes(MANIFEST_FILENAME))
2660
- throw new Error("cannot preserve the harness manifest");
2661
- if (preserved.some((path) => SHARED_CONFIG_PATHS.includes(path))) {
2662
- throw new Error(
2663
- "--preserve applies to generated artifacts; shared configurations use entry-level preservation"
2664
- );
2821
+ if (payload.stopHookActive) {
2822
+ return { kind: "passthrough" };
2665
2823
  }
2666
- assertManagedPaths(root, [...previous.files, ...preserved, ...SHARED_CONFIG_PATHS]);
2667
- const requestedOverrides = args.overridesDir ?? previous.overridesDir;
2668
- const overridesDir = args.overridesDir ? resolve7(args.overridesDir) : previous.overridesDir ? resolve7(root, previous.overridesDir) : root;
2669
- if (requestedOverrides && !existsSync13(join16(overridesDir, ".harness-overrides"))) {
2670
- throw new Error(`recorded overrides are missing: ${overridesDir}/.harness-overrides`);
2824
+ const dirty = coreEnv.gitStatus(coreEnv.cwd);
2825
+ if (dirty.trim() === "") {
2826
+ resetState(coreEnv.cwd, payload.sessionId);
2827
+ return { kind: "passthrough" };
2671
2828
  }
2672
- const hasOverrides = existsSync13(join16(overridesDir, ".harness-overrides"));
2673
- const disablePath = join16(overridesDir, ".harness-overrides", "disable.yaml");
2674
- if (existsSync13(disablePath)) {
2675
- try {
2676
- DisableYamlSchema.parse(parseYAML(readFileSync12(disablePath, "utf8")));
2677
- } catch {
2678
- throw new Error(`invalid override selection: ${disablePath}`);
2679
- }
2829
+ const lintResult = coreEnv.runLint(coreEnv.cwd);
2830
+ if (lintResult.pass) {
2831
+ resetState(coreEnv.cwd, payload.sessionId);
2832
+ return { kind: "passthrough" };
2680
2833
  }
2681
- const rendered = planInit({
2682
- targetDir: root,
2683
- packageDir: args.packageDir,
2684
- engines,
2685
- overridesDir: hasOverrides ? overridesDir : void 0,
2686
- withStopHook: false
2834
+ const lintHash = hash(lintResult.output);
2835
+ const prev = readState(coreEnv.cwd, payload.sessionId);
2836
+ if (prev && prev.attempts > 0 && prev.lastLintHash === lintHash) {
2837
+ resetState(coreEnv.cwd, payload.sessionId);
2838
+ return { kind: "terminate", reason: noProgressReason(lintResult.output) };
2839
+ }
2840
+ const nextAttempts = (prev?.attempts ?? 0) + 1;
2841
+ const limit = maxAttempts(coreEnv.env);
2842
+ if (nextAttempts > limit) {
2843
+ resetState(coreEnv.cwd, payload.sessionId);
2844
+ return { kind: "terminate", reason: exhaustedReason(limit, lintResult.output) };
2845
+ }
2846
+ writeState(coreEnv.cwd, payload.sessionId, {
2847
+ attempts: nextAttempts,
2848
+ lastLintHash: lintHash,
2849
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2687
2850
  });
2688
- const config = planUpdateConfigs(root, previous, hookEngines);
2689
- const output = [...rendered.output.files, ...config.writes].filter(
2690
- (file) => !preserved.includes(file.path)
2691
- );
2692
- const owned = [
2693
- .../* @__PURE__ */ new Set([...rendered.output.files.map((file) => file.path), ...config.ownedFiles])
2694
- ].filter((file) => !preserved.includes(file)).sort();
2695
- const removals = [
2696
- .../* @__PURE__ */ new Set([
2697
- ...previous.files.filter(
2698
- (file) => !owned.includes(file) && !SHARED_CONFIG_PATHS.includes(file)
2699
- ),
2700
- ...config.removals
2701
- ])
2702
- ].filter((file) => !preserved.includes(file));
2703
- assertManagedPaths(root, [...output.map((file) => file.path), ...removals]);
2704
- checkArtifactConflicts(root, previous, output, removals);
2705
- const hashes = Object.fromEntries(
2706
- owned.map((path) => {
2707
- const content = output.find((file) => file.path === path)?.content ?? currentContent(root, path);
2708
- if (content === void 0) throw new Error(`missing planned harness file: ${path}`);
2709
- return [path, hashContent(content)];
2710
- })
2711
- );
2712
- const manifest = {
2713
- version: MANIFEST_VERSION,
2714
- harnessVersion: VERSION,
2715
- engines: [...engines].sort(),
2716
- renderedAt: previous.renderedAt,
2717
- files: owned,
2718
- fileHashes: hashes,
2719
- stopHookInstalled: hookEngines.length > 0,
2720
- stopHookEngines: [...hookEngines].sort(),
2721
- ...hasOverrides ? { overridesDir: overridesDir === root ? "." : overridesDir } : {},
2722
- ...preserved.length ? { preservedFiles: preserved } : {},
2723
- ...config.opencodeConfig ? { opencodeConfig: config.opencodeConfig } : {},
2724
- ...config.hookConfigs ? { hookConfigs: config.hookConfigs } : {}
2725
- };
2726
- const writes = output.filter((file) => currentContent(root, file.path) !== file.content);
2727
- const existingRemovals = removals.filter((path) => existsSync13(join16(root, path)));
2728
- if (writes.length || existingRemovals.length || !isDeepStrictEqual3(previous, manifest)) {
2729
- manifest.renderedAt = (/* @__PURE__ */ new Date()).toISOString();
2730
- writes.push({ path: MANIFEST_FILENAME, content: `${JSON.stringify(manifest, null, 2)}
2731
- ` });
2851
+ return { kind: "inject", reason: lintResult.output };
2852
+ }
2853
+
2854
+ // src/cli/stop-hook.ts
2855
+ function readStdinSync() {
2856
+ try {
2857
+ return readFileSync13(0, "utf8");
2858
+ } catch {
2859
+ return "";
2732
2860
  }
2733
- const warnings = [...rendered.output.warnings, ...config.warnings];
2734
- if (!previous.fileHashes) {
2735
- warnings.push(
2736
- "legacy manifest has no file fingerprints: managed artifacts will be regenerated; backup preserves their previous contents"
2737
- );
2861
+ }
2862
+ function assertEngine(engine) {
2863
+ if (!STOP_HOOK_ENGINES.includes(engine)) {
2864
+ throw new Error(`unknown engine: ${engine} (expected one of ${STOP_HOOK_ENGINES.join(", ")})`);
2738
2865
  }
2739
- if (preserved.length) warnings.push(`preserved user-owned artifacts: ${preserved.join(", ")}`);
2740
- if (engines.includes("codex-cli"))
2741
- warnings.push("ensure .agents/ is ignored by Git (run harness check)");
2742
- return {
2743
- writes,
2744
- removals: existingRemovals,
2745
- result: {
2746
- version: VERSION,
2747
- fromManifestVersion: sourceVersion,
2748
- dryRun: args.dryRun === true,
2749
- upToDate: writes.length === 0 && existingRemovals.length === 0,
2750
- filesToWrite: writes.map((file) => file.path),
2751
- filesToRemove: existingRemovals,
2752
- warnings
2753
- }
2754
- };
2866
+ return engine;
2755
2867
  }
2756
- function updateCommand(args) {
2757
- const plan = planUpdate(args);
2758
- if (args.dryRun || plan.result.upToDate) return plan.result;
2759
- const { backupDir } = applyUpdateTransaction(
2760
- resolve7(args.targetDir),
2761
- plan.writes,
2762
- plan.removals,
2763
- args.backupDir
2764
- );
2765
- return { ...plan.result, backupDir };
2868
+ function stopHookCommand(args, stdin) {
2869
+ const engine = assertEngine(args.engine);
2870
+ const cwd = resolve8(args.cwd ?? ".");
2871
+ const adapter = getAdapter(engine);
2872
+ const payload = adapter.parseInput(stdin, process.env);
2873
+ const decision = decide(payload, {
2874
+ cwd,
2875
+ env: process.env,
2876
+ gitStatus: defaultGitStatus,
2877
+ runLint: defaultRunLint
2878
+ });
2879
+ return adapter.respond(decision);
2880
+ }
2881
+ function stopHookCommandFromStdin(args) {
2882
+ return stopHookCommand(args, readStdinSync());
2766
2883
  }
2767
2884
 
2768
2885
  // src/cli/index.ts
2769
2886
  function resolvePackageDir() {
2770
- const here = dirname8(fileURLToPath2(import.meta.url));
2771
- return resolve8(here, "..");
2887
+ const here = dirname9(fileURLToPath2(import.meta.url));
2888
+ return resolve9(here, "..");
2889
+ }
2890
+ function parseList(raw) {
2891
+ if (raw === void 0) return void 0;
2892
+ return raw.split(",").map((item) => item.trim()).filter(Boolean);
2772
2893
  }
2773
2894
  var program = new Command();
2774
2895
  program.name("vaubaan-harness").description(
@@ -2785,10 +2906,9 @@ program.command("init").description(
2785
2906
  ).option(
2786
2907
  "--no-stop-hook",
2787
2908
  "skip rendering the Stop-hook configs that run lint at end of each agent turn"
2788
- ).action((targetDir, opts) => {
2909
+ ).option("--preserve <paths>", "comma-separated generated files to leave under user ownership").option("--backup-dir <dir>", "new backup directory outside the project").action((targetDir, opts) => {
2789
2910
  const packageDir = opts.packageDir ?? resolvePackageDir();
2790
- const enginesRaw = opts.engines;
2791
- const engines = enginesRaw ? enginesRaw.split(",").map((s) => s.trim()) : void 0;
2911
+ const engines = parseList(opts.engines);
2792
2912
  const withMcpRaw = opts.withMcp;
2793
2913
  const mcpServers = withMcpRaw ? withMcpRaw.split(",").map((s) => s.trim()) : void 0;
2794
2914
  const result = initCommand({
@@ -2797,7 +2917,9 @@ program.command("init").description(
2797
2917
  overridesDir: opts.overridesDir,
2798
2918
  packageDir,
2799
2919
  mcpServers,
2800
- withStopHook: opts.stopHook !== false
2920
+ withStopHook: opts.stopHook !== false,
2921
+ preserveFiles: parseList(opts.preserve),
2922
+ backupDir: opts.backupDir
2801
2923
  });
2802
2924
  process.stdout.write(`rendered ${result.filesWritten.length} files to ${targetDir}
2803
2925
  `);
@@ -2809,12 +2931,14 @@ program.command("init").description(
2809
2931
  }
2810
2932
  if (result.stopHookInstalled) {
2811
2933
  process.stdout.write("stop-hook installed (runs lint at end-of-turn)\n");
2812
- if (result.filesWritten.includes(".codex/hooks.json")) {
2934
+ if (result.engines.includes("codex-cli")) {
2813
2935
  process.stdout.write(
2814
2936
  "codex: the hook runs once you trust this project and approve the hook (startup review or /hooks)\n"
2815
2937
  );
2816
2938
  }
2817
2939
  }
2940
+ if (result.backupDir) process.stdout.write(`backup: ${result.backupDir}
2941
+ `);
2818
2942
  for (const w of result.warnings) process.stderr.write(`warning: ${w}
2819
2943
  `);
2820
2944
  });
@@ -2869,7 +2993,7 @@ program.command("lint").description("Run all mechanical linters and exit non-zer
2869
2993
  "--pillar <pillar>",
2870
2994
  "filter by pillar (spec, decisions, handbook, tests, workflow, quality)"
2871
2995
  ).option("--format <fmt>", "text | json", "text").option("--cwd <dir>", "repo root", ".").action((opts) => {
2872
- const repoRoot = resolve8(opts.cwd ?? ".");
2996
+ const repoRoot = resolve9(opts.cwd ?? ".");
2873
2997
  const lintOpts = opts.pillar ? { pillar: opts.pillar } : {};
2874
2998
  const report = runLinters(repoRoot, lintOpts);
2875
2999
  if (opts.format === "json") {
@@ -2889,8 +3013,7 @@ program.command("render").description("Render harness for preview (no overrides
2889
3013
  "comma-separated MCP servers to render config for (context7) \u2014 opt-in"
2890
3014
  ).option("--no-stop-hook", "skip rendering the Stop-hook configs").action((opts) => {
2891
3015
  const packageDir = opts.packageDir ?? resolvePackageDir();
2892
- const enginesRaw = opts.engines;
2893
- const engines = enginesRaw ? enginesRaw.split(",").map((s) => s.trim()) : void 0;
3016
+ const engines = parseList(opts.engines);
2894
3017
  const withMcpRaw = opts.withMcp;
2895
3018
  const mcpServers = withMcpRaw ? withMcpRaw.split(",").map((s) => s.trim()) : void 0;
2896
3019
  const result = renderCommand({