javi-forge 1.39.4 → 1.40.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.
@@ -22,8 +22,9 @@ const HOST_HOME = os.homedir();
22
22
  // byte-identical; codex is defined so the map is agent-generic but wired end-to-end only in a later slice.
23
23
  const CLAUDE_MANAGED_SET = Object.freeze({ exact: Object.freeze([".claude/settings.json", ".claude/settings.local.json", ".claude/CLAUDE.md", "CLAUDE.md", ".javi-forge/ci.yaml"]), prefixes: Object.freeze([".claude/hooks/", ".claude/agents/", ".claude/skills/"]), caseFoldExact: Object.freeze(["claude.md", ".claude/claude.md"]) });
24
24
  const CODEX_MANAGED_SET = Object.freeze({ exact: Object.freeze([".codex/hooks.json", ".claude/settings.json", ".claude/settings.local.json", ".claude/CLAUDE.md", "CLAUDE.md", ".javi-forge/ci.yaml"]), prefixes: Object.freeze([".claude/hooks/", ".claude/agents/", ".claude/skills/"]), caseFoldExact: Object.freeze(["claude.md", ".claude/claude.md"]) });
25
+ const OPENCODE_MANAGED_SET = Object.freeze({ exact: Object.freeze(["opencode.json", "opencode.jsonc", ".opencode/opencode.json", ".opencode/opencode.jsonc", ".opencode/AGENTS.md", "AGENTS.md", ".javi-forge/ci.yaml"]), prefixes: Object.freeze([".opencode/plugins/", ".opencode/agents/", ".opencode/skills/", ".agents/skills/", ".claude/hooks/", ".claude/agents/", ".claude/skills/"]), caseFoldExact: Object.freeze(["agents.md", ".opencode/agents.md", "claude.md", ".claude/claude.md"]) });
25
26
  const CODEX_GLOBAL_MANAGED_PATHS = Object.freeze([".codex/hooks.json", ".codex/config.toml"]);
26
- export const AGENT_CONFIGS = Object.freeze({ claude: Object.freeze({ id: "claude", managedSet: CLAUDE_MANAGED_SET, globalManagedPaths: Object.freeze([]), projectDir: Object.freeze({ envVar: "CLAUDE_PROJECT_DIR", fallback: "asset-root" }), marker: MANAGED_MARKER }), codex: Object.freeze({ id: "codex", managedSet: CODEX_MANAGED_SET, globalManagedPaths: CODEX_GLOBAL_MANAGED_PATHS, projectDir: Object.freeze({ envVar: null, fallback: "cwd" }), marker: "// javi-forge-managed: codex-pretooluse v1" }) });
27
+ export const AGENT_CONFIGS = Object.freeze({ claude: Object.freeze({ id: "claude", managedSet: CLAUDE_MANAGED_SET, globalManagedPaths: Object.freeze([]), projectDir: Object.freeze({ envVar: "CLAUDE_PROJECT_DIR", fallback: "asset-root" }), marker: MANAGED_MARKER }), codex: Object.freeze({ id: "codex", managedSet: CODEX_MANAGED_SET, globalManagedPaths: CODEX_GLOBAL_MANAGED_PATHS, projectDir: Object.freeze({ envVar: null, fallback: "cwd" }), marker: "// javi-forge-managed: codex-pretooluse v1" }), opencode: Object.freeze({ id: "opencode", managedSet: OPENCODE_MANAGED_SET, globalManagedPaths: Object.freeze([]), projectDir: Object.freeze({ envVar: null, fallback: "cwd" }), marker: "// javi-forge-managed: opencode-skillguard v1" }) });
27
28
  // Fail-closed agent selector: a missing/unknown --agent means we cannot know what to protect, so refuse.
28
29
  function resolveAgentConfig(argv) { const arg = argv.find((value) => typeof value === "string" && value.startsWith("--agent=")); const id = arg === undefined ? undefined : arg.slice("--agent=".length); const config = id === undefined ? undefined : AGENT_CONFIGS[id]; if (!config) fail("invalid-config"); return config; }
29
30
  // Project root per agent: the env var when set (Claude = CLAUDE_PROJECT_DIR); otherwise the per-agent
@@ -4,7 +4,7 @@
4
4
  "name": "javi-forge-skillguard-pre-tool-use.mjs",
5
5
  "version": 1,
6
6
  "policyVersion": 2,
7
- "sha256": "f55ece1aea76251ec005f2b731fa44a58b6245fef8150b58486ae062167a2377",
7
+ "sha256": "6fe1ef55363e6e3f38253ca94b17ec50c942352c00d8431cd2ff2f6858c094b6",
8
8
  "historical": [
9
9
  "78be7e6613c012280b7ad17886462ba166b63ebd031e34565d757b3a0796d7cc",
10
10
  "5dc2a5c31131f4ac7d8657c78b950de52776aad6eaefe78ea0d764a9963c4425",
@@ -14,7 +14,8 @@
14
14
  "9a565cec31d9e091e3fb9420b86685f824733bc1ebe479f086b2b955aba6ef3e",
15
15
  "59fc4224975ad64cfc85bab50ec60d9bd4948070e9d41f42ab43e6d8231c19a1",
16
16
  "6edfbb31ce0551b27e38ae1ffd1daf9cc4bea54f2c86687c5124132af5b8c0af",
17
- "507a57a15f1b967103bb1eefe701a74813d5a9603fa7bcc15579a215b03621e3"
17
+ "507a57a15f1b967103bb1eefe701a74813d5a9603fa7bcc15579a215b03621e3",
18
+ "f55ece1aea76251ec005f2b731fa44a58b6245fef8150b58486ae062167a2377"
18
19
  ]
19
20
  },
20
21
  "settingsEntries": {
@@ -0,0 +1,127 @@
1
+ // javi-forge-managed: opencode-skillguard v1
2
+ async function importPolicyRuntime() {
3
+ try {
4
+ return await import("./javi-forge-skillguard-pre-tool-use.mjs");
5
+ } catch (error) {
6
+ if (
7
+ !isObject(error) ||
8
+ !("code" in error) ||
9
+ error.code !== "ERR_MODULE_NOT_FOUND"
10
+ )
11
+ throw error;
12
+ return import("../claude-hooks/javi-forge-skillguard-pre-tool-use.mjs");
13
+ }
14
+ }
15
+
16
+ const { AGENT_CONFIGS, evaluateEvent } = await importPolicyRuntime();
17
+
18
+ const TOOL_NAME_MAP = Object.freeze({
19
+ apply_patch: "apply_patch",
20
+ bash: "Bash",
21
+ edit: "Edit",
22
+ powershell: "PowerShell",
23
+ pwsh: "PowerShell",
24
+ read: "Read",
25
+ shell: "Bash",
26
+ write: "Write",
27
+ });
28
+
29
+ function isObject(value) {
30
+ return typeof value === "object" && value !== null;
31
+ }
32
+
33
+ function firstString(...values) {
34
+ for (const value of values) if (typeof value === "string") return value;
35
+ return undefined;
36
+ }
37
+
38
+ function mapToolName(tool) {
39
+ return typeof tool === "string" ? TOOL_NAME_MAP[tool] : undefined;
40
+ }
41
+
42
+ function normalizeOpenCodeToolEvent(input, output, fallbackCwd = process.cwd()) {
43
+ const tool = mapToolName(input?.tool);
44
+ if (tool === undefined) return null;
45
+ const args = isObject(output?.args)
46
+ ? output.args
47
+ : isObject(input?.input)
48
+ ? input.input
49
+ : isObject(input?.args)
50
+ ? input.args
51
+ : {};
52
+ const cwd = firstString(args.cwd, input.cwd, input.directory, fallbackCwd);
53
+ const toolInput =
54
+ tool === "Bash" || tool === "PowerShell" || tool === "apply_patch"
55
+ ? { command: firstString(args.command, args.cmd) }
56
+ : { file_path: firstString(args.file_path, args.filePath, args.path, args.file) };
57
+ return {
58
+ hook_event_name: "PreToolUse",
59
+ tool_name: tool,
60
+ tool_input: toolInput,
61
+ cwd,
62
+ };
63
+ }
64
+
65
+ export function evaluateOpenCodeToolEvent(
66
+ input,
67
+ output,
68
+ fallbackCwd = process.cwd(),
69
+ ) {
70
+ const event = normalizeOpenCodeToolEvent(input, output, fallbackCwd);
71
+ if (event === null) return { allowed: true };
72
+ return evaluateEvent(event, AGENT_CONFIGS.opencode);
73
+ }
74
+
75
+ function denyMessage(toolName, decision) {
76
+ const tool = typeof toolName === "string" ? toolName : "tool";
77
+ return `javi-forge OpenCode denied ${tool} [${decision.ruleId ?? "unknown"}]: global guard policy denied the invocation`;
78
+ }
79
+
80
+ function failClosedMessage(error) {
81
+ const id =
82
+ error instanceof Error && /^[a-z-]+$/.test(error.message)
83
+ ? error.message
84
+ : "internal-error";
85
+ return `javi-forge OpenCode failed closed [${id}]: policy evaluation could not complete`;
86
+ }
87
+
88
+ export async function guardOpenCodeTool(input, output, fallbackCwd) {
89
+ let decision;
90
+ try {
91
+ decision = evaluateOpenCodeToolEvent(input, output, fallbackCwd);
92
+ } catch (error) {
93
+ throw new Error(failClosedMessage(error));
94
+ }
95
+ if (!decision.allowed) throw new Error(denyMessage(input?.tool, decision));
96
+ }
97
+
98
+ function resolveFallbackCwd(ctx) {
99
+ return firstString(
100
+ ctx?.location?.project?.canonical,
101
+ ctx?.location?.project?.directory,
102
+ ctx?.worktree,
103
+ ctx?.directory,
104
+ process.cwd(),
105
+ );
106
+ }
107
+
108
+ export async function JaviForgeSkillGuard(ctx = {}) {
109
+ const fallbackCwd = resolveFallbackCwd(ctx);
110
+ return {
111
+ "tool.execute.before": async (input, output) =>
112
+ guardOpenCodeTool(input, output, fallbackCwd),
113
+ };
114
+ }
115
+
116
+ export default {
117
+ id: "javi-forge.skillguard",
118
+ async setup(ctx) {
119
+ const fallbackCwd = resolveFallbackCwd(ctx);
120
+ await ctx.tool.hook("execute.before", (event) =>
121
+ guardOpenCodeTool(event, undefined, fallbackCwd),
122
+ );
123
+ },
124
+ async server(ctx = {}) {
125
+ return JaviForgeSkillGuard(ctx);
126
+ },
127
+ };
@@ -0,0 +1,9 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "plugin": {
4
+ "name": "javi-forge-skillguard-plugin.mjs",
5
+ "version": 1,
6
+ "sha256": "901feed0cb3b50a7b9b0e3e99290d49b8bd25fc85a305513f947d34726b428d5",
7
+ "historical": []
8
+ }
9
+ }
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Subcommands:
8
8
  * - `hooks run <pre-commit|pre-push>` → runHook, exits with its code.
9
- * - `hooks <install|doctor|repair> <claude|codex> [--force]` → the matching
9
+ * - `hooks <install|doctor|repair> <claude|codex|opencode> [--force]` → the matching
10
10
  * agent command, exits with its code (unknown/missing agent → usage + exit 1).
11
11
  * Any other subcommand or a missing name → usage + exit 1.
12
12
  *
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Subcommands:
8
8
  * - `hooks run <pre-commit|pre-push>` → runHook, exits with its code.
9
- * - `hooks <install|doctor|repair> <claude|codex> [--force]` → the matching
9
+ * - `hooks <install|doctor|repair> <claude|codex|opencode> [--force]` → the matching
10
10
  * agent command, exits with its code (unknown/missing agent → usage + exit 1).
11
11
  * Any other subcommand or a missing name → usage + exit 1.
12
12
  *
@@ -23,6 +23,7 @@ import { HOOKS_HELP_TEXT } from "../help.js";
23
23
  const AGENT_COMMAND_LOADERS = {
24
24
  claude: async () => (await import("../../commands/claude-hooks.js")).runClaudeHookCommand,
25
25
  codex: async () => (await import("../../commands/codex-hooks.js")).runCodexHookCommand,
26
+ opencode: async () => (await import("../../commands/opencode-hooks.js")).runOpenCodeHookCommand,
26
27
  };
27
28
  export async function handleHooks(cli) {
28
29
  if (cli.flags.help === true) {
@@ -45,7 +46,7 @@ export async function handleHooks(cli) {
45
46
  // Validity is decided by the agent registry (isAgentId), the single source
46
47
  // of truth; the loader map is exhaustive over AgentId by type.
47
48
  if (!isAgentId(agent)) {
48
- console.error(`Usage: javi-forge hooks ${sub} <claude|codex>`);
49
+ console.error(`Usage: javi-forge hooks ${sub} <claude|codex|opencode>`);
49
50
  process.exit(1);
50
51
  }
51
52
  const run = await AGENT_COMMAND_LOADERS[agent]();
@@ -19,7 +19,7 @@ export declare const CI_HELP_TEXT = "\n Usage\n $ javi-forge ci [subcommand]
19
19
  * Per-command help for `hooks`, shown by `javi-forge hooks --help` (or when
20
20
  * `hooks` is given an unknown subcommand). Whitespace is significant.
21
21
  */
22
- export declare const HOOKS_HELP_TEXT = "\n Usage\n $ javi-forge hooks run <pre-commit|pre-push>\n $ javi-forge hooks <install|doctor|repair> <claude|codex> [--force]\n\n Run the sections enabled under hooks: in .javi-forge/ci.yaml, in a fixed\n cheap\u2192expensive order, fail-fast. With no hooks: config the default is the\n quick native CI gate (setup + lint + compile + gates \u2014 no tests, no coverage).\n\n Subcommands\n run pre-commit Run the composed pre-commit sections\n run pre-push Run the composed pre-push sections\n install claude Install the managed Claude PreToolUse guard (.claude/)\n doctor claude Report Claude PreToolUse guard health (informational)\n repair claude Repair the managed guard; --force overwrites edited assets\n install codex Install the managed Codex PreToolUse guard (~/.codex/)\n doctor codex Report Codex hook execution readiness and trust boundary\n repair codex Repair the managed Codex guard; --force overwrites edits\n\n Notes\n A blocking section failure exits non-zero and blocks the commit/push.\n A broken .javi-forge/ci.yaml exits 1 (fail-closed \u2014 never skips a gate).\n To skip: git commit --no-verify (pre-push: git push --no-verify)\n doctor claude is informational (always exits 0); install/repair exit 0 on\n success, non-zero on refusal/failure. Use repair claude --force to overwrite\n a locally edited managed asset.\n doctor codex exits 0 when runnable, 1 when blocked, and 2 when inconclusive;\n it does not prove provider trust or runtime execution. Use repair codex\n --force only to overwrite an edited managed asset.\n Linux: install/repair claude need the acl package (getfacl) to prove the\n parent chain \u2014 apt install acl / apk add acl / dnf install acl. Without it\n they refuse fail-closed; an already-installed guard keeps firing, and\n doctor claude reports the acl capability as its own row.\n Claude Code spawns the guard with node from ITS path, so node must resolve\n there, not only inside javi-forge.\n\n Examples\n $ javi-forge hooks run pre-commit\n $ javi-forge hooks run pre-push\n $ javi-forge hooks install claude\n $ javi-forge hooks doctor claude\n $ javi-forge hooks repair claude --force\n $ javi-forge hooks install codex\n $ javi-forge hooks doctor codex\n $ javi-forge hooks repair codex --force\n";
22
+ export declare const HOOKS_HELP_TEXT = "\n Usage\n $ javi-forge hooks run <pre-commit|pre-push>\n $ javi-forge hooks <install|doctor|repair> <claude|codex|opencode> [--force]\n\n Run the sections enabled under hooks: in .javi-forge/ci.yaml, in a fixed\n cheap\u2192expensive order, fail-fast. With no hooks: config the default is the\n quick native CI gate (setup + lint + compile + gates \u2014 no tests, no coverage).\n\n Subcommands\n run pre-commit Run the composed pre-commit sections\n run pre-push Run the composed pre-push sections\n install claude Install the managed Claude PreToolUse guard (.claude/)\n doctor claude Report Claude PreToolUse guard health (informational)\n repair claude Repair the managed guard; --force overwrites edited assets\n install codex Install the managed Codex PreToolUse guard (~/.codex/)\n doctor codex Report Codex hook execution readiness and trust boundary\n repair codex Repair the managed Codex guard; --force overwrites edits\n install opencode Install the managed OpenCode global plugin (~/.config/opencode/plugins/)\n doctor opencode Report OpenCode plugin and policy file currency (informational)\n repair opencode Repair the OpenCode plugin pair; --force overwrites edits\n\n Notes\n A blocking section failure exits non-zero and blocks the commit/push.\n A broken .javi-forge/ci.yaml exits 1 (fail-closed \u2014 never skips a gate).\n To skip: git commit --no-verify (pre-push: git push --no-verify)\n doctor claude is informational (always exits 0); install/repair exit 0 on\n success, non-zero on refusal/failure. Use repair claude --force to overwrite\n a locally edited managed asset.\n doctor codex exits 0 when runnable, 1 when blocked, and 2 when inconclusive;\n it does not prove provider trust or runtime execution. Use repair codex\n --force only to overwrite an edited managed asset.\n doctor opencode is informational and inspects installed files only; it does\n not prove OpenCode discovered, loaded, or executed the plugin.\n Linux: install/repair claude need the acl package (getfacl) to prove the\n parent chain \u2014 apt install acl / apk add acl / dnf install acl. Without it\n they refuse fail-closed; an already-installed guard keeps firing, and\n doctor claude reports the acl capability as its own row.\n Claude Code spawns the guard with node from ITS path, so node must resolve\n there, not only inside javi-forge.\n\n Examples\n $ javi-forge hooks run pre-commit\n $ javi-forge hooks run pre-push\n $ javi-forge hooks install claude\n $ javi-forge hooks doctor claude\n $ javi-forge hooks repair claude --force\n $ javi-forge hooks install codex\n $ javi-forge hooks doctor codex\n $ javi-forge hooks repair codex --force\n $ javi-forge hooks install opencode\n $ javi-forge hooks doctor opencode\n $ javi-forge hooks repair opencode --force\n";
23
23
  export declare const FLAGS_SCHEMA: {
24
24
  readonly help: {
25
25
  readonly type: "boolean";
package/dist/cli/help.js CHANGED
@@ -167,7 +167,7 @@ export const CI_HELP_TEXT = `
167
167
  export const HOOKS_HELP_TEXT = `
168
168
  Usage
169
169
  $ javi-forge hooks run <pre-commit|pre-push>
170
- $ javi-forge hooks <install|doctor|repair> <claude|codex> [--force]
170
+ $ javi-forge hooks <install|doctor|repair> <claude|codex|opencode> [--force]
171
171
 
172
172
  Run the sections enabled under hooks: in .javi-forge/ci.yaml, in a fixed
173
173
  cheap→expensive order, fail-fast. With no hooks: config the default is the
@@ -182,6 +182,9 @@ export const HOOKS_HELP_TEXT = `
182
182
  install codex Install the managed Codex PreToolUse guard (~/.codex/)
183
183
  doctor codex Report Codex hook execution readiness and trust boundary
184
184
  repair codex Repair the managed Codex guard; --force overwrites edits
185
+ install opencode Install the managed OpenCode global plugin (~/.config/opencode/plugins/)
186
+ doctor opencode Report OpenCode plugin and policy file currency (informational)
187
+ repair opencode Repair the OpenCode plugin pair; --force overwrites edits
185
188
 
186
189
  Notes
187
190
  A blocking section failure exits non-zero and blocks the commit/push.
@@ -193,6 +196,8 @@ export const HOOKS_HELP_TEXT = `
193
196
  doctor codex exits 0 when runnable, 1 when blocked, and 2 when inconclusive;
194
197
  it does not prove provider trust or runtime execution. Use repair codex
195
198
  --force only to overwrite an edited managed asset.
199
+ doctor opencode is informational and inspects installed files only; it does
200
+ not prove OpenCode discovered, loaded, or executed the plugin.
196
201
  Linux: install/repair claude need the acl package (getfacl) to prove the
197
202
  parent chain — apt install acl / apk add acl / dnf install acl. Without it
198
203
  they refuse fail-closed; an already-installed guard keeps firing, and
@@ -209,6 +214,9 @@ export const HOOKS_HELP_TEXT = `
209
214
  $ javi-forge hooks install codex
210
215
  $ javi-forge hooks doctor codex
211
216
  $ javi-forge hooks repair codex --force
217
+ $ javi-forge hooks install opencode
218
+ $ javi-forge hooks doctor opencode
219
+ $ javi-forge hooks repair opencode --force
212
220
  `;
213
221
  export const FLAGS_SCHEMA = {
214
222
  // `--help` is handled manually (autoHelp is disabled at the entrypoint so
@@ -0,0 +1,15 @@
1
+ /** Console renderer for the OpenCode SkillGuard global plugin manager. */
2
+ import { doctorOpenCodeSkillGuard, installOpenCodeSkillGuard, repairOpenCodeSkillGuard } from "../lib/opencode-hook-manager.js";
3
+ export type OpenCodeHookSub = "install" | "doctor" | "repair";
4
+ export interface OpenCodeHookCmdDeps {
5
+ install?: typeof installOpenCodeSkillGuard;
6
+ doctor?: typeof doctorOpenCodeSkillGuard;
7
+ repair?: typeof repairOpenCodeSkillGuard;
8
+ homeDir?: string;
9
+ log?: (msg: string) => void;
10
+ logError?: (msg: string) => void;
11
+ }
12
+ export declare function runOpenCodeHookCommand(sub: OpenCodeHookSub, _cwd: string, opts: {
13
+ force?: boolean;
14
+ }, deps?: OpenCodeHookCmdDeps): Promise<number>;
15
+ //# sourceMappingURL=opencode-hooks.d.ts.map
@@ -0,0 +1,41 @@
1
+ /** Console renderer for the OpenCode SkillGuard global plugin manager. */
2
+ import { doctorOpenCodeSkillGuard, installOpenCodeSkillGuard, repairOpenCodeSkillGuard, } from "../lib/opencode-hook-manager.js";
3
+ function renderMutation(verb, result, log, logError) {
4
+ if (!result.ok) {
5
+ logError(`${verb} opencode: refused`);
6
+ for (const error of result.errors)
7
+ logError(` ${error}`);
8
+ return 1;
9
+ }
10
+ log(`${verb} opencode: ok`);
11
+ if (result.changed.length === 0)
12
+ log("changed: nothing (already up to date)");
13
+ else
14
+ for (const changed of result.changed)
15
+ log(`changed: ${changed}`);
16
+ return 0;
17
+ }
18
+ function renderDoctor(report, log) {
19
+ log(`doctor opencode: ${report.healthy ? "healthy" : "unhealthy"}`);
20
+ log(` plugin: ${report.plugin.state}`);
21
+ log(` policy: ${report.policy.state}`);
22
+ log(" runtime: not verified (installed files only)");
23
+ for (const remediation of report.remediation)
24
+ log(` remediation: ${remediation}`);
25
+ // This is intentionally informational: local files cannot prove that OpenCode
26
+ // discovered, loaded, or invoked a plugin in a real session.
27
+ return 0;
28
+ }
29
+ export async function runOpenCodeHookCommand(sub, _cwd, opts, deps = {}) {
30
+ const log = deps.log ?? ((message) => console.log(message));
31
+ const logError = deps.logError ?? ((message) => console.error(message));
32
+ const home = deps.homeDir;
33
+ if (sub === "install")
34
+ return renderMutation("install", await (deps.install ?? installOpenCodeSkillGuard)(home), log, logError);
35
+ if (sub === "repair")
36
+ return renderMutation("repair", await (deps.repair ?? repairOpenCodeSkillGuard)(home, {
37
+ force: opts.force === true,
38
+ }), log, logError);
39
+ return renderDoctor(await (deps.doctor ?? doctorOpenCodeSkillGuard)(home), log);
40
+ }
41
+ //# sourceMappingURL=opencode-hooks.js.map
@@ -13,6 +13,8 @@ interface FakeFile {
13
13
  }
14
14
  /** Per-call fault predicates; every one defaults to "no fault". */
15
15
  export interface FakeFaults {
16
+ closeRefuse?: (dirPath: string) => boolean;
17
+ reuseFirstHandle?: (dirPath: string) => boolean;
16
18
  /** Refuse revalidateIdentity for a path on its Nth (1-based) call. */
17
19
  revalidateRefuse?: (target: string, callIndex: number) => boolean;
18
20
  /** Refuse proveOwnershipAndMode for a path on its Nth call. */
@@ -57,8 +59,11 @@ export interface FakeSecureFs extends PlatformSecureFs {
57
59
  readonly dirs: Set<string>;
58
60
  readonly files: Map<string, FakeFile>;
59
61
  readonly dirModes: Map<string, number>;
62
+ readonly events: string[];
63
+ readonly closeAttempts: string[];
60
64
  faults: FakeFaults;
61
65
  seedDir(dirPath: string): void;
66
+ shareDirIdentity(target: string, source: string): void;
62
67
  seedFile(filePath: string, bytes: Buffer, mode?: number): void;
63
68
  fileText(filePath: string): string | undefined;
64
69
  hasBackup(dirPath: string): boolean;
@@ -34,6 +34,9 @@ export function makeFakeSecureFs() {
34
34
  const endangeringCounts = new Map();
35
35
  const writeCounts = new Map();
36
36
  const managedCounts = new Map();
37
+ const events = [];
38
+ const closeAttempts = [];
39
+ let handleSeq = 0, firstHandle;
37
40
  const inoFor = (p) => {
38
41
  let ino = inos.get(p);
39
42
  if (ino === undefined) {
@@ -47,11 +50,23 @@ export function makeFakeSecureFs() {
47
50
  m.set(key, next);
48
51
  return next;
49
52
  };
50
- const handleFor = (p) => ({
51
- path: p,
52
- identity: { dev: 1, ino: inoFor(p) },
53
- close: async () => { },
54
- });
53
+ const handleFor = (p) => {
54
+ if (firstHandle && fake.faults.reuseFirstHandle?.(p))
55
+ return firstHandle;
56
+ const token = `${p}#${++handleSeq}`;
57
+ const handle = {
58
+ path: p,
59
+ identity: { dev: 1, ino: inoFor(p) },
60
+ close: async () => {
61
+ events.push(`close ${token}`);
62
+ closeAttempts.push(token);
63
+ if (fake.faults.closeRefuse?.(p))
64
+ throw new Error(`close failed ${p}`);
65
+ },
66
+ };
67
+ firstHandle ??= handle;
68
+ return handle;
69
+ };
55
70
  const isEmptyDir = (p) => {
56
71
  const prefix = `${p}/`;
57
72
  for (const f of files.keys())
@@ -66,10 +81,15 @@ export function makeFakeSecureFs() {
66
81
  dirs,
67
82
  files,
68
83
  dirModes,
84
+ events,
85
+ closeAttempts,
69
86
  faults: {},
70
87
  seedDir(dirPath) {
71
88
  dirs.add(dirPath);
72
89
  },
90
+ shareDirIdentity(target, source) {
91
+ inos.set(target, inoFor(source));
92
+ },
73
93
  seedFile(filePath, bytes, mode = 0o644) {
74
94
  files.set(filePath, { bytes, mode });
75
95
  },
@@ -94,6 +114,7 @@ export function makeFakeSecureFs() {
94
114
  return okValue(handleFor(dirPath));
95
115
  },
96
116
  async revalidateIdentity(target, held) {
117
+ events.push(`revalidate ${target}`);
97
118
  const idx = bump(revalidateCounts, target);
98
119
  if (fake.faults.revalidateRefuse?.(target, idx)) {
99
120
  return unsafe(`identity drift ${target}`);
@@ -135,6 +156,7 @@ export function makeFakeSecureFs() {
135
156
  },
136
157
  async createDirExclusive(parent, name, mode) {
137
158
  const full = path.join(parent.path, name);
159
+ events.push(`create ${full}`);
138
160
  if (dirs.has(full) || files.has(full))
139
161
  return unsafe(`EEXIST ${full}`);
140
162
  dirs.add(full);
@@ -199,6 +221,7 @@ export function makeFakeSecureFs() {
199
221
  return ok();
200
222
  },
201
223
  async rmdirIfIdentityEmpty(handle) {
224
+ events.push(`rmdir ${handle.path}`);
202
225
  if (!dirs.has(handle.path))
203
226
  return unsafe(`rmdir enoent ${handle.path}`);
204
227
  if (inoFor(handle.path) !== handle.identity.ino) {
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Agent adapter registry (agent-agnostic slice 2). One descriptor per host
3
- * (`claude`, `codex`) capturing the per-agent facts the SkillGuard installer and
3
+ * (`claude`, `codex`, `opencode`) capturing the per-agent facts the SkillGuard installer and
4
4
  * doctor need: config paths, the protected managed set, the project-root source,
5
- * the settings-schema validator (SHARED both hosts use the identical hooks
6
- * schema), the managed marker, the deny protocol, and the trust model.
5
+ * the host registration schema, the managed marker, the deny protocol, and the
6
+ * trust model.
7
7
  *
8
8
  * This descriptor is additive: Codex is routed through it, while Claude keeps its
9
9
  * existing, byte-identical runtime path (the descriptor only DESCRIBES Claude; it
@@ -14,7 +14,7 @@
14
14
  * map is typed `Record<AgentId, …>`, so omitting a loader is a compile error.
15
15
  */
16
16
  import { validateSettingsShape } from "./claude-hook-settings.js";
17
- export type AgentId = "claude" | "codex";
17
+ export type AgentId = "claude" | "codex" | "opencode";
18
18
  export type TrustState = "trusted" | "untrusted" | "unknown";
19
19
  export interface TrustDescriptor {
20
20
  /** Detect whether the installed hook is TRUSTED from the host config text. */
@@ -35,17 +35,18 @@ export interface AgentAdapter {
35
35
  projectDir: {
36
36
  envVar: string | null;
37
37
  };
38
- /** SHARED settings-schema validator the hooks container shape is identical. */
39
- settingsSchema: typeof validateSettingsShape;
38
+ /** Host settings-schema validator, or null when the host does not use one. */
39
+ settingsSchema: typeof validateSettingsShape | null;
40
40
  /** The managed asset marker for this host. */
41
41
  marker: string;
42
- /** Deny protocol emitted by the shared `.mjs` — the same on both hosts. */
43
- emitDeny: "exit2+stderr";
42
+ /** Deny protocol emitted by the host integration. */
43
+ emitDeny: "exit2+stderr" | "throw-error";
44
44
  /** Hook-trust model, or null when the host has none (Claude). */
45
45
  trust: TrustDescriptor | null;
46
46
  }
47
47
  export declare const claudeAdapter: AgentAdapter;
48
48
  export declare const codexAdapter: AgentAdapter;
49
+ export declare const opencodeAdapter: AgentAdapter;
49
50
  export declare const AGENT_ADAPTERS: Record<AgentId, AgentAdapter>;
50
51
  /**
51
52
  * Whether a raw CLI token names a known agent adapter. Backed by the
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Agent adapter registry (agent-agnostic slice 2). One descriptor per host
3
- * (`claude`, `codex`) capturing the per-agent facts the SkillGuard installer and
3
+ * (`claude`, `codex`, `opencode`) capturing the per-agent facts the SkillGuard installer and
4
4
  * doctor need: config paths, the protected managed set, the project-root source,
5
- * the settings-schema validator (SHARED both hosts use the identical hooks
6
- * schema), the managed marker, the deny protocol, and the trust model.
5
+ * the host registration schema, the managed marker, the deny protocol, and the
6
+ * trust model.
7
7
  *
8
8
  * This descriptor is additive: Codex is routed through it, while Claude keeps its
9
9
  * existing, byte-identical runtime path (the descriptor only DESCRIBES Claude; it
@@ -38,6 +38,20 @@ const CODEX_MANAGED_SET = [
38
38
  ".claude/agents/",
39
39
  ".claude/skills/",
40
40
  ];
41
+ const OPENCODE_MANAGED_SET = [
42
+ ".config/opencode/plugins/javi-forge-skillguard-plugin.mjs",
43
+ ".config/opencode/plugins/javi-forge-skillguard-pre-tool-use.mjs",
44
+ ".opencode/opencode.json",
45
+ "opencode.json",
46
+ ".claude/settings.json",
47
+ ".claude/settings.local.json",
48
+ ".claude/CLAUDE.md",
49
+ "CLAUDE.md",
50
+ ".javi-forge/ci.yaml",
51
+ ".claude/hooks/",
52
+ ".claude/agents/",
53
+ ".claude/skills/",
54
+ ];
41
55
  export const claudeAdapter = {
42
56
  id: "claude",
43
57
  configPaths(projectDir) {
@@ -73,9 +87,26 @@ export const codexAdapter = {
73
87
  grantCommand: codexTrustGrantCommand,
74
88
  },
75
89
  };
90
+ export const opencodeAdapter = {
91
+ id: "opencode",
92
+ configPaths(homeDir) {
93
+ const pluginsDir = path.join(homeDir, ".config", "opencode", "plugins");
94
+ return {
95
+ hooksFile: path.join(pluginsDir, "javi-forge-skillguard-plugin.mjs"),
96
+ settingsFile: path.join(pluginsDir, "javi-forge-skillguard-pre-tool-use.mjs"),
97
+ };
98
+ },
99
+ managedSet: OPENCODE_MANAGED_SET,
100
+ projectDir: { envVar: null },
101
+ settingsSchema: null,
102
+ marker: "// javi-forge-managed: opencode-skillguard v1",
103
+ emitDeny: "throw-error",
104
+ trust: null,
105
+ };
76
106
  export const AGENT_ADAPTERS = {
77
107
  claude: claudeAdapter,
78
108
  codex: codexAdapter,
109
+ opencode: opencodeAdapter,
79
110
  };
80
111
  /**
81
112
  * Whether a raw CLI token names a known agent adapter. Backed by the
@@ -0,0 +1,74 @@
1
+ /**
2
+ * OpenCode SkillGuard plugin ownership manager. OpenCode discovers global
3
+ * plugins from `~/.config/opencode/plugins`, so installation is a two-file
4
+ * transaction: the plugin and its side-by-side policy runtime. No user config
5
+ * is edited and doctor deliberately reports installed bytes only, never proof
6
+ * that an OpenCode runtime loaded or executed the plugin.
7
+ */
8
+ import type { AssetManifestEntry } from "./claude-hook-manager.js";
9
+ import type { ClaudeHookComponentState } from "./claude-hook-settings.js";
10
+ import { type PlatformSecureFs } from "./secure-fs-transaction.js";
11
+ export declare const OPENCODE_PLUGIN_NAME = "javi-forge-skillguard-plugin.mjs";
12
+ export declare const OPENCODE_POLICY_NAME = "javi-forge-skillguard-pre-tool-use.mjs";
13
+ export declare const OPENCODE_PLUGIN_MARKER = "// javi-forge-managed: opencode-skillguard v1";
14
+ export declare const OPENCODE_POLICY_MARKER = "// javi-forge-managed: claude-pretooluse v1";
15
+ export declare const OPENCODE_ASSETS_DIR: string;
16
+ export declare const SHIPPED_OPENCODE_PLUGIN: string;
17
+ export declare const SHIPPED_OPENCODE_POLICY: string;
18
+ export interface OpenCodeManifest {
19
+ plugin: AssetManifestEntry;
20
+ policy: AssetManifestEntry;
21
+ }
22
+ export interface OpenCodeConfigPaths {
23
+ configDir: string;
24
+ pluginsDir: string;
25
+ pluginFile: string;
26
+ policyFile: string;
27
+ }
28
+ export declare function opencodeConfigPaths(baseDir: string): OpenCodeConfigPaths;
29
+ export interface OpenCodeAssetClassification {
30
+ state: ClaudeHookComponentState;
31
+ sha256?: string;
32
+ version?: number;
33
+ detail?: string;
34
+ }
35
+ /** Classify one installed managed module using full-byte manifest hashes. */
36
+ export declare function classifyOpenCodeAsset(target: string, marker: string, manifest: AssetManifestEntry): Promise<OpenCodeAssetClassification>;
37
+ export interface OpenCodeHookDoctorReport {
38
+ healthy: boolean;
39
+ plugin: OpenCodeAssetClassification;
40
+ policy: OpenCodeAssetClassification;
41
+ remediation: string[];
42
+ /** Installed-file inspection only; OpenCode runtime loading is not observed. */
43
+ runtimeEvidence: "not-implemented";
44
+ }
45
+ export interface OpenCodeDoctorOptions {
46
+ manifest?: OpenCodeManifest;
47
+ pluginSource?: string;
48
+ policySource?: string;
49
+ }
50
+ export declare function doctorOpenCodeSkillGuard(baseDir?: string, options?: OpenCodeDoctorOptions): Promise<OpenCodeHookDoctorReport>;
51
+ interface OpenCodeMutationSuccess {
52
+ ok: boolean;
53
+ changed: string[];
54
+ backups: string[];
55
+ errors: string[];
56
+ warnings: string[];
57
+ report: OpenCodeHookDoctorReport;
58
+ }
59
+ export type OpenCodeHookMutationResult = OpenCodeMutationSuccess;
60
+ export interface OpenCodeHookRunDeps extends OpenCodeDoctorOptions {
61
+ secureFs?: PlatformSecureFs | null;
62
+ clock?: () => Date;
63
+ nonce?: () => string;
64
+ doctor?: typeof doctorOpenCodeSkillGuard;
65
+ }
66
+ export declare function _runOpenCode(baseDir: string, mode: "install" | "repair", options: {
67
+ force?: boolean;
68
+ }, deps?: OpenCodeHookRunDeps): Promise<OpenCodeHookMutationResult>;
69
+ export declare function installOpenCodeSkillGuard(baseDir?: string, deps?: OpenCodeHookRunDeps): Promise<OpenCodeHookMutationResult>;
70
+ export declare function repairOpenCodeSkillGuard(baseDir?: string, options?: {
71
+ force?: boolean;
72
+ }, deps?: OpenCodeHookRunDeps): Promise<OpenCodeHookMutationResult>;
73
+ export {};
74
+ //# sourceMappingURL=opencode-hook-manager.d.ts.map
@@ -0,0 +1,274 @@
1
+ /**
2
+ * OpenCode SkillGuard plugin ownership manager. OpenCode discovers global
3
+ * plugins from `~/.config/opencode/plugins`, so installation is a two-file
4
+ * transaction: the plugin and its side-by-side policy runtime. No user config
5
+ * is edited and doctor deliberately reports installed bytes only, never proof
6
+ * that an OpenCode runtime loaded or executed the plugin.
7
+ */
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import { lstat, readFile } from "node:fs/promises";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { CLAUDE_HOOK_ASSETS_DIR, FORGE_ROOT } from "../constants.js";
13
+ import { safeReadFile } from "./safe-read.js";
14
+ import { selectSecureFs } from "./secure-fs-posix.js";
15
+ import { runTransaction, } from "./secure-fs-transaction.js";
16
+ const MAX_BYTES = 1024 * 1024;
17
+ const READ_OPTS = {
18
+ maxBytes: MAX_BYTES,
19
+ hardRejectOverBytes: MAX_BYTES,
20
+ maxLineLength: Number.POSITIVE_INFINITY,
21
+ };
22
+ export const OPENCODE_PLUGIN_NAME = "javi-forge-skillguard-plugin.mjs";
23
+ export const OPENCODE_POLICY_NAME = "javi-forge-skillguard-pre-tool-use.mjs";
24
+ export const OPENCODE_PLUGIN_MARKER = "// javi-forge-managed: opencode-skillguard v1";
25
+ export const OPENCODE_POLICY_MARKER = "// javi-forge-managed: claude-pretooluse v1";
26
+ export const OPENCODE_ASSETS_DIR = path.join(FORGE_ROOT, "assets", "opencode-plugins");
27
+ export const SHIPPED_OPENCODE_PLUGIN = path.join(OPENCODE_ASSETS_DIR, OPENCODE_PLUGIN_NAME);
28
+ export const SHIPPED_OPENCODE_POLICY = path.join(CLAUDE_HOOK_ASSETS_DIR, OPENCODE_POLICY_NAME);
29
+ export function opencodeConfigPaths(baseDir) {
30
+ const configDir = path.join(baseDir, ".config", "opencode");
31
+ const pluginsDir = path.join(configDir, "plugins");
32
+ return {
33
+ configDir,
34
+ pluginsDir,
35
+ pluginFile: path.join(pluginsDir, OPENCODE_PLUGIN_NAME),
36
+ policyFile: path.join(pluginsDir, OPENCODE_POLICY_NAME),
37
+ };
38
+ }
39
+ async function fileKind(target) {
40
+ try {
41
+ const stat = await lstat(target);
42
+ if (stat.isSymbolicLink())
43
+ return { kind: "symlink" };
44
+ return stat.isFile() ? { kind: "file" } : { kind: "non-regular" };
45
+ }
46
+ catch (error) {
47
+ const code = error.code;
48
+ if (code === "ENOENT" || code === "ENOTDIR")
49
+ return { kind: "absent" };
50
+ return { kind: "error", detail: code ?? String(error) };
51
+ }
52
+ }
53
+ function sha256(content) {
54
+ return createHash("sha256")
55
+ .update(Buffer.from(content, "utf8"))
56
+ .digest("hex");
57
+ }
58
+ /** Classify one installed managed module using full-byte manifest hashes. */
59
+ export async function classifyOpenCodeAsset(target, marker, manifest) {
60
+ const stat = await fileKind(target);
61
+ if (stat.kind === "absent")
62
+ return { state: "absent" };
63
+ if (stat.kind === "symlink")
64
+ return { state: "symlink" };
65
+ if (stat.kind !== "file")
66
+ return { state: "non-regular", detail: stat.detail };
67
+ const read = await safeReadFile(target, READ_OPTS);
68
+ if (!read.ok) {
69
+ if (read.reason === "not-found")
70
+ return { state: "absent" };
71
+ if (read.reason === "binary" || read.reason === "too-large") {
72
+ return { state: "foreign", detail: read.reason };
73
+ }
74
+ return { state: "non-regular", detail: read.reason };
75
+ }
76
+ if (!read.content.startsWith(`${marker}\n`))
77
+ return { state: "foreign" };
78
+ const observed = sha256(read.content);
79
+ if (observed === manifest.sha256) {
80
+ return {
81
+ state: "managed-current",
82
+ sha256: observed,
83
+ version: manifest.version,
84
+ };
85
+ }
86
+ if (manifest.historical.includes(observed)) {
87
+ return {
88
+ state: "released-outdated",
89
+ sha256: observed,
90
+ version: manifest.version,
91
+ };
92
+ }
93
+ return { state: "edited-managed", sha256: observed };
94
+ }
95
+ async function readManifest() {
96
+ const [pluginRead, policyRead] = await Promise.all([
97
+ safeReadFile(path.join(OPENCODE_ASSETS_DIR, "manifest.json"), READ_OPTS),
98
+ safeReadFile(path.join(CLAUDE_HOOK_ASSETS_DIR, "manifest.json"), READ_OPTS),
99
+ ]);
100
+ if (!pluginRead.ok)
101
+ throw new Error(`unreadable opencode plugin manifest: ${pluginRead.reason}`);
102
+ if (!policyRead.ok)
103
+ throw new Error(`unreadable claude-hooks manifest: ${policyRead.reason}`);
104
+ const plugin = JSON.parse(pluginRead.content);
105
+ const policy = JSON.parse(policyRead.content);
106
+ return { plugin: plugin.plugin, policy: policy.asset };
107
+ }
108
+ export async function doctorOpenCodeSkillGuard(baseDir = os.homedir(), options = {}) {
109
+ const manifest = options.manifest ?? (await readManifest());
110
+ const paths = opencodeConfigPaths(baseDir);
111
+ const [plugin, policy] = await Promise.all([
112
+ classifyOpenCodeAsset(paths.pluginFile, OPENCODE_PLUGIN_MARKER, manifest.plugin),
113
+ classifyOpenCodeAsset(paths.policyFile, OPENCODE_POLICY_MARKER, manifest.policy),
114
+ ]);
115
+ const remediation = [];
116
+ for (const [name, asset] of [
117
+ ["plugin", plugin],
118
+ ["policy runtime", policy],
119
+ ]) {
120
+ if (asset.state === "absent" || asset.state === "released-outdated") {
121
+ remediation.push(`install the OpenCode ${name} with: javi-forge hooks install opencode`);
122
+ }
123
+ else if (asset.state === "edited-managed") {
124
+ remediation.push(`repair the edited OpenCode ${name} with: javi-forge hooks repair opencode --force`);
125
+ }
126
+ else if (asset.state !== "managed-current") {
127
+ remediation.push(`manually review the OpenCode ${name} (${asset.state}); it will not be overwritten`);
128
+ }
129
+ }
130
+ return {
131
+ healthy: plugin.state === "managed-current" && policy.state === "managed-current",
132
+ plugin,
133
+ policy,
134
+ remediation: [...new Set(remediation)],
135
+ runtimeEvidence: "not-implemented",
136
+ };
137
+ }
138
+ function writePlan(state, force, name) {
139
+ if (state === "absent" || state === "released-outdated")
140
+ return { write: true };
141
+ if (state === "managed-current")
142
+ return { write: false };
143
+ if (state === "edited-managed" && force)
144
+ return { write: true };
145
+ if (state === "edited-managed")
146
+ return {
147
+ write: false,
148
+ refusal: `refuse edited ${name}; rerun repair with --force`,
149
+ };
150
+ return {
151
+ write: false,
152
+ refusal: `refuse ${name} in state ${state} — manual review`,
153
+ };
154
+ }
155
+ export async function _runOpenCode(baseDir, mode, options, deps = {}) {
156
+ const manifest = deps.manifest ?? (await readManifest());
157
+ const paths = opencodeConfigPaths(baseDir);
158
+ const doctorFn = deps.doctor ?? doctorOpenCodeSkillGuard;
159
+ const doctor = () => doctorFn(baseDir, {
160
+ manifest,
161
+ pluginSource: deps.pluginSource,
162
+ policySource: deps.policySource,
163
+ });
164
+ const force = mode === "repair" && options.force === true;
165
+ const [plugin, policy] = await Promise.all([
166
+ classifyOpenCodeAsset(paths.pluginFile, OPENCODE_PLUGIN_MARKER, manifest.plugin),
167
+ classifyOpenCodeAsset(paths.policyFile, OPENCODE_POLICY_MARKER, manifest.policy),
168
+ ]);
169
+ const pluginPlan = writePlan(plugin.state, force, "OpenCode plugin");
170
+ const policyPlan = writePlan(policy.state, force, "OpenCode policy runtime");
171
+ if (pluginPlan.refusal || policyPlan.refusal) {
172
+ return {
173
+ ok: false,
174
+ changed: [],
175
+ backups: [],
176
+ errors: [pluginPlan.refusal ?? policyPlan.refusal ?? "refused"],
177
+ warnings: [],
178
+ report: await doctor(),
179
+ };
180
+ }
181
+ if (!pluginPlan.write && !policyPlan.write) {
182
+ return {
183
+ ok: true,
184
+ changed: [],
185
+ backups: [],
186
+ errors: [],
187
+ warnings: [],
188
+ report: await doctor(),
189
+ };
190
+ }
191
+ const secureFs = deps.secureFs !== undefined
192
+ ? deps.secureFs
193
+ : selectSecureFs(process.platform);
194
+ if (!secureFs) {
195
+ return {
196
+ ok: false,
197
+ changed: [],
198
+ backups: [],
199
+ errors: ["windows-secure-object-unavailable"],
200
+ warnings: [],
201
+ report: await doctor(),
202
+ };
203
+ }
204
+ const pluginSource = deps.pluginSource ?? SHIPPED_OPENCODE_PLUGIN;
205
+ const policySource = deps.policySource ?? SHIPPED_OPENCODE_POLICY;
206
+ const [pluginBytes, policyBytes] = await Promise.all([
207
+ pluginPlan.write
208
+ ? readFile(pluginSource)
209
+ : Promise.resolve(null),
210
+ policyPlan.write
211
+ ? readFile(policySource)
212
+ : Promise.resolve(null),
213
+ ]);
214
+ if ((pluginBytes &&
215
+ createHash("sha256").update(pluginBytes).digest("hex") !==
216
+ manifest.plugin.sha256) ||
217
+ (policyBytes &&
218
+ createHash("sha256").update(policyBytes).digest("hex") !==
219
+ manifest.policy.sha256)) {
220
+ return {
221
+ ok: false,
222
+ changed: [],
223
+ backups: [],
224
+ errors: ["refuse packaged OpenCode assets with manifest hash mismatch"],
225
+ warnings: [],
226
+ report: await doctor(),
227
+ };
228
+ }
229
+ const components = [
230
+ {
231
+ path: paths.pluginFile,
232
+ desired: pluginBytes,
233
+ capturePrior: pluginPlan.write && plugin.state !== "absent",
234
+ forceBackup: force && plugin.state === "edited-managed",
235
+ wasAbsent: plugin.state === "absent",
236
+ },
237
+ {
238
+ path: paths.policyFile,
239
+ desired: policyBytes,
240
+ capturePrior: policyPlan.write && policy.state !== "absent",
241
+ forceBackup: force && policy.state === "edited-managed",
242
+ wasAbsent: policy.state === "absent",
243
+ },
244
+ ];
245
+ const tx = await runTransaction({
246
+ secureFs,
247
+ clock: deps.clock ?? (() => new Date()),
248
+ nonce: deps.nonce ?? (() => randomBytes(4).toString("hex")),
249
+ projectDir: baseDir,
250
+ layout: {
251
+ containers: [
252
+ path.join(baseDir, ".config"),
253
+ paths.configDir,
254
+ paths.pluginsDir,
255
+ ],
256
+ components,
257
+ },
258
+ });
259
+ return {
260
+ ok: tx.ok,
261
+ changed: tx.committed,
262
+ backups: tx.backups,
263
+ errors: tx.errors,
264
+ warnings: [],
265
+ report: await doctor(),
266
+ };
267
+ }
268
+ export function installOpenCodeSkillGuard(baseDir, deps = {}) {
269
+ return _runOpenCode(baseDir ?? os.homedir(), "install", {}, deps);
270
+ }
271
+ export function repairOpenCodeSkillGuard(baseDir, options, deps = {}) {
272
+ return _runOpenCode(baseDir ?? os.homedir(), "repair", options ?? {}, deps);
273
+ }
274
+ //# sourceMappingURL=opencode-hook-manager.js.map
@@ -120,11 +120,15 @@ export async function runTransaction(input) {
120
120
  // refuse ANY extended entry (lenient-gated THEN strict-managed); only ancestor-
121
121
  // only segments loosen. No `process.platform` here — role is expressed by which
122
122
  // dirs get proveManagedContainer'd (the managedContainers set).
123
+ function hold(dirPath, handle) {
124
+ heldByPath.set(dirPath, handle);
125
+ if (!heldOrder.includes(handle))
126
+ heldOrder.push(handle);
127
+ }
123
128
  async function gate(dirPath, handle) {
124
129
  // Transfer ownership before refusal-capable proofs so `finally` closes a
125
130
  // successfully opened handle even when either proof fails.
126
- heldByPath.set(dirPath, handle);
127
- heldOrder.push(handle);
131
+ hold(dirPath, handle);
128
132
  must(`ownership ${dirPath}`, await secureFs.proveOwnershipAndMode(dirPath));
129
133
  must(`acl ${dirPath}`, await secureFs.proveNoEndangeringAcl(dirPath));
130
134
  }
@@ -164,6 +168,7 @@ export async function runTransaction(input) {
164
168
  // Own the created segment before post-create validation so any refusal rolls
165
169
  // it back and closes its handle.
166
170
  createdDirs.push(created);
171
+ hold(fullPath, created);
167
172
  // Post-create identity revalidation + full gate on the new segment.
168
173
  must(`revalidate-created ${fullPath}`, await secureFs.revalidateIdentity(fullPath, created.identity));
169
174
  await gate(fullPath, created);
@@ -191,6 +196,8 @@ export async function runTransaction(input) {
191
196
  }
192
197
  return true;
193
198
  }
199
+ let workSucceeded = false;
200
+ const errors = [];
194
201
  try {
195
202
  // --- PREFLIGHT: gate the existing chain root..projectDir ---
196
203
  for (const dirPath of ancestorChain(projectDir)) {
@@ -240,7 +247,14 @@ export async function runTransaction(input) {
240
247
  must(`stage-mode ${tName}`, await secureFs.applyExactMode(path.join(dir.path, tName), prior ? prior.mode : 0o600));
241
248
  // Identity revalidation after each writeExclusive (JD-B-005).
242
249
  must(`revalidate-staged ${parentPath}`, await secureFs.revalidateIdentity(dir.path, dir.identity));
243
- staged.push({ dir, tempName: tName, target: component, prior });
250
+ const stagedEntry = {
251
+ dir,
252
+ tempName: tName,
253
+ target: component,
254
+ prior,
255
+ };
256
+ staged.push(stagedEntry);
257
+ stagedEntry.identity = must(`capture-staged ${tName}`, await secureFs.captureFile(path.join(dir.path, tName))).identity;
244
258
  }
245
259
  // --- PRE-FIRST-RENAME FULL-GATE RE-PROVE (JD-007) ---
246
260
  for (const handle of heldOrder) {
@@ -259,7 +273,8 @@ export async function runTransaction(input) {
259
273
  }
260
274
  }
261
275
  // --- COMMIT: asset first, settings second ---
262
- for (const entry of staged) {
276
+ while (staged.length > 0) {
277
+ const entry = staged[0];
263
278
  const base = path.basename(entry.target.path);
264
279
  must(`pre-rename ${entry.dir.path}`, await secureFs.revalidateIdentity(entry.dir.path, entry.dir.identity));
265
280
  const wroteHash = sha256(entry.target.desired);
@@ -273,6 +288,7 @@ export async function runTransaction(input) {
273
288
  wasAbsent: entry.target.wasAbsent,
274
289
  prior: entry.prior,
275
290
  });
291
+ staged.shift();
276
292
  }
277
293
  else if (renamed.mutation === "unknown") {
278
294
  unknownRenameTarget = entry.target.path;
@@ -280,35 +296,38 @@ export async function runTransaction(input) {
280
296
  must(`rename ${base}`, renamed);
281
297
  must(`post-rename ${entry.dir.path}`, await secureFs.revalidateIdentity(entry.dir.path, entry.dir.identity));
282
298
  }
283
- return {
284
- ok: true,
285
- committed: committed.map((c) => c.path),
286
- backups,
287
- errors: [],
288
- };
299
+ workSucceeded = true;
289
300
  }
290
301
  catch (error) {
291
- const errors = [
292
- error instanceof TxAbort ? error.message : String(error),
293
- ];
302
+ errors.push(error instanceof TxAbort ? error.message : String(error));
294
303
  if (unknownRenameTarget) {
295
304
  errors.push(`STOP: unknown rename outcome at ${unknownRenameTarget}; manual recovery; no rollback or directory cleanup`);
296
305
  }
297
306
  else {
298
- await rollback(committed, createdDirs, errors);
307
+ try {
308
+ await rollback(committed, staged, createdDirs, errors);
309
+ }
310
+ catch (rollbackError) {
311
+ errors.push(`rollback: ${String(rollbackError)}`);
312
+ }
299
313
  }
300
- return {
301
- ok: false,
302
- committed: committed.map((c) => c.path),
303
- backups,
304
- errors,
305
- };
306
314
  }
307
315
  finally {
308
- for (const handle of new Set([...createdDirs, ...heldOrder])) {
309
- await handle.close().catch(() => { });
316
+ for (const handle of heldOrder) {
317
+ try {
318
+ await handle.close();
319
+ }
320
+ catch (error) {
321
+ errors.push(`close ${handle.path}: ${String(error)}`);
322
+ }
310
323
  }
311
324
  }
325
+ return {
326
+ ok: workSucceeded && errors.length === 0,
327
+ committed: committed.map((c) => c.path),
328
+ backups,
329
+ errors,
330
+ };
312
331
  /** A rejected or malformed rename reply never proves absence of mutation. */
313
332
  async function observeRename(dir, from, to) {
314
333
  try {
@@ -340,7 +359,7 @@ export async function runTransaction(input) {
340
359
  }
341
360
  throw new TxAbort("backup", `no exclusive backup name after ${BACKUP_NONCE_CANDIDATES}`);
342
361
  }
343
- async function rollback(done, created, errors) {
362
+ async function rollback(done, pending, created, errors) {
344
363
  for (const entry of [...done].reverse()) {
345
364
  if (!(await gateStillValid())) {
346
365
  errors.push(`STOP: lost parent-chain proof; manual recovery at ${entry.path}`);
@@ -393,6 +412,14 @@ export async function runTransaction(input) {
393
412
  }
394
413
  }
395
414
  }
415
+ for (const entry of pending) {
416
+ const identity = entry.identity ??
417
+ must(`recapture-staged ${entry.tempName}`, await secureFs.captureFile(path.join(entry.dir.path, entry.tempName))).identity;
418
+ const removed = await secureFs.unlinkIfIdentity(entry.dir, entry.tempName, identity);
419
+ const detail = removed.detail ?? removed.refusal;
420
+ if (!removed.ok)
421
+ errors.push(`cleanup ${entry.tempName}: ${detail}`);
422
+ }
396
423
  // Remove only tx-created, identity-matched, still-empty segments, child-first.
397
424
  for (const handle of [...created].reverse()) {
398
425
  await secureFs.rmdirIfIdentityEmpty(handle);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.39.4",
3
+ "version": "1.40.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {