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,298 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { expandWorktreeRoot, loadConfig, normalizeConfig } from "./config.ts";
5
+ import { createSafetyRef, getCurrentBranch, getHeadCommit, getRepoRoot, listWorktrees, runGit } from "./git.ts";
6
+ import { getGuardianPaths, readState, recordSession } from "./state.ts";
7
+ import type { GuardianSession, MutableRecord, WorktreeEntry } from "./types.ts";
8
+ import { isRecordLike } from "./types.ts";
9
+
10
+ type LooseRecord = MutableRecord;
11
+
12
+ type StatusEntry = {
13
+ index: string;
14
+ worktree: string;
15
+ path: string;
16
+ sourcePath: string | null;
17
+ hash: string | null;
18
+ symlink: boolean;
19
+ classification: "review-artifact" | "other";
20
+ };
21
+
22
+ type ResolvedTarget = {
23
+ worktreePath: string;
24
+ branch: string | null;
25
+ targetSource: "state" | "branch" | "worktreePath";
26
+ };
27
+ type UnblockStateInput = {
28
+ readonly sessions?: Record<string, GuardianSession>;
29
+ };
30
+ export type GuardianUnblockFinishResult = MutableRecord & {
31
+ readonly action?: string;
32
+ readonly committedPaths?: readonly string[];
33
+ readonly confirmToken?: string;
34
+ readonly otherDirtyPaths?: readonly string[];
35
+ readonly preflight: MutableRecord & {
36
+ readonly branch?: string | null;
37
+ readonly otherDirtyPaths?: readonly string[];
38
+ readonly reviewArtifactPaths?: readonly string[];
39
+ readonly sessionRecorded?: boolean;
40
+ readonly targetSource?: string | null;
41
+ readonly worktreePath?: string | null;
42
+ };
43
+ readonly reason?: string;
44
+ readonly safetyRef?: string;
45
+ };
46
+
47
+ const REVIEW_ARTIFACT_PATTERN = /^\.milestones\/reviews\/[^/]*impl-rating-\d{8}\.(md|txt)$/;
48
+
49
+ function sha256(value: string | Buffer) {
50
+ return crypto.createHash("sha256").update(value).digest("hex");
51
+ }
52
+
53
+ function blocked(reason: string, details: LooseRecord, preflight: LooseRecord) {
54
+ const previousBlockers = Array.isArray(preflight.blockers) ? preflight.blockers.filter((blocker): blocker is string => typeof blocker === "string") : [];
55
+ const blockers = [...previousBlockers, reason];
56
+ preflight.blockers = blockers;
57
+ return { ok: false, status: "blocked", reason, ...details, preflight: { ...preflight, blockers } };
58
+ }
59
+
60
+ function relativeGitPath(value: string) {
61
+ return value.replace(/\\/g, "/").replace(/^\.\//, "");
62
+ }
63
+
64
+ function samePath(left: string, right: string) {
65
+ return path.resolve(left) === path.resolve(right);
66
+ }
67
+
68
+ function sameOrInside(candidate: string, root: string) {
69
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
70
+ return relative === "" || Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
71
+ }
72
+
73
+ async function contentHash(repoRoot: string, entryPath: string) {
74
+ try {
75
+ return sha256(await fs.readFile(path.join(repoRoot, entryPath)));
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ async function isSymlink(repoRoot: string, entryPath: string) {
82
+ try {
83
+ return (await fs.lstat(path.join(repoRoot, entryPath))).isSymbolicLink();
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ async function statusEntries(worktreePath: string): Promise<StatusEntry[]> {
90
+ const { stdout } = await runGit(worktreePath, ["status", "--porcelain=v1", "--untracked-files=all"]);
91
+ if (!stdout) return [];
92
+ const entries: StatusEntry[] = [];
93
+ for (const line of stdout.split("\n")) {
94
+ if (!line) continue;
95
+ const index = line[0] ?? " ";
96
+ const worktree = line[1] ?? " ";
97
+ const rawPath = relativeGitPath(line.slice(3));
98
+ const [sourcePath, entryPath] = rawPath.includes(" -> ")
99
+ ? [relativeGitPath(rawPath.split(" -> ")[0] ?? rawPath), relativeGitPath(rawPath.split(" -> ").at(-1) ?? rawPath)]
100
+ : [null, rawPath];
101
+ const symlink = await isSymlink(worktreePath, entryPath);
102
+ entries.push({
103
+ index,
104
+ worktree,
105
+ path: entryPath,
106
+ sourcePath,
107
+ hash: await contentHash(worktreePath, entryPath),
108
+ symlink,
109
+ classification: !sourcePath && !symlink && REVIEW_ARTIFACT_PATTERN.test(entryPath) ? "review-artifact" : "other",
110
+ });
111
+ }
112
+ return entries;
113
+ }
114
+
115
+ function createConfirmToken(preflight: LooseRecord) {
116
+ const material = {
117
+ repoRoot: preflight.repoRoot,
118
+ sessionId: preflight.sessionId,
119
+ targetSource: preflight.targetSource,
120
+ sessionRecorded: preflight.sessionRecorded,
121
+ worktreePath: preflight.worktreePath,
122
+ branch: preflight.branch,
123
+ head: preflight.head,
124
+ action: preflight.action,
125
+ entries: preflight.entries,
126
+ };
127
+ return sha256(JSON.stringify(material));
128
+ }
129
+
130
+ function defaultCommitMessage(entries: StatusEntry[]) {
131
+ if (entries.length === 1) {
132
+ const base = path.basename(entries[0].path).replace(/-?impl-rating-\d{8}\.(md|txt)$/i, "").replace(/[-_]+/g, " ").trim();
133
+ if (base) return `docs: add ${base} implementation rating`;
134
+ }
135
+ return "docs: add implementation review artifacts";
136
+ }
137
+
138
+ function preflightBlockers(preflight: LooseRecord): string[] {
139
+ return Array.isArray(preflight.blockers) ? preflight.blockers.filter((blocker): blocker is string => typeof blocker === "string") : [];
140
+ }
141
+
142
+ function isUnblockStateInput(value: unknown): value is UnblockStateInput {
143
+ return isRecordLike(value) && (value.sessions === undefined || isRecordLike(value.sessions));
144
+ }
145
+
146
+ async function resolveListedWorktree(repoRoot: string, predicate: (worktree: WorktreeEntry) => boolean, targetSource: ResolvedTarget["targetSource"], missingReason: string, multipleReason: string, expectedBranch?: string | null): Promise<{ target: ResolvedTarget | null; reason: string | null }> {
147
+ const matches = (await listWorktrees(repoRoot)).filter(predicate);
148
+ if (matches.length !== 1) return { target: null, reason: matches.length > 1 ? multipleReason : missingReason };
149
+ const match = matches[0];
150
+ if (match.detached || !match.branch) return { target: null, reason: "target worktree is detached" };
151
+ if (expectedBranch && match.branch !== expectedBranch) return { target: null, reason: "recorded branch does not match checked-out worktree branch" };
152
+ return { target: { worktreePath: match.path, branch: match.branch, targetSource }, reason: null };
153
+ }
154
+
155
+ async function resolveExplicitTarget(repoRoot: string, input: LooseRecord): Promise<{ target: ResolvedTarget | null; reason: string | null }> {
156
+ const explicitWorktreePath = typeof input.worktreePath === "string" && input.worktreePath.length > 0 ? input.worktreePath : null;
157
+ const explicitBranch = typeof input.branch === "string" && input.branch.length > 0 ? input.branch : null;
158
+ if (!explicitWorktreePath && !explicitBranch) return { target: null, reason: "current session is not recorded in guardian state" };
159
+
160
+ if (explicitWorktreePath) {
161
+ const resolved = path.resolve(repoRoot, explicitWorktreePath);
162
+ return resolveListedWorktree(repoRoot, (worktree) => samePath(worktree.path, resolved), "worktreePath", "worktreePath is not checked out in git worktree list", "worktreePath matches multiple git worktrees", explicitBranch);
163
+ }
164
+
165
+ return resolveListedWorktree(repoRoot, (worktree) => worktree.branch === explicitBranch, "branch", "branch is not checked out in git worktree list", "branch matches multiple git worktrees");
166
+ }
167
+
168
+ function validateResolvedTarget(repoRoot: string, config: LooseRecord, target: ResolvedTarget) {
169
+ if (samePath(target.worktreePath, repoRoot)) return "target worktree is the primary repository worktree";
170
+ if (!target.branch) return "target worktree is detached";
171
+ const protectedBranches = Array.isArray(config.protectedBranches) ? config.protectedBranches : [];
172
+ if (protectedBranches.includes(target.branch)) return "target branch is protected";
173
+ if (target.targetSource !== "state") {
174
+ const configuredRoot = typeof config.worktreeRoot === "string" ? config.worktreeRoot : ".worktrees/$REPO";
175
+ const worktreeRoot = path.resolve(repoRoot, expandWorktreeRoot(configuredRoot, repoRoot));
176
+ if (!sameOrInside(target.worktreePath, worktreeRoot)) return "explicit target is outside Guardian worktree root";
177
+ }
178
+ return null;
179
+ }
180
+
181
+ async function preflightFor(input: LooseRecord) {
182
+ const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
183
+ const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
184
+ const { config } = isRecordLike(input.config) ? { config: normalizeConfig(input.config) } : await loadConfig(repoRoot);
185
+ const sessionId = typeof input.sessionId === "string" ? input.sessionId : null;
186
+ const preflight: LooseRecord = {
187
+ repoRoot: path.resolve(repoRoot),
188
+ mode: input.mode,
189
+ action: typeof input.action === "string" ? input.action : "commit-review-artifacts",
190
+ sessionId,
191
+ sessionRecorded: false,
192
+ targetSource: null,
193
+ worktreePath: null,
194
+ branch: null,
195
+ head: null,
196
+ entries: [],
197
+ reviewArtifactPaths: [],
198
+ otherDirtyPaths: [],
199
+ blockers: [],
200
+ };
201
+ if (!sessionId) return { preflight, config, session: null, entries: [], reviewEntries: [], otherEntries: [], reason: "sessionId is required" };
202
+
203
+ const state = isUnblockStateInput(input.state) ? input.state : await readState(await getGuardianPaths(repoRoot), { repoRoot, config });
204
+ const session = state.sessions?.[sessionId] ?? null;
205
+ preflight.sessionRecorded = Boolean(session);
206
+ let target: ResolvedTarget | null = null;
207
+ if (session?.worktree_path) {
208
+ const resolved = await resolveListedWorktree(
209
+ repoRoot,
210
+ (worktree) => typeof session.worktree_path === "string" && samePath(worktree.path, session.worktree_path),
211
+ "state",
212
+ "recorded worktree is not checked out in git worktree list",
213
+ "recorded worktree path matches multiple git worktrees",
214
+ typeof session.branch === "string" ? session.branch : null,
215
+ );
216
+ target = resolved.target;
217
+ if (resolved.reason) return { preflight, config, session, entries: [], reviewEntries: [], otherEntries: [], reason: resolved.reason };
218
+ } else {
219
+ const resolved = await resolveExplicitTarget(repoRoot, input);
220
+ target = resolved.target;
221
+ if (resolved.reason) return { preflight, config, session, entries: [], reviewEntries: [], otherEntries: [], reason: resolved.reason };
222
+ }
223
+ if (!target) return { preflight, config, session, entries: [], reviewEntries: [], otherEntries: [], reason: "current session is not recorded in guardian state" };
224
+ const invalidTargetReason = validateResolvedTarget(repoRoot, config, target);
225
+ if (invalidTargetReason) return { preflight, config, session, entries: [], reviewEntries: [], otherEntries: [], reason: invalidTargetReason };
226
+ preflight.worktreePath = target.worktreePath;
227
+ preflight.targetSource = target.targetSource;
228
+ if (!await fs.access(target.worktreePath).then(() => true, () => false)) return { preflight, config, session, entries: [], reviewEntries: [], otherEntries: [], reason: "recorded worktree is missing" };
229
+
230
+ const branch = await getCurrentBranch(target.worktreePath);
231
+ const head = await getHeadCommit(target.worktreePath);
232
+ const entries = await statusEntries(target.worktreePath);
233
+ const reviewEntries = entries.filter((entry) => entry.classification === "review-artifact" && entry.index !== "D" && entry.worktree !== "D");
234
+ const otherEntries = entries.filter((entry) => !reviewEntries.includes(entry));
235
+ preflight.branch = branch;
236
+ preflight.head = head;
237
+ preflight.entries = entries;
238
+ preflight.reviewArtifactPaths = reviewEntries.map((entry) => entry.path);
239
+ preflight.otherDirtyPaths = otherEntries.map((entry) => entry.path);
240
+ return { preflight, config, session, entries, reviewEntries, otherEntries, reason: null };
241
+ }
242
+
243
+ export async function guardianUnblockFinish(input: LooseRecord = {}): Promise<GuardianUnblockFinishResult> {
244
+ const mode = input.mode;
245
+ const action = typeof input.action === "string" ? input.action : "commit-review-artifacts";
246
+ const { preflight, config, session, entries, reviewEntries, otherEntries, reason } = await preflightFor({ ...input, action });
247
+ if (mode !== "plan" && mode !== "apply") return blocked("mode must be plan or apply", { mode }, preflight);
248
+ if (action !== "commit-review-artifacts") return blocked("unsupported unblock action", { action }, preflight);
249
+ if (reason) return blocked(reason, {}, preflight);
250
+ if (entries.length === 0) return blocked("worktree is already clean", {}, preflight);
251
+ if (reviewEntries.length === 0) return blocked("no committable review artifacts found", {}, preflight);
252
+ if (otherEntries.length > 0) return blocked("dirty files include non-review artifacts", { otherDirtyPaths: otherEntries.map((entry: StatusEntry) => entry.path) }, preflight);
253
+
254
+ const confirmToken = createConfirmToken(preflight);
255
+ if (mode === "plan") {
256
+ return {
257
+ ok: true,
258
+ status: "planned",
259
+ action,
260
+ confirmToken,
261
+ commitMessage: defaultCommitMessage(reviewEntries),
262
+ preflight: { ...preflight, blockers: preflightBlockers(preflight) },
263
+ suggestedCommand: "guardian_unblock_finish mode=apply action=commit-review-artifacts confirmToken=<token>",
264
+ };
265
+ }
266
+
267
+ if (input.confirmToken !== confirmToken) return blocked("confirm token does not match current unblock plan", {}, preflight);
268
+ const worktreePath = String(preflight.worktreePath);
269
+ const branch = String(preflight.branch);
270
+ const head = String(preflight.head);
271
+ const sessionId = String(preflight.sessionId);
272
+ const safetyRef = await createSafetyRef(worktreePath, { sessionId, branch, commit: head, timestamp: input.timestamp });
273
+ const paths = reviewEntries.map((entry: StatusEntry) => entry.path);
274
+ await runGit(worktreePath, ["add", "--", ...paths]);
275
+ const commitMessage = typeof input.commitMessage === "string" && input.commitMessage.trim() ? input.commitMessage.trim() : defaultCommitMessage(reviewEntries);
276
+ await runGit(worktreePath, ["commit", "-m", commitMessage, "--", ...paths]);
277
+ const newHead = await getHeadCommit(worktreePath);
278
+ await recordSession(String(preflight.repoRoot), config, {
279
+ ...(session ?? {}),
280
+ session_id: sessionId,
281
+ status: session?.status ?? "active",
282
+ branch,
283
+ worktree_path: worktreePath,
284
+ base_ref: session?.base_ref ?? `${config.remote}/${config.baseBranch}`,
285
+ head_commit: newHead,
286
+ safety_refs: [...(session?.safety_refs ?? []), safetyRef],
287
+ }, { event: { type: "guardian_unblock_finish", session_id: sessionId, ref: safetyRef, action, paths } });
288
+ return {
289
+ ok: true,
290
+ status: "applied",
291
+ action,
292
+ committedPaths: paths,
293
+ commit: newHead,
294
+ commitMessage,
295
+ safetyRef,
296
+ preflight: { ...preflight, blockers: preflightBlockers(preflight) },
297
+ };
298
+ }
@@ -0,0 +1,111 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { expandWorktreeRoot } from "./config.ts";
4
+ import { guardianDeleteWorktree } from "./delete.ts";
5
+ import { getDirtyFiles, getRepoRoot, isAncestor, listBranches, listWorktrees } from "./git.ts";
6
+
7
+ export const MAX_WORKFLOW_CLEANUP_CANDIDATES = 25;
8
+
9
+ export function samePath(left: string, right: string): boolean {
10
+ return path.resolve(left) === path.resolve(right);
11
+ }
12
+
13
+ export function isInside(candidate: string, parent: string): boolean {
14
+ const relative = path.relative(parent, candidate);
15
+ return relative === "" || (Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative));
16
+ }
17
+
18
+ export function candidateTokenMaterial(candidate: Record<string, unknown>): Record<string, unknown> {
19
+ return {
20
+ kind: candidate.kind,
21
+ targetPath: candidate.targetPath ?? null,
22
+ branch: candidate.branch ?? null,
23
+ head: candidate.head ?? null,
24
+ targetKind: candidate.targetKind ?? null,
25
+ };
26
+ }
27
+
28
+ export function createWorkflowToken(preflight: Record<string, unknown>, candidates: readonly Record<string, unknown>[]): string {
29
+ const material = {
30
+ repoRoot: preflight.repoRoot,
31
+ baseRef: preflight.baseRef,
32
+ baseRefOid: preflight.baseRefOid,
33
+ candidates: candidates.map(candidateTokenMaterial),
34
+ };
35
+ return crypto.createHash("sha256").update(JSON.stringify(material)).digest("hex");
36
+ }
37
+
38
+ export function isGuardianWorktreeStatusPath(repoRoot: string, guardianRoot: string, statusPath: string): boolean {
39
+ const absoluteStatusPath = path.resolve(repoRoot, statusPath.replace(/\/$/, ""));
40
+ return isInside(absoluteStatusPath, guardianRoot);
41
+ }
42
+
43
+ export async function plannedCandidate(repoRoot: string, config: Record<string, unknown>, input: Record<string, unknown>): Promise<Record<string, unknown>> {
44
+ const plan = await guardianDeleteWorktree({ repoRoot, cwd: repoRoot, mode: "plan", deleteBranch: true, config, ...input });
45
+ if (!plan.ok) return { ok: false, reason: plan.reason, plan };
46
+ const preflight = plan.preflight as Record<string, unknown>;
47
+ return {
48
+ ok: true,
49
+ confirmToken: plan.confirmToken,
50
+ targetKind: preflight.targetKind,
51
+ targetPath: preflight.targetPath ?? null,
52
+ branch: preflight.branch ?? null,
53
+ head: preflight.head ?? null,
54
+ ancestryProven: preflight.ancestryProven,
55
+ plan,
56
+ };
57
+ }
58
+
59
+ export async function discoverCandidates(repoRoot: string, cwd: string, config: Record<string, unknown>, preflight: Record<string, unknown>): Promise<{ readonly candidates: Record<string, unknown>[]; readonly blockers: Record<string, unknown>[] }> {
60
+ const baseRef = `${String(config.remote)}/${String(config.baseBranch)}`;
61
+ const guardianRoot = path.resolve(repoRoot, expandWorktreeRoot(String(config.worktreeRoot), repoRoot));
62
+ const currentWorktree = await getRepoRoot(cwd);
63
+ const worktrees = await listWorktrees(repoRoot) as Array<{ path: string; branch?: string; head?: string }>;
64
+ const checkedOutBranches = new Set(worktrees.map((worktree) => worktree.branch).filter(Boolean));
65
+ const candidates: Record<string, unknown>[] = [];
66
+ const blockers: Record<string, unknown>[] = [];
67
+
68
+ for (const worktree of worktrees) {
69
+ if (samePath(worktree.path, repoRoot) || samePath(worktree.path, currentWorktree)) continue;
70
+ if (!isInside(path.resolve(worktree.path), guardianRoot)) continue;
71
+ if (!worktree.branch || !worktree.head) {
72
+ blockers.push({ kind: "worktree", targetPath: worktree.path, branch: worktree.branch ?? null, head: worktree.head ?? null, reason: "detached Guardian worktree cannot be cleaned by finish workflow" });
73
+ continue;
74
+ }
75
+ if ((config.protectedBranches as string[]).includes(worktree.branch)) {
76
+ blockers.push({ kind: "worktree", targetPath: worktree.path, branch: worktree.branch, head: worktree.head, reason: "protected branch worktree cannot be cleaned by finish workflow" });
77
+ continue;
78
+ }
79
+ const dirtyFiles = await getDirtyFiles(worktree.path);
80
+ if (dirtyFiles.length > 0) {
81
+ blockers.push({ kind: "worktree", targetPath: worktree.path, branch: worktree.branch, reason: "worktree has uncommitted changes", dirtyFileCount: dirtyFiles.length });
82
+ continue;
83
+ }
84
+ if (!(await isAncestor(repoRoot, worktree.head, baseRef))) {
85
+ blockers.push({ kind: "worktree", targetPath: worktree.path, branch: worktree.branch, head: worktree.head, reason: "worktree branch is not proven reachable from base ref" });
86
+ continue;
87
+ }
88
+ const candidate = await plannedCandidate(repoRoot, config, { targetPath: worktree.path });
89
+ if (candidate.ok) candidates.push({ kind: "worktree", ...candidate });
90
+ else blockers.push({ kind: "worktree", targetPath: worktree.path, branch: worktree.branch, reason: candidate.reason });
91
+ }
92
+
93
+ const branches = await listBranches(repoRoot) as Array<{ name: string; commit: string }>;
94
+ for (const branch of branches) {
95
+ if (!branch.name || !branch.commit) continue;
96
+ if (checkedOutBranches.has(branch.name)) continue;
97
+ if ((config.protectedBranches as string[]).includes(branch.name)) continue;
98
+ if (!(await isAncestor(repoRoot, branch.commit, baseRef))) continue;
99
+ const candidate = await plannedCandidate(repoRoot, config, { branch: branch.name });
100
+ if (candidate.ok) candidates.push({ kind: "branch", ...candidate });
101
+ }
102
+
103
+ if (candidates.length > MAX_WORKFLOW_CLEANUP_CANDIDATES) {
104
+ blockers.push({ kind: "candidate-bound", reason: `cleanup candidate count exceeds maximum ${MAX_WORKFLOW_CLEANUP_CANDIDATES}`, candidateCount: candidates.length, maxCandidateCount: MAX_WORKFLOW_CLEANUP_CANDIDATES });
105
+ }
106
+
107
+ preflight.candidateCount = candidates.length;
108
+ preflight.blockerCount = blockers.length;
109
+ preflight.maxCandidateCount = MAX_WORKFLOW_CLEANUP_CANDIDATES;
110
+ return { candidates, blockers };
111
+ }
@@ -0,0 +1,84 @@
1
+ import path from "node:path";
2
+ import { expandWorktreeRoot, loadConfig } from "./config.ts";
3
+ import { guardianDeleteWorktree } from "./delete.ts";
4
+ import { fetchRemote, getCurrentBranch, getDirtyFiles, getRefCommit, getRepoRoot, listStashes } from "./git.ts";
5
+ import { candidateTokenMaterial, createWorkflowToken, discoverCandidates, isGuardianWorktreeStatusPath } from "./workflow-candidates.ts";
6
+
7
+ function blocked(reason: string, details: Record<string, unknown> = {}, preflight?: Record<string, unknown>): Record<string, unknown> {
8
+ if (preflight) preflight.blockers = [...((preflight.blockers as string[] | undefined) ?? []), reason];
9
+ return { ok: false, status: "blocked", reason, ...details, ...(preflight ? { preflight } : {}) };
10
+ }
11
+
12
+ export async function guardianFinishWorkflow(input: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
13
+ const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
14
+ const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
15
+ const { config } = input.config && typeof input.config === "object" ? { config: input.config as Record<string, unknown> } : await loadConfig(repoRoot);
16
+ const mode = input.mode ?? "plan";
17
+ const baseRef = `${String(config.remote)}/${String(config.baseBranch)}`;
18
+ const guardianRoot = path.resolve(repoRoot, expandWorktreeRoot(String(config.worktreeRoot), repoRoot));
19
+ const preflight: Record<string, unknown> = {
20
+ repoRoot: path.resolve(repoRoot),
21
+ mode,
22
+ remote: config.remote,
23
+ baseBranch: config.baseBranch,
24
+ baseRef,
25
+ baseRefOid: null,
26
+ baseRefFetched: false,
27
+ currentBranch: null,
28
+ dirtyFiles: [],
29
+ dirtyFileCount: 0,
30
+ stashCount: 0,
31
+ candidateCount: 0,
32
+ blockerCount: 0,
33
+ blockers: [],
34
+ };
35
+
36
+ if (mode !== "plan" && mode !== "apply") return blocked("mode must be plan or apply", { mode }, preflight);
37
+
38
+ try {
39
+ await fetchRemote(repoRoot, String(config.remote));
40
+ preflight.baseRefFetched = true;
41
+ preflight.baseRefOid = await getRefCommit(repoRoot, baseRef);
42
+ } catch (error) {
43
+ return blocked("remote base ref could not be fetched or resolved", { baseRef, error: error instanceof Error ? error.message : String(error) }, preflight);
44
+ }
45
+
46
+ preflight.currentBranch = await getCurrentBranch(repoRoot);
47
+ const dirtyFiles = await getDirtyFiles(repoRoot);
48
+ const blockingDirtyFiles = dirtyFiles.filter((file) => !isGuardianWorktreeStatusPath(repoRoot, guardianRoot, file));
49
+ const ignoredGuardianWorktreeFiles = dirtyFiles.filter((file) => isGuardianWorktreeStatusPath(repoRoot, guardianRoot, file));
50
+ preflight.dirtyFiles = dirtyFiles;
51
+ preflight.dirtyFileCount = dirtyFiles.length;
52
+ preflight.blockingDirtyFiles = blockingDirtyFiles;
53
+ preflight.blockingDirtyFileCount = blockingDirtyFiles.length;
54
+ preflight.ignoredGuardianWorktreeFiles = ignoredGuardianWorktreeFiles;
55
+ preflight.ignoredGuardianWorktreeFileCount = ignoredGuardianWorktreeFiles.length;
56
+ if (blockingDirtyFiles.length > 0) return blocked("primary worktree has uncommitted changes; commit implemented code before finish workflow cleanup", { dirtyFiles: blockingDirtyFiles }, preflight);
57
+
58
+ const stashes = await listStashes(repoRoot);
59
+ preflight.stashCount = stashes.length;
60
+ if (stashes.length > 0 && config.allowStashIfUnrelated !== true) return blocked("stash inventory is non-empty", { stashes }, preflight);
61
+
62
+ const { candidates, blockers } = await discoverCandidates(repoRoot, cwd, config, preflight);
63
+ if (blockers.length > 0) return blocked("cleanup blockers must be resolved before apply", { candidates, blockers }, preflight);
64
+ const confirmToken = createWorkflowToken(preflight, candidates);
65
+ if (mode === "plan") return { ok: true, status: "planned", confirmToken, preflight, candidates, blockers };
66
+ if (input.confirmToken !== confirmToken) return blocked("confirm token mismatch; re-run mode=plan and use the returned confirmToken", { tokenMatched: false, candidates, blockers }, preflight);
67
+
68
+ const results = [];
69
+ for (const candidate of candidates) {
70
+ const targetKind = typeof candidate.targetKind === "string" ? candidate.targetKind : undefined;
71
+ const targetPath = targetKind === "worktree" && typeof candidate.targetPath === "string" ? candidate.targetPath : undefined;
72
+ const branch = targetKind !== "worktree" && typeof candidate.branch === "string" ? candidate.branch : undefined;
73
+ const plan = await guardianDeleteWorktree({ repoRoot, cwd: repoRoot, mode: "plan", targetPath, branch, deleteBranch: true, config });
74
+ if (!plan.ok) {
75
+ results.push({ ...candidateTokenMaterial(candidate), ok: false, status: "blocked", reason: plan.reason });
76
+ continue;
77
+ }
78
+ const apply = await guardianDeleteWorktree({ repoRoot, cwd: repoRoot, mode: "apply", targetPath, branch, deleteBranch: true, confirmToken: plan.confirmToken, config });
79
+ results.push({ ...candidateTokenMaterial(candidate), ok: apply.ok, status: apply.status, reason: apply.reason, worktreeRemoved: apply.worktreeRemoved, branchDeleted: apply.branchDeleted, safetyRef: apply.safetyRef });
80
+ }
81
+
82
+ const failedResults = results.filter((result) => result.ok !== true);
83
+ return { ok: failedResults.length === 0, status: failedResults.length === 0 ? "cleaned" : "partial", preflight, candidates, blockers, results };
84
+ }