opencode-worktree-guardian 0.1.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +353 -0
  4. package/codex/.codex-plugin/plugin.json +31 -0
  5. package/codex/hooks/guardian-hook.ts +265 -0
  6. package/codex/hooks/hooks.json +30 -0
  7. package/codex/skills/worktree-guardian/SKILL.md +29 -0
  8. package/commands/delete-paths.md +12 -0
  9. package/commands/delete-worktree.md +16 -0
  10. package/commands/done.md +14 -0
  11. package/commands/finish-workflow.md +18 -0
  12. package/commands/finish.md +16 -0
  13. package/commands/hygiene.md +16 -0
  14. package/commands/preserve.md +14 -0
  15. package/commands/recover.md +14 -0
  16. package/commands/report.md +14 -0
  17. package/commands/start.md +14 -0
  18. package/commands/status.md +14 -0
  19. package/commands/unblock-finish.md +16 -0
  20. package/docs/adr/0001-guardian-safety-policy.md +192 -0
  21. package/docs/publishing.md +55 -0
  22. package/docs/release-checklist.md +84 -0
  23. package/package.json +72 -0
  24. package/scripts/readiness.ts +75 -0
  25. package/scripts/with-safe-node-temp.mjs +78 -0
  26. package/skills/worktree-guardian/SKILL.md +73 -0
  27. package/src/config.ts +87 -0
  28. package/src/delete-paths-apply.ts +77 -0
  29. package/src/delete-paths-preflight.ts +146 -0
  30. package/src/delete-paths.ts +1 -0
  31. package/src/delete-worktree-preflight.ts +25 -0
  32. package/src/delete-worktree-report.ts +70 -0
  33. package/src/delete-worktree-targets.ts +152 -0
  34. package/src/delete-worktree.ts +222 -0
  35. package/src/delete.ts +1 -0
  36. package/src/deletion-fingerprint.ts +59 -0
  37. package/src/done-primary-publish.ts +129 -0
  38. package/src/done-primary-snapshot.ts +79 -0
  39. package/src/done-reattach.ts +32 -0
  40. package/src/done-shared.ts +28 -0
  41. package/src/done.ts +80 -0
  42. package/src/filesystem-boundaries.ts +49 -0
  43. package/src/finish-dirty-files.ts +56 -0
  44. package/src/finish-report.ts +80 -0
  45. package/src/finish.ts +212 -0
  46. package/src/git.ts +288 -0
  47. package/src/guards/allowlists.ts +83 -0
  48. package/src/guards/classifier.ts +39 -0
  49. package/src/guards/destructive-classifier.ts +189 -0
  50. package/src/guards/git-invocation.ts +145 -0
  51. package/src/guards/guard-types.ts +36 -0
  52. package/src/guards/options.ts +15 -0
  53. package/src/guards/path-policy.ts +31 -0
  54. package/src/guards/protected-branch-policy.ts +88 -0
  55. package/src/guards/shell-parser.ts +126 -0
  56. package/src/guards/shell-prefix.ts +100 -0
  57. package/src/guards.ts +3 -0
  58. package/src/hygiene-apply.ts +230 -0
  59. package/src/hygiene-scan.ts +200 -0
  60. package/src/hygiene.ts +10 -0
  61. package/src/index.ts +18 -0
  62. package/src/lifecycle.ts +31 -0
  63. package/src/plugin/direct-file-routing.ts +68 -0
  64. package/src/plugin/event-log.ts +60 -0
  65. package/src/plugin/guard-context.ts +40 -0
  66. package/src/plugin/hook-context.ts +35 -0
  67. package/src/plugin/invisible-policy.ts +28 -0
  68. package/src/plugin/native-tool.ts +82 -0
  69. package/src/plugin/plan-token-cache.ts +66 -0
  70. package/src/plugin/readable-output-cleanup.ts +141 -0
  71. package/src/plugin/readable-output-status.ts +86 -0
  72. package/src/plugin/readable-output-values.ts +21 -0
  73. package/src/plugin/readable-output-workflow.ts +70 -0
  74. package/src/plugin/readable-output.ts +16 -0
  75. package/src/plugin/server.ts +257 -0
  76. package/src/plugin/session-routing.ts +74 -0
  77. package/src/plugin/slash-commands.ts +20 -0
  78. package/src/preserve.ts +32 -0
  79. package/src/recover.ts +195 -0
  80. package/src/report.ts +168 -0
  81. package/src/session/context.ts +41 -0
  82. package/src/session/last-safe-state.ts +27 -0
  83. package/src/session/worktree-binding.ts +161 -0
  84. package/src/start.ts +157 -0
  85. package/src/state.ts +197 -0
  86. package/src/tool-registry.ts +35 -0
  87. package/src/tools.ts +8 -0
  88. package/src/tui.ts +113 -0
  89. package/src/types.ts +339 -0
  90. package/src/unblock-finish.ts +298 -0
  91. package/src/workflow-candidates.ts +111 -0
  92. package/src/workflow.ts +84 -0
package/src/state.ts ADDED
@@ -0,0 +1,197 @@
1
+ import fs from "node:fs/promises";
2
+ import type { FileHandle } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { getCommonGitDir } from "./git.ts";
5
+ import { clearTerminalLifecycleFields } from "./lifecycle.ts";
6
+ import type { GuardianConfig, GuardianPaths, GuardianSession, GuardianState, GuardianStateRecord, RecordLike } from "./types.ts";
7
+ import { errorCode, isRecordLike } from "./types.ts";
8
+
9
+ export const STATE_SCHEMA_VERSION = "1.0.0";
10
+
11
+ export type StateErrorKind = "invalid_shape" | "unsupported_schema" | "symlink" | "lock_timeout";
12
+ export type StateBoundaryError = Error & { readonly stateErrorKind: StateErrorKind; readonly guardianPath?: string };
13
+ type GuardianConfigInput = GuardianConfig | RecordLike;
14
+
15
+ function stateError(kind: StateErrorKind, message: string, guardianPath?: string): StateBoundaryError {
16
+ return Object.assign(new Error(message), {
17
+ stateErrorKind: kind,
18
+ ...(guardianPath === undefined ? {} : { guardianPath }),
19
+ });
20
+ }
21
+
22
+ export async function getGuardianPaths(repoRoot: string): Promise<GuardianPaths> {
23
+ const gitDir = await getCommonGitDir(repoRoot);
24
+ const dir = path.join(gitDir, "opencode-guardian");
25
+ return {
26
+ dir,
27
+ statePath: path.join(dir, "state.json"),
28
+ eventsPath: path.join(dir, "events.jsonl"),
29
+ reportPath: path.join(dir, "report.html"),
30
+ lockPath: path.join(dir, "state.lock"),
31
+ };
32
+ }
33
+
34
+ export function createEmptyState({ repoRoot, config }: { readonly repoRoot: string; readonly config: GuardianConfigInput }): GuardianState {
35
+ return {
36
+ schema_version: STATE_SCHEMA_VERSION,
37
+ state_version: 0,
38
+ repo_root: repoRoot,
39
+ base_branch: typeof config.baseBranch === "string" ? config.baseBranch : "",
40
+ remote: typeof config.remote === "string" ? config.remote : "",
41
+ finish_mode: typeof config.finishMode === "string" ? config.finishMode : "",
42
+ worktree_root: typeof config.worktreeRoot === "string" ? config.worktreeRoot : "",
43
+ sessions: {},
44
+ created_at: new Date().toISOString(),
45
+ updated_at: new Date().toISOString(),
46
+ };
47
+ }
48
+
49
+ async function assertNotSymlink(filePath: string, label: string) {
50
+ try {
51
+ const stat = await fs.lstat(filePath);
52
+ if (stat.isSymbolicLink()) throw stateError("symlink", `Refusing guardian ${label} symlink: ${filePath}`, filePath);
53
+ } catch (error) {
54
+ if (errorCode(error) !== "ENOENT") throw error;
55
+ }
56
+ }
57
+
58
+ function validateStateShape(state: unknown): GuardianStateRecord {
59
+ if (!state || typeof state !== "object" || Array.isArray(state)) throw stateError("invalid_shape", "Invalid guardian state: expected object");
60
+ if (!isRecordLike(state)) throw stateError("invalid_shape", "Invalid guardian state: expected object");
61
+ if (state.schema_version !== STATE_SCHEMA_VERSION) {
62
+ throw stateError("unsupported_schema", `Unsupported guardian state schema_version: ${String(state.schema_version)}`);
63
+ }
64
+ if (typeof state.state_version !== "number") throw stateError("invalid_shape", "Invalid guardian state: state_version must be a number");
65
+ if (!state.sessions || typeof state.sessions !== "object" || Array.isArray(state.sessions)) {
66
+ throw stateError("invalid_shape", "Invalid guardian state: sessions must be an object");
67
+ }
68
+ const sessions: Record<string, GuardianSession> = {};
69
+ for (const [sessionId, session] of Object.entries(state.sessions)) {
70
+ if (isRecordLike(session)) sessions[sessionId] = session;
71
+ }
72
+ return {
73
+ ...state,
74
+ sessions,
75
+ };
76
+ }
77
+
78
+ export async function readState(paths: GuardianPaths, context: { readonly repoRoot: string; readonly config: GuardianConfigInput }): Promise<GuardianStateRecord> {
79
+ try {
80
+ await assertNotSymlink(paths.statePath, "state");
81
+ const raw = await fs.readFile(paths.statePath, "utf8");
82
+ return validateStateShape(JSON.parse(raw));
83
+ } catch (error) {
84
+ if (errorCode(error) !== "ENOENT") throw error;
85
+ return createEmptyState(context);
86
+ }
87
+ }
88
+
89
+ async function sleep(ms: number) {
90
+ await new Promise((resolve) => setTimeout(resolve, ms));
91
+ }
92
+
93
+ export async function acquireStateLock(paths: GuardianPaths, options: { readonly timeoutMs?: number } = {}) {
94
+ const timeoutMs = options.timeoutMs ?? 5_000;
95
+ const started = Date.now();
96
+ await fs.mkdir(paths.dir, { recursive: true });
97
+
98
+ while (true) {
99
+ let handle: FileHandle | undefined;
100
+ try {
101
+ await assertNotSymlink(paths.lockPath, "lock");
102
+ handle = await fs.open(paths.lockPath, "wx");
103
+ await handle.writeFile(JSON.stringify({ pid: process.pid, acquired_at: new Date().toISOString() }));
104
+ const acquiredHandle = handle;
105
+ return async () => {
106
+ await acquiredHandle.close();
107
+ await fs.unlink(paths.lockPath).catch((error) => {
108
+ if (errorCode(error) !== "ENOENT") throw error;
109
+ });
110
+ };
111
+ } catch (error) {
112
+ if (handle) await handle.close().catch(() => {});
113
+ if (errorCode(error) !== "EEXIST") throw error;
114
+ if (Date.now() - started >= timeoutMs) {
115
+ throw stateError("lock_timeout", `Timed out acquiring guardian state lock at ${paths.lockPath}`, paths.lockPath);
116
+ }
117
+ await sleep(25);
118
+ }
119
+ }
120
+ }
121
+
122
+ export async function writeStateAtomic(paths: GuardianPaths, state: GuardianState | GuardianStateRecord) {
123
+ await fs.mkdir(paths.dir, { recursive: true });
124
+ await assertNotSymlink(paths.statePath, "state");
125
+ const tmpPath = `${paths.statePath}.${process.pid}.${Date.now()}.tmp`;
126
+ await fs.writeFile(tmpPath, `${JSON.stringify(state, null, 2)}\n`);
127
+ await fs.rename(tmpPath, paths.statePath);
128
+ }
129
+
130
+ export async function writeReportAtomic(paths: GuardianPaths, html: string) {
131
+ await fs.mkdir(paths.dir, { recursive: true });
132
+ await assertNotSymlink(paths.reportPath, "report");
133
+ const tmpPath = `${paths.reportPath}.${process.pid}.${Date.now()}.tmp`;
134
+ await fs.writeFile(tmpPath, html);
135
+ await fs.rename(tmpPath, paths.reportPath);
136
+ }
137
+
138
+ export async function appendEvent(paths: GuardianPaths, event: RecordLike) {
139
+ await fs.mkdir(paths.dir, { recursive: true });
140
+ await assertNotSymlink(paths.eventsPath, "events");
141
+ await fs.appendFile(paths.eventsPath, `${JSON.stringify({ ...event, at: event.at ?? new Date().toISOString() })}\n`);
142
+ }
143
+
144
+ export async function updateState(repoRoot: string, config: GuardianConfigInput, updater: (state: GuardianStateRecord) => GuardianStateRecord | Promise<GuardianStateRecord>, options: { readonly paths?: GuardianPaths; readonly event?: RecordLike } = {}) {
145
+ const paths = options.paths ?? await getGuardianPaths(repoRoot);
146
+ const release = await acquireStateLock(paths, { timeoutMs: typeof config.lockTimeoutMs === "number" ? config.lockTimeoutMs : 5_000 });
147
+ try {
148
+ const previous = await readState(paths, { repoRoot, config });
149
+ const next = await updater(structuredClone(previous));
150
+ next.state_version = (previous.state_version ?? 0) + 1;
151
+ next.updated_at = new Date().toISOString();
152
+ const event = options.event ? { ...options.event, state_version: next.state_version } : null;
153
+ if (event) await assertNotSymlink(paths.eventsPath, "events");
154
+ await writeStateAtomic(paths, next);
155
+ try {
156
+ if (event) await appendEvent(paths, event);
157
+ } catch (error) {
158
+ await writeStateAtomic(paths, previous);
159
+ throw error;
160
+ }
161
+ return next;
162
+ } finally {
163
+ await release();
164
+ }
165
+ }
166
+
167
+ export async function recordSession(repoRoot: string, config: GuardianConfigInput, session: GuardianSession, options: { readonly paths?: GuardianPaths; readonly event?: RecordLike } = {}) {
168
+ return updateState(repoRoot, config, (state) => {
169
+ if (!state.sessions) state.sessions = {};
170
+ const sessionId = session.session_id;
171
+ if (!sessionId) throw new Error("session.session_id is required");
172
+ const previous = isRecordLike(state.sessions[sessionId]) ? state.sessions[sessionId] : undefined;
173
+ const now = new Date().toISOString();
174
+ if (session.status === "active" && typeof session.worktree_path === "string") {
175
+ for (const [candidateSessionId, candidate] of Object.entries(state.sessions)) {
176
+ if (candidateSessionId !== sessionId && isRecordLike(candidate) && candidate.status === "active" && typeof candidate.worktree_path === "string" && path.resolve(candidate.worktree_path) === path.resolve(session.worktree_path)) {
177
+ state.sessions[candidateSessionId] = {
178
+ ...candidate,
179
+ status: "superseded",
180
+ superseded_by: sessionId,
181
+ superseded_at: now,
182
+ updated_at: now,
183
+ };
184
+ }
185
+ }
186
+ }
187
+ state.sessions[sessionId] = clearTerminalLifecycleFields({
188
+ ...previous,
189
+ ...session,
190
+ state_version: (typeof previous?.state_version === "number" ? previous.state_version : 0) + 1,
191
+ safety_refs: session.safety_refs ?? previous?.safety_refs ?? [],
192
+ created_at: previous?.created_at ?? now,
193
+ updated_at: now,
194
+ });
195
+ return state;
196
+ }, { ...options, event: options.event ?? { type: "session_recorded", session_id: session.session_id } });
197
+ }
@@ -0,0 +1,35 @@
1
+ import { guardianDeletePaths } from "./delete-paths.ts";
2
+ import { guardianDeleteWorktree } from "./delete.ts";
3
+ import { guardianDone } from "./done.ts";
4
+ import { guardianFinish } from "./finish.ts";
5
+ import { guardianHygiene } from "./hygiene.ts";
6
+ import { guardianPreserve } from "./preserve.ts";
7
+ import { guardianRecover, guardianStatus } from "./recover.ts";
8
+ import { guardianReportHtml } from "./report.ts";
9
+ import { guardianStart } from "./start.ts";
10
+ import { guardianUnblockFinish } from "./unblock-finish.ts";
11
+ import { guardianFinishWorkflow } from "./workflow.ts";
12
+ import type { GuardianToolInput, GuardianToolName, GuardianToolResult } from "./types.ts";
13
+ import { isGuardianToolName } from "./types.ts";
14
+
15
+ type GuardianToolRunner = (input: GuardianToolInput) => Promise<GuardianToolResult>;
16
+
17
+ export const GUARDIAN_TOOL_RUNNERS = {
18
+ guardian_delete_paths: guardianDeletePaths,
19
+ guardian_delete_worktree: guardianDeleteWorktree,
20
+ guardian_done: guardianDone,
21
+ guardian_finish: guardianFinish,
22
+ guardian_finish_workflow: guardianFinishWorkflow,
23
+ guardian_hygiene: guardianHygiene,
24
+ guardian_preserve: guardianPreserve,
25
+ guardian_recover: guardianRecover,
26
+ guardian_report_html: guardianReportHtml,
27
+ guardian_start: guardianStart,
28
+ guardian_status: guardianStatus,
29
+ guardian_unblock_finish: guardianUnblockFinish,
30
+ } satisfies Record<GuardianToolName, GuardianToolRunner>;
31
+
32
+ export async function runGuardianTool(name: GuardianToolName | string, input: GuardianToolInput = {}): Promise<GuardianToolResult> {
33
+ if (isGuardianToolName(name)) return GUARDIAN_TOOL_RUNNERS[name](input);
34
+ throw new Error(`Unknown guardian tool: ${name}`);
35
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { buildInvisiblePolicy, injectInvisiblePolicy } from "./plugin/invisible-policy.ts";
2
+ export { rewriteGuardianCommand } from "./plugin/slash-commands.ts";
3
+ export { guardianPreserve } from "./preserve.ts";
4
+ export { guardianStart } from "./start.ts";
5
+ export type { GuardianStartResult } from "./start.ts";
6
+ export { recordLastSafeState } from "./session/last-safe-state.ts";
7
+ export { collectKnownWorktreePaths, resolveSessionWorktree } from "./session/worktree-binding.ts";
8
+ export { runGuardianTool } from "./tool-registry.ts";
package/src/tui.ts ADDED
@@ -0,0 +1,113 @@
1
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
2
+
3
+ const COMMANDS = [
4
+ {
5
+ name: "guardian-done",
6
+ title: "Guardian: Done",
7
+ description: "Plan or apply the safest implementation-done path for the current repository state.",
8
+ prompt: "Use the guardian_done native tool. Run mode=plan first. If it selects dirty primary-main publishing, require an explicit commitMessage and explicit user confirmation, then apply with confirm=true so the plugin can reuse the matching internal plan token. After publish, inspect the returned cleanupPlan and do not silently apply cleanup. Never force-push, mutate stashes, delete remote branches, or run raw cleanup commands.",
9
+ },
10
+ {
11
+ name: "guardian-status",
12
+ title: "Guardian: Status",
13
+ description: "Show Guardian session, worktree, branch, stash, and recovery inventory.",
14
+ prompt: "Use the guardian_status native tool to inspect the current repository. Treat the result as read-only evidence.",
15
+ },
16
+ {
17
+ name: "guardian-start",
18
+ title: "Guardian: Start",
19
+ description: "Create or attach this session to a Guardian-owned worktree.",
20
+ prompt: "Use the guardian_start native tool to create or attach this session to a Guardian-owned worktree. Do not use raw git worktree add.",
21
+ },
22
+ {
23
+ name: "guardian-finish",
24
+ title: "Guardian: Finish",
25
+ description: "Finish Guardian-owned work through the configured gated finish mode.",
26
+ prompt: "Use the guardian_finish native tool for gated completion. Do not manually push, merge, clean, or remove worktrees. If dirty files block finish, distinguish allowedDirtyFiles from blockingDirtyFiles; narrow file-specific runtime paths can be allowed through repo config allowDirtyPaths, and allowed runtime dirt is left untouched.",
27
+ },
28
+ {
29
+ name: "guardian-finish-workflow",
30
+ title: "Guardian: Finish Workflow",
31
+ description: "Plan or apply implementation-done cleanup for redundant merged worktrees and branches.",
32
+ prompt: "Use the guardian_finish_workflow native tool. Run mode=plan first, inspect clean/synced preflight facts, cleanup candidates, blockers, and confirmToken, then apply only after explicit user confirmation with the fresh token. This workflow may remove only redundant merged Guardian worktrees and merged local branches through Guardian gates; it must not invent commits, merge protected branches, mutate stashes, run raw cleanup commands, or bypass guardian_finish/guardian_delete_worktree safety checks.",
33
+ },
34
+ {
35
+ name: "guardian-preserve",
36
+ title: "Guardian: Preserve",
37
+ description: "Mark Guardian-owned work as terminal/preserved with a safety ref.",
38
+ prompt: "Use the guardian_preserve native tool to mark the current Guardian-owned session as terminal/preserved with a safety ref. Preserved worktrees are cleanup-eligible; do not treat preservation as a reason to retain disk state forever.",
39
+ },
40
+ {
41
+ name: "guardian-recover",
42
+ title: "Guardian: Recover",
43
+ description: "Inspect Guardian recovery refs, orphaned sessions, stashes, and evidence.",
44
+ prompt: "Use the guardian_recover native tool for read-only recovery evidence. Do not mutate stashes, refs, worktrees, or files.",
45
+ },
46
+ {
47
+ name: "guardian-report",
48
+ title: "Guardian: Report",
49
+ description: "Write a static offline Guardian HTML report.",
50
+ prompt: "Use the guardian_report_html native tool to write the offline report, then return the report path and summarize the main risks.",
51
+ },
52
+ {
53
+ name: "guardian-hygiene",
54
+ title: "Guardian: Hygiene",
55
+ description: "Scan, plan, or apply confirmed cleanup for workspace hygiene findings.",
56
+ prompt: "Use the guardian_hygiene native tool. With no mode it scans only. For cleanup, run mode=plan first, inspect exact approved targets and blockers, get explicit user confirmation, then apply with confirmDelete=true. Never run raw cleanup commands.",
57
+ },
58
+ {
59
+ name: "guardian-delete-worktree",
60
+ title: "Guardian: Delete Worktree",
61
+ description: "Plan or apply safe Guardian-mediated worktree, orphan branch, stale branch, or explicit unmerged abandon deletion.",
62
+ prompt: "Use the guardian_delete_worktree native tool. Run mode=plan first unless a fresh confirmToken for the exact target/options is provided. Stale local Guardian branch cleanup requires an exact branch or terminal sessionId plus deleteBranch=true and Guardian ownership proof from terminal state or safety refs. Intentional unmerged local abandonment requires deleteBranch=true plus abandonUnmerged=true in both plan and apply after inspecting unmerged commit evidence. Never run raw worktree removal, filesystem deletion, forced branch deletion, hard reset, forced clean, or stash mutation.",
63
+ },
64
+ {
65
+ name: "guardian-delete-paths",
66
+ title: "Guardian: Delete Paths",
67
+ description: "Plan or apply exact path deletion for approved files or directories.",
68
+ prompt: "Use the guardian_delete_paths native tool. Run mode=plan first with exact paths, inspect target status and blockers, get explicit user confirmation, then apply with confirmDelete=true. Tracked source deletion requires allowTracked=true. Directory deletion requires allowRecursive=true. Use guardian_delete_worktree for worktree removal.",
69
+ },
70
+ {
71
+ name: "guardian-unblock-finish",
72
+ title: "Guardian: Unblock Finish",
73
+ description: "Plan or apply safe Guardian finish blocker resolution.",
74
+ prompt: "Use the guardian_unblock_finish native tool. Run mode=plan first unless a fresh confirmToken for the exact action is provided. Do not delete files, stash, clean, or commit source changes.",
75
+ },
76
+ ] as const;
77
+
78
+ async function submitPrompt(api: TuiPluginApi, prompt: string) {
79
+ const route = api.route.current;
80
+ let sessionID: string | undefined;
81
+ if (route.name === "session" && route.params && typeof route.params.sessionID === "string") {
82
+ sessionID = route.params.sessionID;
83
+ }
84
+ if (!sessionID) {
85
+ api.ui.toast({ variant: "warning", title: "Guardian", message: "Open a session before running Guardian commands." });
86
+ return;
87
+ }
88
+
89
+ await api.client.session.promptAsync({
90
+ sessionID,
91
+ directory: api.state.path.directory,
92
+ parts: [{ type: "text", text: prompt }],
93
+ });
94
+ }
95
+
96
+ export async function tui(api: TuiPluginApi) {
97
+ api.keymap.registerLayer({
98
+ commands: COMMANDS.map((command) => ({
99
+ namespace: "palette",
100
+ name: command.name,
101
+ title: command.title,
102
+ desc: command.description,
103
+ category: "Guardian",
104
+ slashName: command.name,
105
+ run: () => submitPrompt(api, command.prompt),
106
+ })),
107
+ bindings: [],
108
+ });
109
+ }
110
+
111
+ export const id = "opencode-worktree-guardian";
112
+
113
+ export default { id, tui };
package/src/types.ts ADDED
@@ -0,0 +1,339 @@
1
+ export type RecordLike = Record<string, unknown>;
2
+
3
+ export type MutableRecord = {
4
+ [key: string]: unknown;
5
+ };
6
+
7
+ export type GuardianFinishMode = "preserve-only" | "push-branch" | "create-pr" | "merge-to-base";
8
+
9
+ export type GuardianConfig = {
10
+ readonly remote: string;
11
+ readonly baseBranch: string;
12
+ readonly worktreeRoot: string;
13
+ readonly branchPrefix: string;
14
+ readonly finishMode: GuardianFinishMode;
15
+ readonly autoStart: boolean;
16
+ readonly autoFinish: boolean;
17
+ readonly autoCleanup: boolean;
18
+ readonly safetyRefRetentionDays: number;
19
+ readonly allowStashIfUnrelated: boolean;
20
+ readonly allowDirtyPaths: readonly string[];
21
+ readonly protectedBranches: readonly string[];
22
+ readonly lockTimeoutMs: number;
23
+ readonly [key: string]: unknown;
24
+ };
25
+
26
+ export type ConfigFileSystem = {
27
+ readonly readFile: (path: string, encoding: "utf8") => Promise<string>;
28
+ };
29
+
30
+ export type LoadConfigOptions = {
31
+ readonly fs?: ConfigFileSystem;
32
+ readonly configPath?: string;
33
+ };
34
+
35
+ export type LoadedGuardianConfig = {
36
+ readonly config: GuardianConfig;
37
+ readonly path: string;
38
+ readonly loaded: boolean;
39
+ };
40
+
41
+ export type GuardianPaths = {
42
+ readonly dir: string;
43
+ readonly statePath: string;
44
+ readonly eventsPath: string;
45
+ readonly reportPath: string;
46
+ readonly lockPath: string;
47
+ };
48
+
49
+ export type GuardianSessionStatus =
50
+ | "active"
51
+ | "deleted"
52
+ | "abandoned"
53
+ | "finished"
54
+ | "preserved"
55
+ | "superseded"
56
+ | string;
57
+
58
+ export type GuardianSession = {
59
+ readonly session_id?: string;
60
+ readonly sessionId?: string;
61
+ readonly status?: GuardianSessionStatus;
62
+ readonly branch?: string;
63
+ readonly worktree_path?: string;
64
+ readonly worktreePath?: string;
65
+ readonly base_ref?: string;
66
+ readonly head_commit?: string;
67
+ readonly safety_refs?: readonly string[];
68
+ readonly deleted_worktree_path?: string;
69
+ readonly deleted_branch?: string | null;
70
+ readonly abandoned_branch?: string;
71
+ readonly branch_only_delete?: boolean;
72
+ readonly superseded_by?: string;
73
+ readonly superseded_at?: string;
74
+ readonly created_at?: string;
75
+ readonly updated_at?: string;
76
+ readonly state_version?: number;
77
+ readonly [key: string]: unknown;
78
+ };
79
+
80
+ export type GuardianState = {
81
+ schema_version: string;
82
+ state_version: number;
83
+ repo_root: string;
84
+ base_branch: string;
85
+ remote: string;
86
+ finish_mode: string;
87
+ worktree_root: string;
88
+ sessions: Record<string, GuardianSession>;
89
+ created_at: string;
90
+ updated_at: string;
91
+ [key: string]: unknown;
92
+ };
93
+
94
+ export type GuardianStateRecord = MutableRecord & {
95
+ schema_version?: unknown;
96
+ state_version?: number;
97
+ sessions: Record<string, GuardianSession>;
98
+ };
99
+
100
+ export type WorktreeEntry = {
101
+ readonly path: string;
102
+ readonly head?: string;
103
+ readonly branch?: string;
104
+ readonly detached?: boolean;
105
+ readonly bare?: boolean;
106
+ readonly [key: string]: unknown;
107
+ };
108
+
109
+ export type GuardianToolInput = MutableRecord;
110
+ export type GuardianToolResult = MutableRecord & {
111
+ ok?: boolean;
112
+ status?: string | RecordLike;
113
+ reason?: string;
114
+ session?: GuardianSession;
115
+ sessions?: readonly GuardianSession[];
116
+ activeSessions?: readonly GuardianSession[];
117
+ terminalSessions?: readonly GuardianSession[];
118
+ worktrees?: readonly WorktreeEntry[];
119
+ preflight?: MutableRecord;
120
+ previous?: MutableRecord & {
121
+ branch?: string;
122
+ worktree_path?: string;
123
+ reason?: string;
124
+ };
125
+ report?: MutableRecord;
126
+ summary?: MutableRecord;
127
+ confirmToken?: string;
128
+ repoRoot?: string;
129
+ safetyRefs?: readonly unknown[];
130
+ };
131
+
132
+ export type SessionWorktreeResult = {
133
+ readonly ok: boolean;
134
+ readonly sessionId?: string | null;
135
+ readonly expectedWorktree?: string | null;
136
+ readonly actualWorktree?: string | null;
137
+ readonly matches?: boolean;
138
+ readonly source?: string;
139
+ readonly terminal?: boolean;
140
+ readonly status?: string;
141
+ readonly reason?: string;
142
+ readonly [key: string]: unknown;
143
+ };
144
+
145
+ export type GuardOptions = {
146
+ readonly cwd?: string;
147
+ readonly knownWorktreePaths?: readonly string[];
148
+ readonly protectedBranches?: readonly string[];
149
+ readonly branchPrefix?: string | null;
150
+ readonly guardianBranches?: readonly string[];
151
+ readonly protectedBranchWorktreePaths?: readonly string[];
152
+ readonly currentBranch?: string | null;
153
+ readonly inheritedEnvAssignments?: readonly string[];
154
+ readonly [key: string]: unknown;
155
+ };
156
+
157
+ export type GuardCommandPayload = MutableRecord & {
158
+ args?: MutableRecord & {
159
+ command?: unknown;
160
+ cwd?: unknown;
161
+ workdir?: unknown;
162
+ };
163
+ parts?: readonly { readonly type: string; readonly text: string }[];
164
+ system?: unknown;
165
+ command?: unknown;
166
+ code?: unknown;
167
+ tool?: unknown;
168
+ callID?: unknown;
169
+ sessionID?: unknown;
170
+ sessionId?: unknown;
171
+ };
172
+
173
+ export type GuardDecision = {
174
+ readonly blocked: boolean;
175
+ readonly reason: string | null;
176
+ readonly command: string;
177
+ readonly tokens?: readonly string[];
178
+ readonly segment?: readonly string[];
179
+ };
180
+
181
+ export type AllowDecision = {
182
+ readonly allowed: boolean;
183
+ readonly reason: string | null;
184
+ };
185
+
186
+ export type HookContext = {
187
+ readonly directory?: string;
188
+ readonly worktree?: string;
189
+ readonly sessionID?: string;
190
+ readonly sessionId?: string;
191
+ readonly metadata?: (metadata: { readonly title?: string; readonly metadata?: RecordLike }) => void;
192
+ readonly [key: string]: unknown;
193
+ };
194
+
195
+ export type PluginClient = {
196
+ readonly app?: {
197
+ readonly log?: (event: { readonly body: RecordLike }) => Promise<void> | void;
198
+ };
199
+ };
200
+
201
+ export type PluginServerOptions = {
202
+ readonly client?: PluginClient;
203
+ readonly directory?: string;
204
+ readonly worktree?: string;
205
+ };
206
+
207
+ export type ToolExecutionPayload = MutableRecord & {
208
+ args?: MutableRecord;
209
+ parts?: readonly { readonly type: string; readonly text: string }[];
210
+ system?: unknown;
211
+ command?: unknown;
212
+ tool?: unknown;
213
+ callID?: unknown;
214
+ sessionID?: unknown;
215
+ sessionId?: unknown;
216
+ };
217
+
218
+ export const GUARDIAN_TOOL_NAMES = [
219
+ "guardian_delete_paths",
220
+ "guardian_delete_worktree",
221
+ "guardian_done",
222
+ "guardian_finish",
223
+ "guardian_finish_workflow",
224
+ "guardian_hygiene",
225
+ "guardian_preserve",
226
+ "guardian_recover",
227
+ "guardian_report_html",
228
+ "guardian_start",
229
+ "guardian_status",
230
+ "guardian_unblock_finish",
231
+ ] as const;
232
+
233
+ export type GuardianToolName = typeof GUARDIAN_TOOL_NAMES[number];
234
+
235
+ export type GuardianPluginMetadata = {
236
+ readonly id: string;
237
+ };
238
+
239
+ export type GuardianNativeToolReturn = {
240
+ readonly title: GuardianToolName;
241
+ readonly metadata: GuardianToolResult;
242
+ readonly output: string;
243
+ };
244
+
245
+ export type PlanTokenCache = Map<string, string>;
246
+
247
+ export type PlanCacheToolArgs = MutableRecord & {
248
+ mode?: unknown;
249
+ sessionId?: unknown;
250
+ repoRoot?: unknown;
251
+ cwd?: unknown;
252
+ paths?: unknown;
253
+ cleanupPaths?: unknown;
254
+ allowCategories?: unknown;
255
+ allowTracked?: unknown;
256
+ allowRecursive?: unknown;
257
+ allowDirtyNestedGit?: unknown;
258
+ commitMessage?: unknown;
259
+ finishMode?: unknown;
260
+ deleteBranch?: unknown;
261
+ abandonUnmerged?: unknown;
262
+ allowIgnoredFiles?: unknown;
263
+ action?: unknown;
264
+ confirm?: unknown;
265
+ confirmDelete?: unknown;
266
+ confirmToken?: unknown;
267
+ branch?: unknown;
268
+ targetPath?: unknown;
269
+ worktreePath?: unknown;
270
+ };
271
+
272
+ export function isRecordLike(value: unknown): value is RecordLike {
273
+ return value !== null && typeof value === "object" && !Array.isArray(value);
274
+ }
275
+
276
+ export function isMutableRecord(value: unknown): value is MutableRecord {
277
+ return isRecordLike(value);
278
+ }
279
+
280
+ export function stringArray(value: unknown): string[] {
281
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
282
+ }
283
+
284
+ export function isGuardianToolName(value: string): value is GuardianToolName {
285
+ return GUARDIAN_TOOL_NAMES.some((toolName) => toolName === value);
286
+ }
287
+
288
+ export function nonEmptyString(value: unknown): string | null {
289
+ return typeof value === "string" && value.length > 0 ? value : null;
290
+ }
291
+
292
+ export function errorCode(error: unknown): string | undefined {
293
+ return isRecordLike(error) && typeof error.code === "string" ? error.code : undefined;
294
+ }
295
+
296
+ export function errorMessage(error: unknown): string {
297
+ return error instanceof Error ? error.message : String(error);
298
+ }
299
+
300
+ export type GitCommandOutput = { readonly stdout: string; readonly stderr: string };
301
+
302
+ export type GitCommandMetadata = {
303
+ readonly gitArgs: readonly string[];
304
+ readonly gitStdout: string;
305
+ readonly gitStderr: string;
306
+ readonly gitExitCode?: number;
307
+ readonly gitSignal?: string;
308
+ };
309
+
310
+ export type GitCommandFailure = Error & GitCommandMetadata;
311
+
312
+ function textFromOutput(value: unknown): string {
313
+ if (typeof value === "string") return value.trim();
314
+ if (Buffer.isBuffer(value)) return value.toString("utf8").trim();
315
+ return "";
316
+ }
317
+
318
+ function toError(error: unknown): Error {
319
+ return error instanceof Error ? error : new Error(String(error));
320
+ }
321
+
322
+ export function gitMetadataFromError(args: readonly string[], error: unknown, fallback: GitCommandOutput = { stdout: "", stderr: "" }): GitCommandMetadata {
323
+ if (!isRecordLike(error)) {
324
+ return { gitArgs: [...args], gitStdout: fallback.stdout, gitStderr: fallback.stderr };
325
+ }
326
+ const code = typeof error.code === "number" ? error.code : undefined;
327
+ const signal = typeof error.signal === "string" ? error.signal : undefined;
328
+ return {
329
+ gitArgs: [...args],
330
+ gitStdout: textFromOutput(error.stdout) || fallback.stdout,
331
+ gitStderr: textFromOutput(error.stderr) || fallback.stderr,
332
+ ...(code === undefined ? {} : { gitExitCode: code }),
333
+ ...(signal === undefined ? {} : { gitSignal: signal }),
334
+ };
335
+ }
336
+
337
+ export function withGitMetadata(error: unknown, metadata: GitCommandMetadata): GitCommandFailure {
338
+ return Object.assign(toError(error), metadata);
339
+ }