@vaubaan/harness 1.3.0 → 1.4.0

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