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
@@ -0,0 +1,49 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export function isEnoent(error: unknown) {
5
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
6
+ }
7
+
8
+ export function normalizeRelativePath(value: string) {
9
+ return value.split(path.sep).join("/").replace(/^\.\//, "");
10
+ }
11
+
12
+ export function relativePath(repoRoot: string, absolutePath: string) {
13
+ return normalizeRelativePath(path.relative(repoRoot, absolutePath)) || ".";
14
+ }
15
+
16
+ export function isSameOrInside(candidate: string, root: string) {
17
+ const relative = path.relative(root, candidate);
18
+ return relative === "" || Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
19
+ }
20
+
21
+ export function samePath(left: string, right: string) {
22
+ return path.resolve(left) === path.resolve(right);
23
+ }
24
+
25
+ export function parseNullSeparated(stdout: string) {
26
+ return stdout.split("\0").map((entry) => entry.trim()).filter(Boolean);
27
+ }
28
+
29
+ export function recordValue(value: unknown): Record<string, unknown> {
30
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
31
+ }
32
+
33
+ export function stringArray(value: unknown) {
34
+ if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
35
+ return typeof value === "string" && value.trim().length > 0 ? [value] : [];
36
+ }
37
+
38
+ export function uniqueSorted(values: string[]) {
39
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
40
+ }
41
+
42
+ export async function lstatOrMissing(candidate: string) {
43
+ try {
44
+ return await fs.lstat(candidate);
45
+ } catch (error) {
46
+ if (isEnoent(error)) return null;
47
+ throw error;
48
+ }
49
+ }
@@ -0,0 +1,56 @@
1
+ import { expandWorktreeRoot } from "./config.ts";
2
+ import type { MutableRecord } from "./types.ts";
3
+
4
+ export function normalizeDirtyPath(value: string): string {
5
+ return value.replace(/\\/g, "/").replace(/^\.\//, "");
6
+ }
7
+
8
+ export function escapeRegExp(value: string): string {
9
+ return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
10
+ }
11
+
12
+ export function globToRegExp(pattern: string): RegExp {
13
+ let source = "^";
14
+ for (let index = 0; index < pattern.length; index += 1) {
15
+ const char = pattern[index];
16
+ if (char === "*") {
17
+ if (pattern[index + 1] === "*") {
18
+ source += ".*";
19
+ index += 1;
20
+ } else {
21
+ source += "[^/]*";
22
+ }
23
+ } else {
24
+ source += escapeRegExp(char);
25
+ }
26
+ }
27
+ return new RegExp(`${source}$`);
28
+ }
29
+
30
+ export function matchesAllowedDirtyPath(filePath: string, pattern: string): boolean {
31
+ const file = normalizeDirtyPath(filePath);
32
+ const allowed = normalizeDirtyPath(pattern);
33
+ if (!allowed) return false;
34
+ if (allowed.endsWith("/**")) {
35
+ const prefix = allowed.slice(0, -3).replace(/\/$/, "");
36
+ return file === prefix || file.startsWith(`${prefix}/`);
37
+ }
38
+ if (!allowed.includes("*")) return file === allowed;
39
+ return globToRegExp(allowed).test(file);
40
+ }
41
+
42
+ export function classifyDirtyFiles(dirtyFiles: readonly string[], allowDirtyPaths: unknown): { readonly allowedDirtyFiles: string[]; readonly blockingDirtyFiles: string[] } {
43
+ const patterns = Array.isArray(allowDirtyPaths) ? allowDirtyPaths.filter((value): value is string => typeof value === "string" && value.length > 0) : [];
44
+ const allowedDirtyFiles = dirtyFiles.filter((file) => patterns.some((pattern) => matchesAllowedDirtyPath(file, pattern)));
45
+ const blockingDirtyFiles = dirtyFiles.filter((file) => !allowedDirtyFiles.includes(file));
46
+ return { allowedDirtyFiles, blockingDirtyFiles };
47
+ }
48
+
49
+ export function splitPrimaryDirtyFiles(dirtyFiles: readonly string[], repoRoot: string, config: MutableRecord): { readonly ignoredDirtyFiles: string[]; readonly blockingDirtyFiles: string[] } {
50
+ const configuredRoot = typeof config.worktreeRoot === "string" ? config.worktreeRoot : ".worktrees/$REPO";
51
+ const guardianRoot = normalizeDirtyPath(expandWorktreeRoot(configuredRoot, repoRoot)).replace(/\/$/, "");
52
+ const guardianRootPrefix = `${guardianRoot}/`;
53
+ const ignoredDirtyFiles = dirtyFiles.filter((file) => normalizeDirtyPath(file).startsWith(guardianRootPrefix));
54
+ const blockingDirtyFiles = dirtyFiles.filter((file) => !ignoredDirtyFiles.includes(file));
55
+ return { ignoredDirtyFiles, blockingDirtyFiles };
56
+ }
@@ -0,0 +1,80 @@
1
+ import type { GuardianSession, MutableRecord } from "./types.ts";
2
+ import { isRecordLike } from "./types.ts";
3
+
4
+ export type LooseRecord = MutableRecord;
5
+
6
+ export type FinishPreflight = MutableRecord & {
7
+ blockers: string[];
8
+ allowedDirtyFileCount: number;
9
+ };
10
+
11
+ export type FinishStateInput = {
12
+ readonly sessions?: Record<string, GuardianSession>;
13
+ };
14
+
15
+ export type GuardianFinishResult = MutableRecord & {
16
+ reason?: string;
17
+ safetyRef?: string;
18
+ suggestedCommand?: string;
19
+ commit?: string;
20
+ dirtyFiles?: readonly string[];
21
+ cleanupSkippedReason?: string;
22
+ preflight: MutableRecord;
23
+ report: MutableRecord;
24
+ };
25
+
26
+ export function snapshotPreflight(preflight: FinishPreflight): MutableRecord {
27
+ return { ...preflight, blockers: [...preflight.blockers] };
28
+ }
29
+
30
+ export function withFinishReport(result: LooseRecord, preflight: FinishPreflight, reportDetails: LooseRecord = {}): GuardianFinishResult {
31
+ const preflightSnapshot = snapshotPreflight(preflight);
32
+ return {
33
+ ...result,
34
+ preflight: preflightSnapshot,
35
+ report: {
36
+ action: typeof reportDetails.action === "string" ? reportDetails.action : result.status,
37
+ sessionId: preflightSnapshot.sessionId,
38
+ sessionRecorded: preflightSnapshot.sessionRecorded,
39
+ sessionOwnedWorktree: preflightSnapshot.sessionOwnedWorktree,
40
+ currentWorktree: preflightSnapshot.currentWorktree,
41
+ sessionWorktree: preflightSnapshot.sessionWorktree,
42
+ currentBranch: preflightSnapshot.currentBranch,
43
+ sessionBranch: preflightSnapshot.sessionBranch,
44
+ branchProtected: preflightSnapshot.branchProtected,
45
+ dirtyFileCount: preflightSnapshot.dirtyFileCount,
46
+ allowedDirtyFileCount: preflightSnapshot.allowedDirtyFileCount,
47
+ blockingDirtyFileCount: preflightSnapshot.blockingDirtyFileCount,
48
+ stashCount: preflightSnapshot.stashCount,
49
+ baseWorktree: preflightSnapshot.baseWorktree,
50
+ baseWorktreeBranch: preflightSnapshot.baseWorktreeBranch,
51
+ baseWorktreeDirtyFileCount: preflightSnapshot.baseWorktreeDirtyFileCount,
52
+ baseWorktreeIgnoredDirtyFileCount: preflightSnapshot.baseWorktreeIgnoredDirtyFileCount,
53
+ safetyRef: preflightSnapshot.safetyRef ?? result.safetyRef ?? null,
54
+ remote: preflightSnapshot.remote,
55
+ baseBranch: preflightSnapshot.baseBranch,
56
+ mode: preflightSnapshot.mode,
57
+ blockers: preflightSnapshot.blockers,
58
+ suggestedCommand: result.suggestedCommand,
59
+ ...reportDetails,
60
+ },
61
+ };
62
+ }
63
+
64
+ export function blocked(reason: string, details: LooseRecord = {}, preflight: FinishPreflight, reportDetails: LooseRecord = {}): GuardianFinishResult {
65
+ const result = { ok: false, status: "blocked", reason, ...details };
66
+ preflight.blockers = [...preflight.blockers, reason];
67
+ return withFinishReport(result, preflight, { action: "blocked", ...reportDetails });
68
+ }
69
+
70
+ export function errorMessage(error: unknown): string {
71
+ if (isRecordLike(error)) {
72
+ if (typeof error.gitStderr === "string" && error.gitStderr.length > 0) return error.gitStderr;
73
+ if (typeof error.message === "string" && error.message.length > 0) return error.message;
74
+ }
75
+ return String(error);
76
+ }
77
+
78
+ export function isFinishStateInput(value: unknown): value is FinishStateInput {
79
+ return isRecordLike(value) && (value.sessions === undefined || isRecordLike(value.sessions));
80
+ }
package/src/finish.ts ADDED
@@ -0,0 +1,212 @@
1
+ import path from "node:path";
2
+ import { loadConfig, normalizeConfig } from "./config.ts";
3
+ import { classifyDirtyFiles, splitPrimaryDirtyFiles } from "./finish-dirty-files.ts";
4
+ import { blocked, errorMessage, isFinishStateInput, withFinishReport } from "./finish-report.ts";
5
+ import type { FinishPreflight, GuardianFinishResult, LooseRecord } from "./finish-report.ts";
6
+ import { createSafetyRef, fetchRemote, getCurrentBranch, getDirtyFiles, getHeadCommit, getRepoRoot, isAncestor, listStashes, pushBranch, runGit } from "./git.ts";
7
+ import { isTerminalSession } from "./lifecycle.ts";
8
+ import { getGuardianPaths, readState, recordSession } from "./state.ts";
9
+ import { isRecordLike } from "./types.ts";
10
+
11
+ function samePath(a: string, b: string) {
12
+ return path.resolve(a) === path.resolve(b);
13
+ }
14
+
15
+ export type { GuardianFinishResult } from "./finish-report.ts";
16
+
17
+ export async function guardianFinish(input: LooseRecord = {}): Promise<GuardianFinishResult> {
18
+ const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
19
+ const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
20
+ const { config } = isRecordLike(input.config) ? { config: normalizeConfig(input.config) } : await loadConfig(repoRoot);
21
+ const mode = typeof input.finishMode === "string" ? input.finishMode : config.finishMode;
22
+ const sessionId = typeof input.sessionId === "string" ? input.sessionId : null;
23
+ const preflight: FinishPreflight = {
24
+ sessionId: sessionId ?? null,
25
+ sessionRecorded: false,
26
+ sessionOwnedWorktree: false,
27
+ currentWorktree: null,
28
+ sessionWorktree: null,
29
+ currentBranch: null,
30
+ sessionBranch: null,
31
+ branchProtected: null,
32
+ protectedBranches: config.protectedBranches,
33
+ allowDirtyPaths: config.allowDirtyPaths,
34
+ dirtyFiles: [],
35
+ dirtyFileCount: 0,
36
+ allowedDirtyFiles: [],
37
+ allowedDirtyFileCount: 0,
38
+ blockingDirtyFiles: [],
39
+ blockingDirtyFileCount: 0,
40
+ stashCount: 0,
41
+ baseWorktree: null,
42
+ baseWorktreeBranch: null,
43
+ baseWorktreeDirtyFiles: [],
44
+ baseWorktreeDirtyFileCount: 0,
45
+ baseWorktreeIgnoredDirtyFiles: [],
46
+ baseWorktreeIgnoredDirtyFileCount: 0,
47
+ safetyRef: null,
48
+ remote: config.remote,
49
+ baseBranch: config.baseBranch,
50
+ mode,
51
+ blockers: [],
52
+ };
53
+ if (!sessionId) return blocked("sessionId is required", {}, preflight);
54
+
55
+ const paths = await getGuardianPaths(repoRoot);
56
+ const state = isFinishStateInput(input.state) ? input.state : await readState(paths, { repoRoot, config });
57
+ const session = state.sessions?.[sessionId];
58
+ preflight.sessionRecorded = Boolean(session);
59
+ if (!session) return blocked("current session is not recorded in guardian state", { sessionId }, preflight);
60
+ if (typeof session.worktree_path !== "string" || session.worktree_path.length === 0) {
61
+ return blocked("recorded session is missing a worktree path", { sessionId }, preflight);
62
+ }
63
+ preflight.sessionWorktree = session.worktree_path;
64
+ preflight.sessionBranch = session.branch;
65
+ if (isTerminalSession(session)) {
66
+ return blocked(`session ${sessionId} is terminal (${session.status}); start a new session instead of finishing a deleted or closed worktree`, { sessionId, sessionStatus: session.status }, preflight);
67
+ }
68
+
69
+ const currentWorktree = await getRepoRoot(cwd);
70
+ preflight.currentWorktree = currentWorktree;
71
+ preflight.sessionOwnedWorktree = samePath(session.worktree_path, currentWorktree);
72
+ if (!preflight.sessionOwnedWorktree) {
73
+ return blocked("current session does not own this worktree", { sessionWorktree: session.worktree_path, currentWorktree }, preflight);
74
+ }
75
+
76
+ const branch = await getCurrentBranch(currentWorktree);
77
+ preflight.currentBranch = branch;
78
+ if (!branch) return blocked("detached HEAD cannot be finished safely", { worktree: currentWorktree }, preflight);
79
+ preflight.branchProtected = config.protectedBranches.includes(branch);
80
+ if (branch !== session.branch) return blocked("current branch does not match recorded session branch", { branch, sessionBranch: session.branch }, preflight);
81
+ if (preflight.branchProtected) return blocked("protected branches cannot be finished by guardian", { branch }, preflight);
82
+
83
+ const dirtyFiles = await getDirtyFiles(currentWorktree);
84
+ const { allowedDirtyFiles, blockingDirtyFiles } = classifyDirtyFiles(dirtyFiles, config.allowDirtyPaths);
85
+ preflight.dirtyFiles = dirtyFiles;
86
+ preflight.dirtyFileCount = dirtyFiles.length;
87
+ preflight.allowedDirtyFiles = allowedDirtyFiles;
88
+ preflight.allowedDirtyFileCount = allowedDirtyFiles.length;
89
+ preflight.blockingDirtyFiles = blockingDirtyFiles;
90
+ preflight.blockingDirtyFileCount = blockingDirtyFiles.length;
91
+ if (blockingDirtyFiles.length) return blocked("worktree has uncommitted changes", { dirtyFiles: blockingDirtyFiles, allowedDirtyFiles, worktree: currentWorktree }, preflight);
92
+
93
+ const stashes = await listStashes(currentWorktree);
94
+ preflight.stashCount = stashes.length;
95
+ if (stashes.length && !config.allowStashIfUnrelated) {
96
+ return blocked("stash inventory is non-empty", {
97
+ stashes,
98
+ suggestedCommands: ["git stash list", "git stash show -p stash@{0}"],
99
+ }, preflight, { action: "inspect-stashes", suggestedCommands: ["git stash list", "git stash show -p stash@{0}"] });
100
+ }
101
+
102
+ const commit = await getHeadCommit(currentWorktree);
103
+ preflight.commit = commit;
104
+ const existingSafetyRefs = Array.isArray(session.safety_refs) ? session.safety_refs.filter((ref: unknown) => typeof ref === "string") : [];
105
+ const existingSafetyRef = existingSafetyRefs[existingSafetyRefs.length - 1];
106
+ if (mode === "preserve-only" && session.status === "preserved" && existingSafetyRef) {
107
+ preflight.safetyRef = existingSafetyRef;
108
+ return withFinishReport({ ok: true, status: "preserved", mode, branch, worktree: currentWorktree, commit, safetyRef: existingSafetyRef, idempotent: true }, preflight, { action: "already-preserved" });
109
+ }
110
+ const safetyRef = await createSafetyRef(currentWorktree, { sessionId, branch, commit, timestamp: input.timestamp });
111
+ preflight.safetyRef = safetyRef;
112
+ await recordSession(repoRoot, config, {
113
+ ...session,
114
+ session_id: sessionId,
115
+ status: mode === "preserve-only" ? "preserved" : session.status,
116
+ head_commit: commit,
117
+ safety_refs: [...(session.safety_refs ?? []), safetyRef],
118
+ }, { event: { type: "safety_ref_created", session_id: sessionId, ref: safetyRef } });
119
+
120
+ if (mode === "preserve-only") {
121
+ return withFinishReport({ ok: true, status: "preserved", mode, branch, worktree: currentWorktree, commit, safetyRef }, preflight, { action: "preserved" });
122
+ }
123
+
124
+ if (mode === "push-branch" || mode === "create-pr") {
125
+ try {
126
+ await pushBranch(currentWorktree, config.remote, branch);
127
+ } catch (error) {
128
+ return blocked("push failed", { safetyRef, branch, error: errorMessage(error) }, preflight);
129
+ }
130
+ await recordSession(repoRoot, config, {
131
+ ...session,
132
+ session_id: sessionId,
133
+ status: "preserved",
134
+ head_commit: commit,
135
+ safety_refs: [...(session.safety_refs ?? []), safetyRef],
136
+ }, { event: { type: "guardian_finish", session_id: sessionId, ref: safetyRef } });
137
+ const result: LooseRecord = { ok: true, status: mode === "push-branch" ? "pushed" : "pr-suggested", mode, branch, safetyRef };
138
+ if (mode === "create-pr") {
139
+ result.suggestedCommand = `gh pr create --base ${config.baseBranch} --head ${branch}`;
140
+ result.note = "No native GitHub integration is wired; branch was pushed and a PR command is suggested.";
141
+ }
142
+ return withFinishReport(result, preflight, { action: mode === "push-branch" ? "pushed" : "pushed-and-suggested-pr" });
143
+ }
144
+
145
+ if (mode === "merge-to-base") {
146
+ if (input.allowMergeToBase !== true) {
147
+ return blocked("merge-to-base requires explicit allowMergeToBase=true", { safetyRef, branch }, preflight, { action: "requires-explicit-merge-approval" });
148
+ }
149
+ const baseWorktree = await getRepoRoot(repoRoot);
150
+ const baseWorktreeBranch = await getCurrentBranch(baseWorktree);
151
+ const baseWorktreeAllDirtyFiles = await getDirtyFiles(baseWorktree);
152
+ const { ignoredDirtyFiles: baseWorktreeIgnoredDirtyFiles, blockingDirtyFiles: baseWorktreeDirtyFiles } = splitPrimaryDirtyFiles(baseWorktreeAllDirtyFiles, repoRoot, config);
153
+ preflight.baseWorktree = baseWorktree;
154
+ preflight.baseWorktreeBranch = baseWorktreeBranch;
155
+ preflight.baseWorktreeDirtyFiles = baseWorktreeDirtyFiles;
156
+ preflight.baseWorktreeDirtyFileCount = baseWorktreeDirtyFiles.length;
157
+ preflight.baseWorktreeIgnoredDirtyFiles = baseWorktreeIgnoredDirtyFiles;
158
+ preflight.baseWorktreeIgnoredDirtyFileCount = baseWorktreeIgnoredDirtyFiles.length;
159
+ if (baseWorktreeBranch !== config.baseBranch) {
160
+ return blocked("merge-to-base requires primary repo worktree to already be on the base branch", { safetyRef, branch, baseWorktree, baseWorktreeBranch, baseBranch: config.baseBranch }, preflight);
161
+ }
162
+ if (baseWorktreeDirtyFiles.length > 0) {
163
+ return blocked("merge-to-base requires primary repo worktree to be clean", { safetyRef, branch, baseWorktree, dirtyFiles: baseWorktreeDirtyFiles }, preflight);
164
+ }
165
+ await runGit(repoRoot, ["checkout", config.baseBranch]);
166
+ await runGit(repoRoot, ["merge", "--ff-only", branch]);
167
+ await runGit(repoRoot, ["push", config.remote, config.baseBranch]);
168
+ await fetchRemote(repoRoot, config.remote);
169
+ const proven = await isAncestor(repoRoot, commit, `${config.remote}/${config.baseBranch}`);
170
+ if (!proven) return blocked("merged commit is not proven reachable from remote base", { safetyRef, commit }, preflight);
171
+
172
+ const shouldCleanup = config.autoCleanup === true || input.allowCleanup === true;
173
+ if (!shouldCleanup) {
174
+ await recordSession(repoRoot, config, {
175
+ ...session,
176
+ session_id: sessionId,
177
+ status: "finished",
178
+ head_commit: commit,
179
+ safety_refs: [...(session.safety_refs ?? []), safetyRef],
180
+ }, { event: { type: "guardian_finish", session_id: sessionId, ref: safetyRef } });
181
+ return withFinishReport({ ok: true, status: "merged", mode, branch, commit, safetyRef, cleaned: false }, preflight, { action: "merged-without-cleanup" });
182
+ }
183
+ if (preflight.allowedDirtyFileCount > 0) {
184
+ await recordSession(repoRoot, config, {
185
+ ...session,
186
+ session_id: sessionId,
187
+ status: "finished",
188
+ head_commit: commit,
189
+ safety_refs: [...(session.safety_refs ?? []), safetyRef],
190
+ }, { event: { type: "guardian_finish", session_id: sessionId, ref: safetyRef } });
191
+ return withFinishReport({ ok: true, status: "merged", mode, branch, commit, safetyRef, cleaned: false, cleanupSkippedReason: "allowed dirty files are present" }, preflight, { action: "merged-without-cleanup", cleanupSkippedReason: "allowed dirty files are present" });
192
+ }
193
+ if (samePath(currentWorktree, repoRoot)) {
194
+ return blocked("refusing to remove the primary/current repo worktree", { safetyRef, commit, branch }, preflight);
195
+ }
196
+
197
+ await runGit(repoRoot, ["worktree", "remove", currentWorktree]);
198
+ await runGit(repoRoot, ["branch", "-d", branch]);
199
+ await recordSession(repoRoot, config, {
200
+ ...session,
201
+ session_id: sessionId,
202
+ status: "finished",
203
+ head_commit: commit,
204
+ safety_refs: [...(session.safety_refs ?? []), safetyRef],
205
+ deleted_worktree_path: currentWorktree,
206
+ deleted_branch: branch,
207
+ }, { event: { type: "guardian_finish", session_id: sessionId, ref: safetyRef } });
208
+ return withFinishReport({ ok: true, status: "finished", mode, branch, commit, safetyRef, cleaned: true }, preflight, { action: "merged-and-cleaned" });
209
+ }
210
+
211
+ return blocked(`unsupported finish mode: ${mode}`, { safetyRef }, preflight);
212
+ }