@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/NOTICE +1 -1
- package/README.md +229 -112
- package/dist/agents/coding.agent.md +1 -2
- package/dist/agents/merge.agent.md +2 -2
- package/dist/agents/planning.agent.md +1 -1
- package/dist/agents/review.agent.md +1 -1
- package/dist/cli/index.js +1438 -1287
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +804 -667
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +29 -6
- package/dist/init.js +1145 -470
- package/dist/init.js.map +1 -1
- package/dist/linters/index.js +3 -2
- package/dist/linters/index.js.map +1 -1
- package/dist/render.d.ts +1 -1
- package/dist/render.js +41 -8
- package/dist/render.js.map +1 -1
- package/dist/skills/spec-writer.skill.md +1 -1
- package/dist/skills/test-plan-writer.skill.md +1 -1
- package/package.json +9 -4
- package/dist/{render-B9_kNtQc.d.ts → render-Cu2XrkkY.d.ts} +3 -3
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
782
|
-
import { dirname as
|
|
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/
|
|
786
|
-
import {
|
|
787
|
-
import { join as join5 } from "path";
|
|
788
|
-
|
|
789
|
-
|
|
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/
|
|
792
|
-
import {
|
|
793
|
-
import
|
|
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/
|
|
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/
|
|
812
|
-
var
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
876
|
-
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1005
|
+
function yamlFrontmatter(data) {
|
|
1006
|
+
const rendered = yamlStringify(data, { lineWidth: 0 }).trimEnd();
|
|
1007
|
+
return `---
|
|
1008
|
+
${rendered}
|
|
1009
|
+
---
|
|
1010
|
+
`;
|
|
883
1011
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
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
|
|
1331
|
-
|
|
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/
|
|
1371
|
-
var
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
|
1386
|
-
|
|
1387
|
-
|
|
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
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
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
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
const
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
-
|
|
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 || !
|
|
1780
|
-
const before = parseObject(
|
|
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 =
|
|
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(
|
|
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 =
|
|
1821
|
-
if (!
|
|
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(
|
|
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 =
|
|
1888
|
-
version:
|
|
1889
|
-
engines:
|
|
1890
|
-
renderedAt:
|
|
1891
|
-
files:
|
|
1892
|
-
stopHookInstalled:
|
|
1893
|
-
stopHookEngines:
|
|
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:
|
|
1897
|
-
overridesDir:
|
|
1898
|
-
fileHashes:
|
|
1899
|
-
preservedFiles:
|
|
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
|
|
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 (!
|
|
1934
|
-
const raw = JSON.parse(
|
|
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/
|
|
1939
|
-
import { existsSync as existsSync9
|
|
1940
|
-
import { dirname as dirname3, join as join10,
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
)
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
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 {
|
|
1846
|
+
return { add: added, disable };
|
|
1990
1847
|
}
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
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
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
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
|
|
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/
|
|
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
|
|
2015
|
-
|
|
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 =
|
|
2024
|
-
if (
|
|
2025
|
-
return
|
|
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 =
|
|
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
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
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
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
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
|
|
2071
|
-
const
|
|
2072
|
-
const
|
|
2073
|
-
const
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
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
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
const
|
|
2098
|
-
|
|
2099
|
-
|
|
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 {
|
|
2075
|
+
return { filesRemoved, dirsPruned: pruneEmptyDirs(root, dirsToConsider), missing };
|
|
2119
2076
|
}
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
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
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
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/
|
|
2216
|
-
import {
|
|
2217
|
-
import {
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
}
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
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
|
|
2246
|
-
const path =
|
|
2247
|
-
|
|
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
|
|
2272
|
-
|
|
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
|
|
2275
|
-
const
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
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
|
|
2168
|
+
function writeBackupFile(path, content) {
|
|
2169
|
+
createDirectories(dirname5(path), [], 448);
|
|
2170
|
+
const fd = openSync(path, "wx", 384);
|
|
2281
2171
|
try {
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
});
|
|
2287
|
-
} catch {
|
|
2288
|
-
return "";
|
|
2172
|
+
writeFileSync3(fd, content);
|
|
2173
|
+
fsyncSync(fd);
|
|
2174
|
+
} finally {
|
|
2175
|
+
closeSync(fd);
|
|
2289
2176
|
}
|
|
2290
2177
|
}
|
|
2291
|
-
function
|
|
2292
|
-
const
|
|
2293
|
-
|
|
2294
|
-
}
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
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
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
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
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2333
|
-
|
|
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
|
-
|
|
2336
|
-
} catch {
|
|
2337
|
-
|
|
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
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
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
|
|
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
|
|
2365
|
-
import { join as
|
|
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
|
|
2370
|
-
import { join as
|
|
2371
|
-
import { z as
|
|
2372
|
-
var EngineSchema =
|
|
2373
|
-
var LegacySchema =
|
|
2374
|
-
version:
|
|
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:
|
|
2377
|
-
renderedAt:
|
|
2378
|
-
files:
|
|
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 (!
|
|
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(
|
|
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:
|
|
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(
|
|
2411
|
-
const hookEngines = manifest.stopHookInstalled === false ? [] : manifest.stopHookEngines !== void 0 ?
|
|
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
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
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
|
|
2458
|
-
const
|
|
2459
|
-
|
|
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
|
|
2462
|
-
|
|
2463
|
-
|
|
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
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
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
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
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
|
-
|
|
2478
|
-
|
|
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
|
|
2482
|
-
const
|
|
2483
|
-
|
|
2484
|
-
|
|
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
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
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
|
|
2492
|
-
|
|
2493
|
-
const
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
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
|
|
2502
|
-
const
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2529
|
-
|
|
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
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
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
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
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
|
-
|
|
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
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
const
|
|
2567
|
-
|
|
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
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
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
|
-
|
|
2580
|
-
|
|
2581
|
-
const
|
|
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
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
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
|
-
|
|
2610
|
-
|
|
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
|
|
2614
|
-
|
|
2615
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
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
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
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
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
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
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
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
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
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
|
-
|
|
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
|
|
2734
|
-
const
|
|
2735
|
-
|
|
2736
|
-
const
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
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 =
|
|
2748
|
-
return
|
|
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
|
-
"
|
|
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
|
|
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 =
|
|
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
|
|
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({
|