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/config.ts ADDED
@@ -0,0 +1,87 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { GuardianConfig, GuardianFinishMode, LoadedGuardianConfig, LoadConfigOptions, RecordLike } from "./types.ts";
4
+ import { errorCode, isRecordLike } from "./types.ts";
5
+
6
+ export const CONFIG_PATH = path.join(".opencode", "worktree-guardian.json");
7
+
8
+ export const FINISH_MODES = new Set(["preserve-only", "push-branch", "create-pr", "merge-to-base"]);
9
+
10
+ export const DEFAULT_CONFIG: GuardianConfig = Object.freeze({
11
+ remote: "origin",
12
+ baseBranch: "main",
13
+ worktreeRoot: ".worktrees/$REPO",
14
+ branchPrefix: "guardian/",
15
+ finishMode: "create-pr",
16
+ autoStart: true,
17
+ autoFinish: false,
18
+ autoCleanup: false,
19
+ safetyRefRetentionDays: 30,
20
+ allowStashIfUnrelated: false,
21
+ allowDirtyPaths: [],
22
+ protectedBranches: ["main", "master", "develop", "production"],
23
+ lockTimeoutMs: 5_000,
24
+ });
25
+
26
+ export type ConfigErrorKind = "unsupported_finish_mode";
27
+ export type ConfigBoundaryError = Error & { readonly configErrorKind: ConfigErrorKind };
28
+
29
+ function configError(kind: ConfigErrorKind, message: string): ConfigBoundaryError {
30
+ return Object.assign(new Error(message), { configErrorKind: kind });
31
+ }
32
+
33
+ function uniqueStrings(values: readonly unknown[]): string[] {
34
+ return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0))];
35
+ }
36
+
37
+ function isGuardianFinishMode(value: unknown): value is GuardianFinishMode {
38
+ return typeof value === "string" && FINISH_MODES.has(value);
39
+ }
40
+
41
+ export function normalizeConfig(input: RecordLike = {}): GuardianConfig {
42
+ const config = { ...DEFAULT_CONFIG, ...input };
43
+ if (!isGuardianFinishMode(config.finishMode)) {
44
+ throw configError("unsupported_finish_mode", `Unsupported worktree guardian finishMode: ${String(config.finishMode)}`);
45
+ }
46
+
47
+ const protectedBranches = uniqueStrings([
48
+ ...DEFAULT_CONFIG.protectedBranches,
49
+ ...(Array.isArray(input.protectedBranches) ? input.protectedBranches : []),
50
+ ]);
51
+
52
+ return {
53
+ ...config,
54
+ autoStart: config.autoStart !== false,
55
+ autoFinish: config.autoFinish === true,
56
+ autoCleanup: config.autoCleanup === true,
57
+ allowStashIfUnrelated: config.allowStashIfUnrelated === true,
58
+ allowDirtyPaths: uniqueStrings(Array.isArray(input.allowDirtyPaths) ? input.allowDirtyPaths : []),
59
+ protectedBranches,
60
+ lockTimeoutMs: typeof config.lockTimeoutMs === "number" && Number.isFinite(config.lockTimeoutMs) ? config.lockTimeoutMs : DEFAULT_CONFIG.lockTimeoutMs,
61
+ };
62
+ }
63
+
64
+ export async function loadConfig(repoRoot: string, options: LoadConfigOptions = {}): Promise<LoadedGuardianConfig> {
65
+ const fileSystem = options.fs ?? fs;
66
+ const configPath = options.configPath ?? path.join(repoRoot, CONFIG_PATH);
67
+ let parsed: RecordLike = {};
68
+
69
+ try {
70
+ const raw = await fileSystem.readFile(configPath, "utf8");
71
+ const value: unknown = JSON.parse(raw);
72
+ parsed = isRecordLike(value) ? value : {};
73
+ } catch (error) {
74
+ if (errorCode(error) !== "ENOENT") throw error;
75
+ }
76
+
77
+ return {
78
+ config: normalizeConfig(parsed),
79
+ path: configPath,
80
+ loaded: Object.keys(parsed).length > 0,
81
+ };
82
+ }
83
+
84
+ export function expandWorktreeRoot(template: string, repoRoot: string) {
85
+ const repoName = path.basename(repoRoot);
86
+ return template.replaceAll("$REPO", repoName);
87
+ }
@@ -0,0 +1,77 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import { buildDeletePathsPreflight, deleteSummary } from "./delete-paths-preflight.ts";
4
+ import type { DeletePathBlocker, DeletePathTarget } from "./delete-paths-preflight.ts";
5
+
6
+ function createDeleteConfirmToken(preflight: Record<string, unknown>) {
7
+ const material = {
8
+ tool: "guardian_delete_paths",
9
+ repoRoot: preflight.repoRoot,
10
+ paths: preflight.paths,
11
+ allowTracked: preflight.allowTracked === true,
12
+ allowRecursive: preflight.allowRecursive === true,
13
+ targets: (preflight.targets as DeletePathTarget[] | undefined ?? []).map((target) => ({
14
+ path: target.path,
15
+ kind: target.kind,
16
+ status: target.status,
17
+ trackedContents: target.trackedContents,
18
+ fingerprint: target.fingerprint,
19
+ })),
20
+ };
21
+ return crypto.createHash("sha256").update(JSON.stringify(material)).digest("hex");
22
+ }
23
+
24
+ function deleteReport(result: Record<string, unknown>, preflight: Record<string, unknown>, removedTargets: DeletePathTarget[] = []) {
25
+ return {
26
+ ...result,
27
+ preflight,
28
+ report: {
29
+ action: result.status,
30
+ mode: preflight.mode,
31
+ repoRoot: preflight.repoRoot,
32
+ paths: preflight.paths,
33
+ approvedTargets: (preflight.targets as DeletePathTarget[] | undefined ?? []).map((target) => target.path),
34
+ removedTargets: removedTargets.map((target) => target.path),
35
+ blockers: preflight.blockers,
36
+ summary: result.summary,
37
+ },
38
+ };
39
+ }
40
+
41
+ async function removeDeleteTarget(target: DeletePathTarget) {
42
+ await fs.rm(target.absolutePath, { recursive: target.kind === "directory", force: false });
43
+ }
44
+
45
+ export async function guardianDeletePaths(input: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
46
+ const mode = input.mode;
47
+ const preflight = await buildDeletePathsPreflight(input);
48
+ if (mode !== "plan" && mode !== "apply") {
49
+ const blocker = { reason: "mode must be plan or apply", fatal: true };
50
+ const blockers = [blocker];
51
+ const summary = deleteSummary([], blockers);
52
+ return deleteReport({ ok: false, status: "blocked", reason: blocker.reason, summary, targets: [], blockers }, { ...preflight, blockers, summary });
53
+ }
54
+ const targets = preflight.targets as DeletePathTarget[];
55
+ const blockers = preflight.blockers as DeletePathBlocker[];
56
+ const summary = preflight.summary as Record<string, unknown>;
57
+ const fatalBlockers = blockers.filter((blocker) => blocker.fatal);
58
+ if (fatalBlockers.length > 0 || targets.length === 0) {
59
+ const reason = fatalBlockers.length > 0 ? "delete paths preflight has fatal blockers" : "no approved delete targets";
60
+ return deleteReport({ ok: false, status: "blocked", reason, summary, targets, blockers }, preflight);
61
+ }
62
+ const confirmToken = createDeleteConfirmToken(preflight);
63
+ if (mode === "plan") return deleteReport({ ok: true, status: "planned", confirmToken, summary, targets, blockers, suggestedCommands: ["guardian_delete_paths"] }, preflight);
64
+ if (input.confirmDelete !== true) {
65
+ return deleteReport({ ok: false, status: "blocked", reason: "confirmDelete=true is required for guardian_delete_paths apply", tokenMatched: false, summary, targets, blockers }, preflight);
66
+ }
67
+ if (input.confirmToken !== confirmToken) {
68
+ return deleteReport({ ok: false, status: "blocked", reason: "confirm token mismatch; re-run mode=plan and apply with confirmDelete=true", tokenMatched: false, summary, targets, blockers }, preflight);
69
+ }
70
+ const removedTargets: DeletePathTarget[] = [];
71
+ for (const target of targets) {
72
+ await removeDeleteTarget(target);
73
+ removedTargets.push(target);
74
+ }
75
+ const finalSummary = deleteSummary(targets, blockers, removedTargets);
76
+ return deleteReport({ ok: true, status: "deleted", summary: finalSummary, targets, removedTargets, blockers, suggestedCommands: ["guardian_status"] }, { ...preflight, summary: finalSummary }, removedTargets);
77
+ }
@@ -0,0 +1,146 @@
1
+ import path from "node:path";
2
+ import { expandWorktreeRoot, loadConfig } from "./config.ts";
3
+ import { collectDeleteFingerprint } from "./deletion-fingerprint.ts";
4
+ import { getRepoRoot, listWorktrees, runGit, tryGit } from "./git.ts";
5
+ import { isEnoent, isSameOrInside, lstatOrMissing, normalizeRelativePath, parseNullSeparated, recordValue, relativePath, stringArray, uniqueSorted } from "./filesystem-boundaries.ts";
6
+ import { getGuardianPaths, readState } from "./state.ts";
7
+
8
+ export type DeletePathKind = "directory" | "file" | "symlink" | "other" | "missing";
9
+ export type DeletePathStatus = "tracked" | "ignored" | "untracked" | "missing";
10
+ export type DeletePathBlocker = { path?: string; reason: string; fatal: boolean };
11
+ export type DeletePathTarget = {
12
+ path: string;
13
+ absolutePath: string;
14
+ kind: DeletePathKind;
15
+ status: DeletePathStatus;
16
+ trackedContents: string[];
17
+ ignored: boolean;
18
+ fingerprint: Array<Record<string, string | number>>;
19
+ };
20
+
21
+ const PROTECTED_PATH_ROOTS = new Set([".opencode", "node_modules", "vendor", ".pnpm-store"]);
22
+
23
+ function pathKind(stat: Awaited<ReturnType<typeof lstatOrMissing>>): DeletePathKind {
24
+ if (!stat) return "missing";
25
+ if (stat.isSymbolicLink()) return "symlink";
26
+ if (stat.isDirectory()) return "directory";
27
+ if (stat.isFile()) return "file";
28
+ return "other";
29
+ }
30
+
31
+ function resolveDeletePath(repoRoot: string, deletePath: string) {
32
+ const absolutePath = path.isAbsolute(deletePath) ? path.resolve(deletePath) : path.resolve(repoRoot, deletePath);
33
+ const relative = relativePath(repoRoot, absolutePath);
34
+ return { absolutePath, relative };
35
+ }
36
+
37
+ async function listTrackedContents(repoRoot: string, relative: string) {
38
+ const result = await runGit(repoRoot, ["ls-files", "-z", "--", relative]);
39
+ return parseNullSeparated(result.stdout).map((entry) => normalizeRelativePath(entry)).sort((left, right) => left.localeCompare(right));
40
+ }
41
+
42
+ async function isIgnoredPath(repoRoot: string, relative: string) {
43
+ const result = await tryGit(repoRoot, ["check-ignore", "--quiet", "--", relative]);
44
+ return result.ok;
45
+ }
46
+
47
+ function protectedPathReason(relative: string) {
48
+ if (relative === ".git" || relative.startsWith(".git/")) return "git metadata";
49
+ const firstPart = relative.split("/").filter(Boolean)[0] ?? "";
50
+ return PROTECTED_PATH_ROOTS.has(firstPart) ? `protected ${firstPart} path` : null;
51
+ }
52
+
53
+ async function collectDeleteProtectedRoots(repoRoot: string, cwd: string, config: Record<string, unknown>) {
54
+ const roots = new Map<string, { reason: string; blockInside: boolean }>();
55
+ const configuredWorktreeRoot = path.resolve(repoRoot, expandWorktreeRoot(String(config.worktreeRoot), repoRoot));
56
+ roots.set(configuredWorktreeRoot, { reason: "configured Guardian worktree root", blockInside: true });
57
+ try {
58
+ const currentRoot = await getRepoRoot(cwd);
59
+ roots.set(path.resolve(currentRoot), { reason: "current worktree root", blockInside: false });
60
+ } catch {}
61
+ for (const entry of await listWorktrees(repoRoot)) {
62
+ const worktreePath = path.resolve(String(entry.path));
63
+ if (worktreePath !== path.resolve(repoRoot)) roots.set(worktreePath, { reason: "registered Git worktree path", blockInside: true });
64
+ }
65
+ try {
66
+ const state = await readState(await getGuardianPaths(repoRoot), { repoRoot, config });
67
+ for (const session of Object.values(recordValue(state.sessions))) {
68
+ const sessionRecord = recordValue(session);
69
+ if (typeof sessionRecord.worktree_path === "string" && path.resolve(sessionRecord.worktree_path) !== path.resolve(repoRoot)) {
70
+ roots.set(path.resolve(sessionRecord.worktree_path), { reason: "registered Guardian session worktree path", blockInside: true });
71
+ }
72
+ }
73
+ } catch {}
74
+ return [...roots.entries()].map(([root, metadata]) => ({ root, ...metadata })).sort((left, right) => left.root.localeCompare(right.root));
75
+ }
76
+
77
+ function protectedRootBlocker(absolutePath: string, protectedRoots: Array<{ root: string; reason: string; blockInside: boolean }>) {
78
+ return protectedRoots.find((entry) => {
79
+ const resolved = path.resolve(absolutePath);
80
+ return resolved === entry.root || isSameOrInside(entry.root, resolved) || entry.blockInside && isSameOrInside(resolved, entry.root);
81
+ });
82
+ }
83
+
84
+ export function deleteSummary(targets: DeletePathTarget[], blockers: DeletePathBlocker[], removedTargets: DeletePathTarget[] = []) {
85
+ return {
86
+ approvedTargetCount: targets.length,
87
+ blockedTargetCount: blockers.length,
88
+ fatalBlockerCount: blockers.filter((blocker) => blocker.fatal).length,
89
+ removedTargetCount: removedTargets.length,
90
+ trackedTargetCount: targets.filter((target) => target.trackedContents.length > 0).length,
91
+ directoryTargetCount: targets.filter((target) => target.kind === "directory").length,
92
+ };
93
+ }
94
+
95
+ export async function buildDeletePathsPreflight(input: Record<string, unknown>) {
96
+ const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
97
+ const repoRoot = path.resolve(typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd));
98
+ const loadedConfig = input.config && typeof input.config === "object" ? { config: input.config as Record<string, unknown> } : await loadConfig(repoRoot);
99
+ const config = loadedConfig.config;
100
+ const allowTracked = input.allowTracked === true;
101
+ const allowRecursive = input.allowRecursive === true;
102
+ const paths = uniqueSorted(stringArray(input.paths));
103
+ const blockers: DeletePathBlocker[] = [];
104
+ if (paths.length === 0) blockers.push({ reason: "paths must include at least one path", fatal: true });
105
+ const protectedRoots = await collectDeleteProtectedRoots(repoRoot, cwd, config);
106
+ const targets: DeletePathTarget[] = [];
107
+ const seenTargets = new Set<string>();
108
+ for (const requestedPath of paths) {
109
+ const { absolutePath, relative } = resolveDeletePath(repoRoot, requestedPath);
110
+ const pathBlockers: DeletePathBlocker[] = [];
111
+ if (!isSameOrInside(absolutePath, repoRoot)) pathBlockers.push({ path: requestedPath, reason: "delete path is outside the repository root", fatal: true });
112
+ if (relative === ".") pathBlockers.push({ path: relative, reason: "repository root cannot be deleted by guardian_delete_paths", fatal: true });
113
+ const protectedReason = protectedPathReason(relative);
114
+ if (protectedReason) pathBlockers.push({ path: relative, reason: protectedReason, fatal: true });
115
+ const protectedRoot = protectedRootBlocker(absolutePath, protectedRoots);
116
+ if (protectedRoot) pathBlockers.push({ path: relative, reason: protectedRoot.reason, fatal: true });
117
+ const stat = pathBlockers.length > 0 ? null : await lstatOrMissing(absolutePath);
118
+ const kind = pathKind(stat);
119
+ if (pathBlockers.length === 0 && stat == null) pathBlockers.push({ path: relative, reason: "delete path is missing", fatal: true });
120
+ if (kind === "symlink") pathBlockers.push({ path: relative, reason: "symlink delete roots are not allowed", fatal: true });
121
+ if (kind === "directory" && !allowRecursive) pathBlockers.push({ path: relative, reason: "directory deletion requires allowRecursive=true", fatal: true });
122
+ const trackedContents = pathBlockers.some((blocker) => blocker.fatal) ? [] : await listTrackedContents(repoRoot, relative);
123
+ const ignored = pathBlockers.some((blocker) => blocker.fatal) ? false : await isIgnoredPath(repoRoot, relative);
124
+ const status: DeletePathStatus = stat == null ? "missing" : trackedContents.length > 0 ? "tracked" : ignored ? "ignored" : "untracked";
125
+ if (trackedContents.length > 0 && !allowTracked) pathBlockers.push({ path: relative, reason: "tracked source deletion requires allowTracked=true", fatal: true });
126
+ if (pathBlockers.length > 0) {
127
+ blockers.push(...pathBlockers);
128
+ continue;
129
+ }
130
+ if (!stat || seenTargets.has(relative)) continue;
131
+ try {
132
+ targets.push({ path: relative, absolutePath, kind, status, trackedContents, ignored, fingerprint: await collectDeleteFingerprint(repoRoot, absolutePath) });
133
+ seenTargets.add(relative);
134
+ } catch (error) {
135
+ if (!isEnoent(error)) throw error;
136
+ blockers.push({ path: relative, reason: "delete path disappeared during preflight", fatal: true });
137
+ }
138
+ }
139
+ for (const target of targets) {
140
+ const overlap = targets.find((candidate) => candidate.path !== target.path && isSameOrInside(path.resolve(repoRoot, candidate.path), path.resolve(repoRoot, target.path)));
141
+ if (overlap) blockers.push({ path: target.path, reason: `delete paths overlap with ${overlap.path}`, fatal: true });
142
+ }
143
+ const preflight: Record<string, unknown> = { repoRoot, mode: input.mode, paths, allowTracked, allowRecursive, targets, blockers };
144
+ preflight.summary = deleteSummary(targets, blockers);
145
+ return preflight;
146
+ }
@@ -0,0 +1 @@
1
+ export { guardianDeletePaths } from "./delete-paths-apply.ts";
@@ -0,0 +1,25 @@
1
+ import { collectIgnoredFileFingerprint } from "./deletion-fingerprint.ts";
2
+ import { isAncestor, listUnmergedCommits } from "./git.ts";
3
+ import { errorMessage } from "./delete-worktree-report.ts";
4
+
5
+ export { collectIgnoredFileFingerprint };
6
+
7
+ export async function recordAncestryPreflight(repoRoot: string, head: string, baseRef: string, preflight: Record<string, unknown>) {
8
+ preflight.ancestryRef = baseRef;
9
+ const proven = await isAncestor(repoRoot, head, baseRef);
10
+ preflight.ancestryProven = proven;
11
+ if (proven) {
12
+ preflight.unmergedCommits = [];
13
+ preflight.unmergedCommitCount = 0;
14
+ return proven;
15
+ }
16
+ let unmergedCommits: { commit: string; subject: string | undefined }[] = [];
17
+ try {
18
+ unmergedCommits = await listUnmergedCommits(repoRoot, head, baseRef);
19
+ } catch (error) {
20
+ preflight.unmergedCommitError = errorMessage(error);
21
+ }
22
+ preflight.unmergedCommits = unmergedCommits;
23
+ preflight.unmergedCommitCount = unmergedCommits.length;
24
+ return proven;
25
+ }
@@ -0,0 +1,70 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export function snapshotPreflight(preflight: Record<string, unknown>) {
4
+ const snapshot: Record<string, unknown> = { ...preflight, blockers: [...((preflight.blockers as string[] | undefined) ?? [])] };
5
+ return snapshot;
6
+ }
7
+
8
+ export function withDeleteReport(result: Record<string, unknown>, preflight: Record<string, unknown>, reportDetails: Record<string, unknown> = {}) {
9
+ const preflightSnapshot = snapshotPreflight(preflight);
10
+ return {
11
+ ...result,
12
+ preflight: preflightSnapshot,
13
+ report: {
14
+ action: reportDetails.action ?? result.status,
15
+ mode: preflightSnapshot.mode,
16
+ targetPath: preflightSnapshot.targetPath,
17
+ branch: preflightSnapshot.branch,
18
+ head: preflightSnapshot.head,
19
+ sessionId: preflightSnapshot.sessionId,
20
+ sessionStatus: preflightSnapshot.sessionStatus,
21
+ deleteBranch: preflightSnapshot.deleteBranch,
22
+ abandonUnmerged: preflightSnapshot.abandonUnmerged,
23
+ ancestryRef: preflightSnapshot.ancestryRef,
24
+ ancestryProven: preflightSnapshot.ancestryProven,
25
+ unmergedCommitCount: preflightSnapshot.unmergedCommitCount,
26
+ dirtyFileCount: preflightSnapshot.dirtyFileCount,
27
+ ignoredFileCount: preflightSnapshot.ignoredFileCount,
28
+ stashCount: preflightSnapshot.stashCount,
29
+ safetyRef: preflightSnapshot.safetyRef ?? result.safetyRef ?? null,
30
+ blockers: preflightSnapshot.blockers,
31
+ ...reportDetails,
32
+ },
33
+ };
34
+ }
35
+
36
+ export function blocked(reason: string, details: Record<string, unknown>, preflight: Record<string, unknown>) {
37
+ preflight.blockers = [...((preflight.blockers as string[] | undefined) ?? []), reason];
38
+ return withDeleteReport({ ok: false, status: "blocked", reason, ...details }, preflight, { action: "blocked" });
39
+ }
40
+
41
+ export function errorMessage(error: unknown) {
42
+ if (typeof error === "object" && error !== null) {
43
+ const details = error as Record<string, unknown>;
44
+ if (typeof details.gitStderr === "string" && details.gitStderr.length > 0) return details.gitStderr;
45
+ if (typeof details.message === "string" && details.message.length > 0) return details.message;
46
+ }
47
+ return String(error);
48
+ }
49
+
50
+ export function createConfirmToken(preflight: Record<string, unknown>) {
51
+ const material = {
52
+ repoRoot: preflight.repoRoot,
53
+ targetKind: preflight.targetKind ?? "worktree",
54
+ targetPath: preflight.targetPath,
55
+ worktreeListed: preflight.worktreeListed !== false,
56
+ branch: preflight.branch ?? "<detached>",
57
+ head: preflight.head,
58
+ sessionId: preflight.sessionId ?? null,
59
+ sessionStatus: preflight.sessionStatus ?? "unrecorded",
60
+ deleteBranch: preflight.deleteBranch === true,
61
+ abandonUnmerged: preflight.abandonUnmerged === true,
62
+ allowIgnoredFiles: preflight.allowIgnoredFiles === true,
63
+ ignoredFiles: preflight.ignoredFiles ?? [],
64
+ ignoredFileFingerprint: preflight.ignoredFileFingerprint ?? [],
65
+ ancestryRef: preflight.ancestryRef ?? null,
66
+ ancestryProven: preflight.ancestryProven === true,
67
+ unmergedCommits: preflight.unmergedCommits ?? [],
68
+ };
69
+ return crypto.createHash("sha256").update(JSON.stringify(material)).digest("hex");
70
+ }
@@ -0,0 +1,152 @@
1
+ import path from "node:path";
2
+ import { getBranchCommit, listRefs } from "./git.ts";
3
+ import { isTerminalSession, isActiveSession } from "./lifecycle.ts";
4
+ import { samePath } from "./filesystem-boundaries.ts";
5
+ import type { GuardianSession, WorktreeEntry } from "./types.ts";
6
+
7
+ export type TargetResolution = {
8
+ entry?: WorktreeEntry;
9
+ session?: GuardianSession;
10
+ targetKind?: "worktree" | "orphan-branch" | "stale-branch" | "merged-branch";
11
+ branch?: string;
12
+ head?: string;
13
+ ownershipProof?: string;
14
+ unresolvedReason: string;
15
+ };
16
+
17
+ function findSessionByWorktree(sessions: GuardianSession[], targetPath: string) {
18
+ const matchingSessions = sessions.filter((session) => typeof session.worktree_path === "string" && samePath(session.worktree_path, targetPath));
19
+ return matchingSessions.find(isActiveSession) ?? matchingSessions[0];
20
+ }
21
+
22
+ function sessionWorktreeEntry(session: GuardianSession | undefined, worktrees: WorktreeEntry[]) {
23
+ return session?.worktree_path ? worktrees.find((worktree) => samePath(worktree.path, session.worktree_path ?? "")) : undefined;
24
+ }
25
+
26
+ function sessionBranchCheckedOut(session: GuardianSession | undefined, worktrees: WorktreeEntry[]) {
27
+ return Boolean(session?.branch && worktrees.some((worktree) => worktree.branch === session.branch));
28
+ }
29
+
30
+ function safeRefSegment(value: unknown) {
31
+ return String(value)
32
+ .replace(/^refs\//, "")
33
+ .replace(/\.\.+/g, ".")
34
+ .replace(/[^A-Za-z0-9._/-]+/g, "-")
35
+ .replace(/^\/+|\/+$/g, "")
36
+ .replace(/\/+/g, "/");
37
+ }
38
+
39
+ function safetyRefMatchesBranch(refName: string, safeBranch: string) {
40
+ const prefix = "refs/opencode-guardian/";
41
+ if (!refName.startsWith(prefix)) return false;
42
+ const parts = refName.slice(prefix.length).split("/").filter(Boolean);
43
+ if (parts.length < 3) return false;
44
+ const branchStart = parts[0] === "preserved" ? 2 : 1;
45
+ return parts.slice(branchStart, -1).join("/") === safeBranch;
46
+ }
47
+
48
+ function branchFromInput(input: Record<string, unknown>) {
49
+ return typeof input.branch === "string" && input.branch.length > 0 ? input.branch : undefined;
50
+ }
51
+
52
+ function explicitTargets(input: Record<string, unknown>) {
53
+ return ["sessionId", "targetPath", "branch"].filter((key) => typeof input[key] === "string" && String(input[key]).length > 0);
54
+ }
55
+
56
+ function conflictingExplicitTarget(input: Record<string, unknown>, sessions: GuardianSession[]) {
57
+ const branch = branchFromInput(input);
58
+ if (!branch) return undefined;
59
+ if (typeof input.sessionId === "string" && input.sessionId.length > 0) {
60
+ const session = sessions.find((candidate) => candidate.session_id === input.sessionId);
61
+ if (!session) return `sessionId does not match guardian state for branch ${branch}`;
62
+ if (session?.branch && session.branch !== branch) return `sessionId resolves to branch ${session.branch}, not ${branch}`;
63
+ }
64
+ if (typeof input.targetPath === "string" && input.targetPath.length > 0) {
65
+ const resolved = path.resolve(String(input.repoRoot ?? process.cwd()), input.targetPath);
66
+ const pathSessions = sessions.filter((session) => typeof session.worktree_path === "string" && samePath(session.worktree_path, resolved));
67
+ if (pathSessions.length === 0) return `targetPath does not match guardian state for branch ${branch}`;
68
+ if (pathSessions.length > 1) return `targetPath matches multiple guardian sessions for branch ${branch}`;
69
+ if (pathSessions.length === 1 && pathSessions[0].branch && pathSessions[0].branch !== branch) return `targetPath resolves to branch ${pathSessions[0].branch}, not ${branch}`;
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ function orphanBranchResolution(session: GuardianSession | undefined, worktrees: WorktreeEntry[], deleteRequestedBranch: boolean, repoRoot: string): TargetResolution | undefined {
75
+ if (!session) return undefined;
76
+ if (!deleteRequestedBranch) return undefined;
77
+ if (!session.branch || !session.worktree_path) return undefined;
78
+ if (session.status === "deleted") return undefined;
79
+ const entry = sessionWorktreeEntry(session, worktrees);
80
+ if (entry && !samePath(entry.path, repoRoot)) return undefined;
81
+ if (sessionBranchCheckedOut(session, worktrees)) return undefined;
82
+ return { session, targetKind: "orphan-branch", unresolvedReason: entry ? "recorded session worktree is the primary repo but the branch is not checked out" : "recorded session worktree is not in git worktree list" };
83
+ }
84
+
85
+ async function staleBranchResolution(input: Record<string, unknown>, worktrees: WorktreeEntry[], sessions: GuardianSession[], repoRoot: string, requestedSession?: GuardianSession): Promise<TargetResolution | undefined> {
86
+ const branch = branchFromInput(input) ?? (requestedSession && isTerminalSession(requestedSession) ? requestedSession.branch : undefined);
87
+ if (!branch || input.deleteBranch !== true) return undefined;
88
+ if (worktrees.some((worktree) => worktree.branch === branch)) return undefined;
89
+ let head: string;
90
+ try {
91
+ head = await getBranchCommit(repoRoot, branch);
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ const terminalMatches = requestedSession && isTerminalSession(requestedSession) ? [requestedSession] : sessions.filter((session) => session.branch === branch && isTerminalSession(session));
96
+ if (terminalMatches.length > 1) return { branch, head, targetKind: "stale-branch", unresolvedReason: "branch matches multiple terminal guardian sessions" };
97
+ if (terminalMatches.length === 1 && terminalMatches[0].head_commit === head) {
98
+ return { session: terminalMatches[0], branch, head, targetKind: "stale-branch", ownershipProof: "terminal-session", unresolvedReason: "branch has no checked-out worktree but terminal guardian state proves ownership" };
99
+ }
100
+ const safeBranch = safeRefSegment(branch);
101
+ const safetyRefs = (await listRefs(repoRoot, "refs/opencode-guardian")) as Array<{ name?: string; commit?: string }>;
102
+ const matchingRefs = safetyRefs.filter((ref) => ref.commit === head && typeof ref.name === "string" && safetyRefMatchesBranch(ref.name, safeBranch));
103
+ if (matchingRefs.length > 0) return { branch, head, targetKind: "stale-branch", ownershipProof: "safety-ref", unresolvedReason: "branch has no checked-out worktree but guardian safety refs prove ownership" };
104
+ return undefined;
105
+ }
106
+
107
+ async function mergedBranchResolution(input: Record<string, unknown>, worktrees: WorktreeEntry[], repoRoot: string): Promise<TargetResolution | undefined> {
108
+ const branch = branchFromInput(input);
109
+ if (!branch || input.deleteBranch !== true) return undefined;
110
+ if (branch.startsWith("guardian/")) return undefined;
111
+ if (worktrees.some((worktree) => worktree.branch === branch)) return undefined;
112
+ try {
113
+ const head = await getBranchCommit(repoRoot, branch);
114
+ return { branch, head, targetKind: "merged-branch", unresolvedReason: "local branch is not checked out in any worktree and can be deleted if ancestry is proven" };
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ }
119
+
120
+ export async function findTarget(input: Record<string, unknown>, worktrees: WorktreeEntry[], sessions: GuardianSession[]): Promise<TargetResolution> {
121
+ const targets = explicitTargets(input);
122
+ if (targets.length > 1) return { unresolvedReason: `target inputs conflict: provide exactly one of targetPath, sessionId, or branch; received ${targets.join(", ")}` };
123
+ const conflict = conflictingExplicitTarget(input, sessions);
124
+ if (conflict) return { unresolvedReason: `target inputs conflict: ${conflict}` };
125
+ const deleteRequestedBranch = input.deleteBranch === true;
126
+ const repoRoot = String(input.repoRoot ?? process.cwd());
127
+ if (typeof input.sessionId === "string" && input.sessionId.length > 0) {
128
+ const session = sessions.find((candidate) => candidate.session_id === input.sessionId);
129
+ const staleBranch = await staleBranchResolution(input, worktrees, sessions, repoRoot, session);
130
+ if (staleBranch) return staleBranch;
131
+ const entry = sessionWorktreeEntry(session, worktrees);
132
+ return orphanBranchResolution(session, worktrees, deleteRequestedBranch, repoRoot) ?? (entry ? { entry, session, targetKind: "worktree", unresolvedReason: "" } : { session, unresolvedReason: session ? "recorded session worktree is not in git worktree list" : "sessionId does not match guardian state" });
133
+ }
134
+ if (typeof input.targetPath === "string" && input.targetPath.length > 0) {
135
+ const resolved = path.resolve(repoRoot, input.targetPath);
136
+ const entry = worktrees.find((worktree) => samePath(worktree.path, resolved));
137
+ if (entry) return { entry, session: findSessionByWorktree(sessions, entry.path), targetKind: "worktree", unresolvedReason: "" };
138
+ const matches = sessions.filter((session) => typeof session.worktree_path === "string" && samePath(session.worktree_path, resolved));
139
+ return matches.length === 1 ? orphanBranchResolution(matches[0], worktrees, deleteRequestedBranch, repoRoot) ?? { session: matches[0], unresolvedReason: "targetPath is not in git worktree list" } : { unresolvedReason: matches.length > 1 ? "targetPath matches multiple guardian sessions" : "targetPath is not in git worktree list" };
140
+ }
141
+ if (typeof input.branch === "string" && input.branch.length > 0) {
142
+ const staleBranch = await staleBranchResolution(input, worktrees, sessions, repoRoot);
143
+ if (staleBranch) return staleBranch;
144
+ const mergedBranch = await mergedBranchResolution(input, worktrees, repoRoot);
145
+ if (mergedBranch) return mergedBranch;
146
+ const matches = worktrees.filter((worktree) => worktree.branch === input.branch);
147
+ if (matches.length === 1) return { entry: matches[0], session: findSessionByWorktree(sessions, matches[0].path), targetKind: "worktree", unresolvedReason: "" };
148
+ const sessionMatches = sessions.filter((session) => session.branch === input.branch && session.status !== "deleted");
149
+ return sessionMatches.length === 1 ? orphanBranchResolution(sessionMatches[0], worktrees, deleteRequestedBranch, repoRoot) ?? { session: sessionMatches[0], unresolvedReason: "branch is not checked out in git worktree list" } : { unresolvedReason: matches.length > 1 ? "branch matches multiple worktrees" : sessionMatches.length > 1 ? "branch matches multiple guardian sessions" : "branch is not checked out in git worktree list" };
150
+ }
151
+ return { entry: undefined, session: undefined, unresolvedReason: "one of targetPath, sessionId, or branch is required" };
152
+ }