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,222 @@
1
+ import path from "node:path";
2
+ import { expandWorktreeRoot, loadConfig } from "./config.ts";
3
+ import { abandonBranch, createSafetyRef, deleteBranch, getBranchCommit, getDirtyFiles, getHeadCommit, getIgnoredFiles, getRepoRoot, listStashes, listWorktrees, removeWorktree } from "./git.ts";
4
+ import { isSameOrInside, samePath } from "./filesystem-boundaries.ts";
5
+ import { getGuardianPaths, readState, recordSession } from "./state.ts";
6
+ import { blocked, createConfirmToken, errorMessage, withDeleteReport } from "./delete-worktree-report.ts";
7
+ import { collectIgnoredFileFingerprint, recordAncestryPreflight } from "./delete-worktree-preflight.ts";
8
+ import { findTarget } from "./delete-worktree-targets.ts";
9
+ import type { GuardianSession, WorktreeEntry } from "./types.ts";
10
+
11
+ function emptyDeletePreflight(repoRoot: string, mode: unknown, deleteRequestedBranch: boolean, abandonUnmerged: boolean, allowIgnoredFiles: boolean): Record<string, unknown> {
12
+ return {
13
+ repoRoot: path.resolve(repoRoot),
14
+ mode,
15
+ targetKind: null,
16
+ targetPath: null,
17
+ worktreeListed: null,
18
+ branch: null,
19
+ head: null,
20
+ detached: false,
21
+ sessionId: null,
22
+ sessionStatus: "unrecorded",
23
+ sessionRecorded: false,
24
+ deleteBranch: deleteRequestedBranch,
25
+ abandonUnmerged,
26
+ ancestryRef: null,
27
+ ancestryProven: null,
28
+ unmergedCommits: [],
29
+ unmergedCommitCount: 0,
30
+ allowIgnoredFiles,
31
+ dirtyFiles: [],
32
+ dirtyFileCount: 0,
33
+ ignoredFiles: [],
34
+ ignoredFileCount: 0,
35
+ stashCount: 0,
36
+ safetyRef: null,
37
+ blockers: [],
38
+ };
39
+ }
40
+
41
+ async function rejectInvalidDeleteRequest(input: Record<string, unknown>, config: Record<string, unknown>, preflight: Record<string, unknown>) {
42
+ const mode = input.mode;
43
+ if (mode !== "plan" && mode !== "apply") return blocked("mode must be plan or apply", { mode }, preflight);
44
+ if (input.abandonUnmerged === true && input.deleteBranch !== true) return blocked("abandonUnmerged requires deleteBranch=true", {}, preflight);
45
+ if (typeof input.branch === "string" && (config.protectedBranches as string[]).includes(input.branch)) {
46
+ preflight.branch = input.branch;
47
+ return blocked("protected branches cannot be deleted by guardian_delete_worktree", { branch: input.branch }, preflight);
48
+ }
49
+ return null;
50
+ }
51
+
52
+ async function loadDeleteContext(input: Record<string, unknown>, repoRoot: string, config: Record<string, unknown>) {
53
+ const guardianPaths = await getGuardianPaths(repoRoot);
54
+ const state = input.state && typeof input.state === "object" ? input.state as { sessions?: Record<string, GuardianSession> } : await readState(guardianPaths, { repoRoot, config });
55
+ const sessions = Object.values(state.sessions ?? {});
56
+ const worktrees = await listWorktrees(repoRoot) as WorktreeEntry[];
57
+ return { sessions, worktrees };
58
+ }
59
+
60
+ async function preflightBranchOnlyDeletion(input: Record<string, unknown>, config: Record<string, unknown>, preflight: Record<string, unknown>, worktrees: WorktreeEntry[], targetKind: "orphan-branch" | "stale-branch" | "merged-branch", session: GuardianSession | undefined, resolvedBranch: string | undefined, resolvedHead: string | undefined, ownershipProof: string | undefined, unresolvedReason: string) {
61
+ const repoRoot = String(preflight.repoRoot);
62
+ const deleteRequestedBranch = input.deleteBranch === true;
63
+ const abandonUnmerged = input.abandonUnmerged === true;
64
+ const branch = String(resolvedBranch ?? session?.branch ?? "");
65
+ preflight.targetPath = session?.worktree_path ? path.resolve(String(session.worktree_path)) : null;
66
+ preflight.branch = branch;
67
+ preflight.detached = false;
68
+ preflight.ownershipProof = ownershipProof ?? (targetKind === "orphan-branch" ? "active-session" : targetKind === "merged-branch" ? "ancestry-proof" : null);
69
+ if (targetKind === "stale-branch" && !ownershipProof) return blocked(unresolvedReason, { branch }, preflight);
70
+ if (!deleteRequestedBranch) return blocked(`${targetKind} cleanup requires deleteBranch=true`, { branch }, preflight);
71
+ if ((config.protectedBranches as string[]).includes(branch)) return blocked("protected branches cannot be deleted by guardian_delete_worktree", { branch }, preflight);
72
+ const checkedOut = worktrees.find((worktree) => worktree.branch === branch);
73
+ preflight.branchCheckedOut = Boolean(checkedOut);
74
+ if (checkedOut) return blocked("branch is checked out in a git worktree", { branch, targetPath: checkedOut.path }, preflight);
75
+ let head = resolvedHead;
76
+ try {
77
+ head = head ?? await getBranchCommit(repoRoot, branch);
78
+ } catch {
79
+ return blocked("branch does not exist", { branch }, preflight);
80
+ }
81
+ preflight.head = head;
82
+ const stashes = await listStashes(repoRoot);
83
+ preflight.stashCount = stashes.length;
84
+ if (stashes.length > 0 && config.allowStashIfUnrelated !== true) return blocked("stash inventory is non-empty", { stashes }, preflight);
85
+ const baseRef = session?.base_ref ?? `${String(config.remote)}/${String(config.baseBranch)}`;
86
+ const proven = await recordAncestryPreflight(repoRoot, head, baseRef, preflight);
87
+ if (!proven && abandonUnmerged && preflight.unmergedCommitError) return blocked("unmerged commits could not be listed", { branch, head, baseRef, error: preflight.unmergedCommitError }, preflight);
88
+ if (!proven && !abandonUnmerged) return blocked("branch head is not proven reachable from base ref", { branch, head, baseRef }, preflight);
89
+ const confirmToken = createConfirmToken(preflight);
90
+ if (input.mode === "plan") return withDeleteReport({ ok: true, status: "planned", confirmToken }, preflight, { action: "planned" });
91
+ if (input.confirmToken !== confirmToken) return blocked("confirm token mismatch; re-run mode=plan and use the returned confirmToken", { tokenMatched: false }, preflight);
92
+ return applyBranchOnlyDeletion(input, config, preflight, session, targetKind, branch, head, proven, abandonUnmerged);
93
+ }
94
+
95
+ async function applyBranchOnlyDeletion(input: Record<string, unknown>, config: Record<string, unknown>, preflight: Record<string, unknown>, session: GuardianSession | undefined, targetKind: "orphan-branch" | "stale-branch" | "merged-branch", branch: string, head: string, proven: boolean, abandonUnmerged: boolean) {
96
+ const repoRoot = String(preflight.repoRoot);
97
+ const safetyRef = await createSafetyRef(repoRoot, { sessionId: session?.session_id ?? (targetKind === "merged-branch" ? "merged-local-branch" : "orphan-guardian-branch"), branch, commit: head, timestamp: input.timestamp });
98
+ preflight.safetyRef = safetyRef;
99
+ if (!proven && abandonUnmerged) await abandonBranch(repoRoot, branch);
100
+ else await deleteBranch(repoRoot, branch);
101
+ if (session?.session_id) {
102
+ await recordSession(repoRoot, config, {
103
+ ...session,
104
+ session_id: session.session_id,
105
+ status: !proven && abandonUnmerged ? "abandoned" : "deleted",
106
+ head_commit: head,
107
+ safety_refs: [...(session.safety_refs ?? []), safetyRef],
108
+ deleted_worktree_path: session.worktree_path,
109
+ deleted_branch: branch,
110
+ branch_only_delete: true,
111
+ abandon_unmerged: !proven && abandonUnmerged,
112
+ abandoned_branch: !proven && abandonUnmerged ? branch : undefined,
113
+ unmerged_commits: !proven && abandonUnmerged ? preflight.unmergedCommits : undefined,
114
+ }, { event: { type: targetKind === "stale-branch" ? "guardian_delete_stale_branch" : "guardian_delete_orphan_branch", session_id: session.session_id, ref: safetyRef } });
115
+ }
116
+ const actionPrefix = targetKind === "stale-branch" ? "stale-branch" : targetKind === "merged-branch" ? "merged-branch" : "orphan-branch";
117
+ return withDeleteReport({ ok: true, status: !proven && abandonUnmerged ? "abandoned" : "deleted", targetPath: session?.worktree_path ?? null, branch, head, safetyRef, branchDeleted: true, worktreeRemoved: false, abandonUnmerged: !proven && abandonUnmerged }, preflight, { action: !proven && abandonUnmerged ? `${actionPrefix}-abandoned` : `${actionPrefix}-deleted`, worktreeRemoved: false });
118
+ }
119
+
120
+ async function preflightWorktreeDeletion(input: Record<string, unknown>, config: Record<string, unknown>, preflight: Record<string, unknown>, entry: WorktreeEntry, session: GuardianSession | undefined, cwd: string) {
121
+ const repoRoot = String(preflight.repoRoot);
122
+ const deleteRequestedBranch = input.deleteBranch === true;
123
+ const abandonUnmerged = input.abandonUnmerged === true;
124
+ const allowIgnoredFiles = input.allowIgnoredFiles === true;
125
+ preflight.targetPath = path.resolve(entry.path);
126
+ preflight.worktreeListed = true;
127
+ preflight.branch = entry.branch ?? null;
128
+ preflight.head = entry.head ?? null;
129
+ preflight.detached = entry.detached === true || !entry.branch;
130
+ if (samePath(entry.path, repoRoot)) return blocked("refusing to delete the primary repo worktree", { targetPath: entry.path }, preflight);
131
+ const currentWorktree = await getRepoRoot(cwd);
132
+ preflight.currentWorktree = currentWorktree;
133
+ if (samePath(entry.path, currentWorktree)) return blocked("refusing to delete the current execution worktree", { targetPath: entry.path, currentWorktree }, preflight);
134
+ if (entry.detached || !entry.branch) return blocked("detached HEAD worktrees cannot be deleted by guardian_delete_worktree", { targetPath: entry.path }, preflight);
135
+ if ((config.protectedBranches as string[]).includes(entry.branch)) return blocked("protected branches cannot be deleted by guardian_delete_worktree", { branch: entry.branch }, preflight);
136
+ const guardianRoot = path.resolve(repoRoot, expandWorktreeRoot(String(config.worktreeRoot), repoRoot));
137
+ if (!session && !isSameOrInside(path.resolve(entry.path), guardianRoot)) return blocked("unrecorded worktrees outside the Guardian worktree root cannot be deleted", { targetPath: entry.path, guardianRoot }, preflight);
138
+ const dirtyFiles = await getDirtyFiles(entry.path);
139
+ preflight.dirtyFiles = dirtyFiles;
140
+ preflight.dirtyFileCount = dirtyFiles.length;
141
+ if (dirtyFiles.length > 0) return blocked("worktree has uncommitted changes", { dirtyFiles, targetPath: entry.path }, preflight);
142
+ const ignoredFiles = await getIgnoredFiles(entry.path);
143
+ preflight.ignoredFiles = ignoredFiles;
144
+ preflight.ignoredFileFingerprint = await collectIgnoredFileFingerprint(entry.path, ignoredFiles);
145
+ preflight.ignoredFileCount = ignoredFiles.length;
146
+ if (ignoredFiles.length > 0 && !allowIgnoredFiles) return blocked("worktree has ignored files", { ignoredFiles, targetPath: entry.path }, preflight);
147
+ const stashes = await listStashes(repoRoot);
148
+ preflight.stashCount = stashes.length;
149
+ if (stashes.length > 0 && config.allowStashIfUnrelated !== true) return blocked("stash inventory is non-empty", { stashes }, preflight);
150
+ if (deleteRequestedBranch) {
151
+ const head = entry.head ?? await getHeadCommit(entry.path);
152
+ preflight.head = head;
153
+ const baseRef = session?.base_ref ?? `${String(config.remote)}/${String(config.baseBranch)}`;
154
+ const proven = await recordAncestryPreflight(repoRoot, head, baseRef, preflight);
155
+ if (!proven && abandonUnmerged && preflight.unmergedCommitError) return blocked("unmerged commits could not be listed", { branch: entry.branch, head, baseRef, error: preflight.unmergedCommitError }, preflight);
156
+ if (!proven && !abandonUnmerged) return blocked("branch head is not proven reachable from base ref", { branch: entry.branch, head, baseRef }, preflight);
157
+ }
158
+ const confirmToken = createConfirmToken(preflight);
159
+ if (input.mode === "plan") return withDeleteReport({ ok: true, status: "planned", confirmToken }, preflight, { action: "planned" });
160
+ if (input.confirmToken !== confirmToken) return blocked("confirm token mismatch; re-run mode=plan and use the returned confirmToken", { tokenMatched: false }, preflight);
161
+ return applyWorktreeDeletion(input, config, preflight, entry, session);
162
+ }
163
+
164
+ async function applyWorktreeDeletion(input: Record<string, unknown>, config: Record<string, unknown>, preflight: Record<string, unknown>, entry: WorktreeEntry, session: GuardianSession | undefined) {
165
+ const repoRoot = String(preflight.repoRoot);
166
+ const deleteRequestedBranch = input.deleteBranch === true;
167
+ const abandonUnmerged = input.abandonUnmerged === true;
168
+ const branch = entry.branch;
169
+ if (!branch) return blocked("detached HEAD worktrees cannot be deleted by guardian_delete_worktree", { targetPath: entry.path }, preflight);
170
+ const safetySessionId = session?.session_id ?? "unrecorded-worktree";
171
+ const head = String(preflight.head ?? await getHeadCommit(entry.path));
172
+ const safetyRef = await createSafetyRef(repoRoot, { sessionId: safetySessionId, branch, commit: head, timestamp: input.timestamp });
173
+ preflight.safetyRef = safetyRef;
174
+ await removeWorktree(repoRoot, entry.path);
175
+ let branchDeleted = false;
176
+ if (deleteRequestedBranch) {
177
+ try {
178
+ if (preflight.ancestryProven === false && abandonUnmerged) await abandonBranch(repoRoot, branch);
179
+ else await deleteBranch(repoRoot, branch);
180
+ branchDeleted = true;
181
+ } catch (error) {
182
+ return recordPartialWorktreeDeletion(repoRoot, config, preflight, entry, session, head, safetyRef, abandonUnmerged, error);
183
+ }
184
+ }
185
+ if (session?.session_id) {
186
+ const abandoned = preflight.ancestryProven === false && abandonUnmerged;
187
+ await recordSession(repoRoot, config, { ...session, session_id: session.session_id, status: abandoned ? "abandoned" : "deleted", head_commit: head, safety_refs: [...(session.safety_refs ?? []), safetyRef], deleted_worktree_path: entry.path, deleted_branch: branchDeleted ? branch : null, abandon_unmerged: abandoned, abandoned_branch: abandoned ? branch : undefined, unmerged_commits: abandoned ? preflight.unmergedCommits : undefined }, { event: { type: "guardian_delete_worktree", session_id: session.session_id, ref: safetyRef } });
188
+ }
189
+ const abandoned = preflight.ancestryProven === false && abandonUnmerged;
190
+ return withDeleteReport({ ok: true, status: abandoned ? "abandoned" : "deleted", targetPath: entry.path, branch, head, safetyRef, branchDeleted, worktreeRemoved: true, abandonUnmerged: abandoned }, preflight, { action: abandoned ? "worktree-and-branch-abandoned" : branchDeleted ? "worktree-and-branch-deleted" : "worktree-deleted", worktreeRemoved: true });
191
+ }
192
+
193
+ async function recordPartialWorktreeDeletion(repoRoot: string, config: Record<string, unknown>, preflight: Record<string, unknown>, entry: WorktreeEntry, session: GuardianSession | undefined, head: string, safetyRef: string, abandonUnmerged: boolean, error: unknown) {
194
+ const branchDeleteError = errorMessage(error);
195
+ if (session?.session_id) {
196
+ await recordSession(repoRoot, config, { ...session, session_id: session.session_id, status: "deleted", head_commit: head, safety_refs: [...(session.safety_refs ?? []), safetyRef], deleted_worktree_path: entry.path, deleted_branch: null, branch_delete_failed: true, branch_delete_error: branchDeleteError, abandon_unmerged: preflight.ancestryProven === false && abandonUnmerged, unmerged_commits: preflight.ancestryProven === false && abandonUnmerged ? preflight.unmergedCommits : undefined }, { event: { type: "guardian_delete_worktree_partial", session_id: session.session_id, ref: safetyRef } });
197
+ }
198
+ return withDeleteReport({ ok: false, status: "partial", reason: "worktree deleted but branch deletion failed", targetPath: entry.path, branch: entry.branch, head, safetyRef, branchDeleted: false, worktreeRemoved: true, error: branchDeleteError }, preflight, { action: "worktree-deleted-branch-delete-failed", worktreeRemoved: true, branchDeleteError });
199
+ }
200
+
201
+ export async function guardianDeleteWorktree(input: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
202
+ const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
203
+ const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
204
+ const { config } = input.config && typeof input.config === "object" ? { config: input.config as Record<string, unknown> } : await loadConfig(repoRoot);
205
+ const preflight = emptyDeletePreflight(repoRoot, input.mode, input.deleteBranch === true, input.abandonUnmerged === true, input.allowIgnoredFiles === true);
206
+ const invalid = await rejectInvalidDeleteRequest(input, config, preflight);
207
+ if (invalid) return invalid;
208
+ const { sessions, worktrees } = await loadDeleteContext(input, repoRoot, config);
209
+ const target = await findTarget({ ...input, repoRoot }, worktrees, sessions);
210
+ const { entry, session, targetKind, branch: resolvedBranch, head: resolvedHead, ownershipProof, unresolvedReason } = target;
211
+ if (!entry && targetKind !== "orphan-branch" && targetKind !== "stale-branch" && targetKind !== "merged-branch") return blocked(unresolvedReason, {}, preflight);
212
+ preflight.targetKind = targetKind ?? "worktree";
213
+ preflight.worktreeListed = Boolean(entry);
214
+ preflight.sessionId = session?.session_id ?? null;
215
+ preflight.sessionStatus = session?.status ?? "unrecorded";
216
+ preflight.sessionRecorded = Boolean(session);
217
+ if (targetKind === "orphan-branch" || targetKind === "stale-branch" || targetKind === "merged-branch") {
218
+ return preflightBranchOnlyDeletion(input, config, preflight, worktrees, targetKind, session, resolvedBranch, resolvedHead, ownershipProof, unresolvedReason);
219
+ }
220
+ if (!entry) return blocked(unresolvedReason, {}, preflight);
221
+ return preflightWorktreeDeletion(input, config, preflight, entry, session, cwd);
222
+ }
package/src/delete.ts ADDED
@@ -0,0 +1 @@
1
+ export { guardianDeleteWorktree } from "./delete-worktree.ts";
@@ -0,0 +1,59 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { relativePath } from "./filesystem-boundaries.ts";
5
+
6
+ export type DeletionFingerprintEntry = Record<string, string | number>;
7
+
8
+ async function collectFilesystemFingerprint(repoRoot: string, absolutePath: string) {
9
+ const entries: DeletionFingerprintEntry[] = [];
10
+ async function visit(currentAbsolute: string) {
11
+ const stat = await fs.lstat(currentAbsolute);
12
+ const currentRelative = relativePath(repoRoot, currentAbsolute);
13
+ if (stat.isSymbolicLink()) {
14
+ entries.push({ path: currentRelative, kind: "symlink", target: await fs.readlink(currentAbsolute) });
15
+ return;
16
+ }
17
+ if (stat.isDirectory()) {
18
+ entries.push({ path: currentRelative, kind: "directory" });
19
+ const children = await fs.readdir(currentAbsolute);
20
+ for (const child of children.sort((left, right) => left.localeCompare(right))) await visit(path.join(currentAbsolute, child));
21
+ return;
22
+ }
23
+ if (stat.isFile()) {
24
+ const content = await fs.readFile(currentAbsolute);
25
+ entries.push({ path: currentRelative, kind: "file", size: stat.size, sha256: crypto.createHash("sha256").update(content).digest("hex") });
26
+ return;
27
+ }
28
+ entries.push({ path: currentRelative, kind: "other", size: stat.size });
29
+ }
30
+ await visit(absolutePath);
31
+ return entries;
32
+ }
33
+
34
+ export function collectCleanupFingerprint(repoRoot: string, absolutePath: string) {
35
+ return collectFilesystemFingerprint(repoRoot, absolutePath);
36
+ }
37
+
38
+ export function collectDeleteFingerprint(repoRoot: string, absolutePath: string) {
39
+ return collectFilesystemFingerprint(repoRoot, absolutePath);
40
+ }
41
+
42
+ export async function collectIgnoredFileFingerprint(worktreePath: string, ignoredFiles: string[]) {
43
+ const entries = new Set<string>();
44
+ async function addEntry(relativePath: string) {
45
+ const normalized = relativePath.replace(/\/+/g, "/");
46
+ entries.add(normalized);
47
+ if (!normalized.endsWith("/")) return;
48
+ const absoluteDir = path.join(worktreePath, normalized);
49
+ let children: string[] = [];
50
+ try {
51
+ children = await fs.readdir(absoluteDir);
52
+ } catch {
53
+ return;
54
+ }
55
+ for (const child of children) await addEntry(`${normalized}${child}${(await fs.stat(path.join(absoluteDir, child))).isDirectory() ? "/" : ""}`);
56
+ }
57
+ for (const ignoredFile of ignoredFiles) await addEntry(ignoredFile);
58
+ return [...entries].sort();
59
+ }
@@ -0,0 +1,129 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { createSafetyRef, fetchRemote, getCurrentBranch, getHeadCommit, getRefCommit, getRepoRoot, isAncestor, listStashes, runGit } from "./git.ts";
4
+ import { guardianFinishWorkflow } from "./workflow.ts";
5
+ import type { GuardianConfig, MutableRecord } from "./types.ts";
6
+ import type { DirtySnapshot } from "./done-primary-snapshot.ts";
7
+ import { dirtySnapshot } from "./done-primary-snapshot.ts";
8
+ import { blocked, errorMessage, samePath, text } from "./done-shared.ts";
9
+
10
+ type PrimaryPreflightResult =
11
+ | { readonly ok: false; readonly preflight: MutableRecord; readonly result: MutableRecord }
12
+ | { readonly ok: true; readonly preflight: MutableRecord; readonly snapshot: DirtySnapshot; readonly commitMessage: string; readonly confirmToken: string };
13
+
14
+ export function createPrimaryToken(preflight: Record<string, unknown>, snapshot: Record<string, unknown>, commitMessage: string): string {
15
+ const material = {
16
+ repoRoot: preflight.repoRoot,
17
+ branch: preflight.currentBranch,
18
+ head: preflight.head,
19
+ baseRef: preflight.baseRef,
20
+ baseRefOid: preflight.baseRefOid,
21
+ commitMessage,
22
+ snapshot,
23
+ };
24
+ return crypto.createHash("sha256").update(JSON.stringify(material)).digest("hex");
25
+ }
26
+
27
+ export async function primaryPreflight(repoRoot: string, cwd: string, config: GuardianConfig, input: Record<string, unknown>): Promise<PrimaryPreflightResult> {
28
+ const currentWorktree = await getRepoRoot(cwd);
29
+ const branch = await getCurrentBranch(currentWorktree);
30
+ const baseRef = `${String(config.remote)}/${String(config.baseBranch)}`;
31
+ const preflight: MutableRecord = {
32
+ repoRoot: path.resolve(repoRoot),
33
+ currentWorktree,
34
+ currentBranch: branch,
35
+ baseBranch: config.baseBranch,
36
+ remote: config.remote,
37
+ baseRef,
38
+ baseRefOid: null,
39
+ baseRefFetched: false,
40
+ head: null,
41
+ branchProtected: Array.isArray(config.protectedBranches) && typeof branch === "string" ? config.protectedBranches.includes(branch) : false,
42
+ stashCount: 0,
43
+ dirtyFileCount: 0,
44
+ dirtyFiles: [],
45
+ blockers: [],
46
+ };
47
+
48
+ if (!samePath(currentWorktree, repoRoot)) return { ok: false, preflight, result: blocked("primary-main publish requires the primary repository worktree", { currentWorktree, repoRoot }, preflight) };
49
+ if (!branch) return { ok: false, preflight, result: blocked("detached HEAD cannot be published by guardian_done", {}, preflight) };
50
+ if (branch !== config.baseBranch) return { ok: false, preflight, result: blocked("primary-main publish requires the configured base branch", { branch, baseBranch: config.baseBranch }, preflight) };
51
+ if (!preflight.branchProtected) return { ok: false, preflight, result: blocked("primary-main publish lane requires a protected base branch", { branch }, preflight) };
52
+
53
+ try {
54
+ await fetchRemote(repoRoot, String(config.remote));
55
+ preflight.baseRefFetched = true;
56
+ preflight.baseRefOid = await getRefCommit(repoRoot, baseRef);
57
+ } catch (error) {
58
+ return { ok: false, preflight, result: blocked("remote base ref could not be fetched or resolved", { baseRef, error: errorMessage(error) }, preflight) };
59
+ }
60
+
61
+ const head = await getHeadCommit(repoRoot);
62
+ preflight.head = head;
63
+ if (head !== preflight.baseRefOid) return { ok: false, preflight, result: blocked("primary base branch is not synced to remote; sync before publishing dirty primary work", { head, baseRefOid: preflight.baseRefOid }, preflight) };
64
+
65
+ const stashes = await listStashes(repoRoot);
66
+ preflight.stashCount = stashes.length;
67
+ if (stashes.length > 0 && config.allowStashIfUnrelated !== true) return { ok: false, preflight, result: blocked("stash inventory is non-empty", { stashes }, preflight) };
68
+
69
+ const snapshot = await dirtySnapshot(repoRoot, config);
70
+ preflight.dirtyFiles = snapshot.paths;
71
+ preflight.dirtyFileCount = snapshot.paths.length;
72
+ if (snapshot.paths.length === 0) return { ok: false, preflight, result: blocked("primary-main publish requires dirty implemented code; use cleanup-only lane instead", {}, preflight) };
73
+
74
+ const commitMessage = text(input.commitMessage).trim();
75
+ if (!commitMessage) return { ok: false, preflight, result: blocked("commitMessage is required for primary-main publish", { dirtyFiles: snapshot.paths }, preflight) };
76
+
77
+ const confirmToken = createPrimaryToken(preflight, snapshot, commitMessage);
78
+ return { ok: true, preflight, snapshot, commitMessage, confirmToken };
79
+ }
80
+
81
+ export async function primaryMainDone(repoRoot: string, cwd: string, config: GuardianConfig, input: Record<string, unknown>): Promise<MutableRecord> {
82
+ const mode = input.mode ?? "plan";
83
+ const planned = await primaryPreflight(repoRoot, cwd, config, input);
84
+ if (!planned.ok) return planned.result;
85
+ const { preflight, snapshot, commitMessage, confirmToken } = planned;
86
+ const plan = {
87
+ ok: true,
88
+ status: "planned",
89
+ lane: "primary-main-publish",
90
+ confirmToken,
91
+ commitMessage,
92
+ preflight,
93
+ dirtySnapshot: snapshot,
94
+ nextAction: "After explicit user confirmation, apply with confirm=true and the same commitMessage to create a safety ref, commit the token-bound dirty files, push the base branch, fetch, and return a fresh cleanup plan.",
95
+ };
96
+ if (mode === "plan") return plan;
97
+ if (mode !== "apply") return blocked("mode must be plan or apply", { mode }, preflight);
98
+ if (input.confirmToken !== confirmToken) return blocked("plan changed; rerun plan and review the updated dirty files before applying", { tokenMatched: false }, preflight);
99
+
100
+ const branch = String(preflight.currentBranch);
101
+ const head = String(preflight.head);
102
+ const safetySessionId = typeof input.sessionId === "string" && input.sessionId.length > 0 ? input.sessionId : "primary-main";
103
+ const safetyRef = await createSafetyRef(repoRoot, { sessionId: safetySessionId, branch, commit: head, timestamp: input.timestamp });
104
+ preflight.safetyRef = safetyRef;
105
+ const dirtyPaths = snapshot.paths;
106
+ const snapshotEntries = snapshot.entries;
107
+ const missingPaths = new Set(snapshot.fingerprints.filter((fingerprint) => fingerprint.kind === "missing").map((fingerprint) => fingerprint.path));
108
+ const unstagedDeletedPaths = new Set(snapshotEntries.filter((entry) => entry.status[1] === "D").flatMap((entry) => [entry.path, entry.sourcePath].filter((value): value is string => typeof value === "string")));
109
+ const stageablePaths = dirtyPaths.filter((dirtyPath) => !missingPaths.has(dirtyPath) || unstagedDeletedPaths.has(dirtyPath));
110
+ try {
111
+ if (stageablePaths.length > 0) await runGit(repoRoot, ["add", "--all", "--", ...stageablePaths]);
112
+ await runGit(repoRoot, ["commit", "-m", commitMessage]);
113
+ } catch (error) {
114
+ return blocked("commit failed", { safetyRef, error: errorMessage(error) }, preflight);
115
+ }
116
+
117
+ const commit = await getHeadCommit(repoRoot);
118
+ try {
119
+ await runGit(repoRoot, ["push", String(config.remote), branch]);
120
+ await fetchRemote(repoRoot, String(config.remote));
121
+ } catch (error) {
122
+ return blocked("push failed after commit; safety ref preserves the pre-commit head", { safetyRef, commit, error: errorMessage(error) }, preflight);
123
+ }
124
+
125
+ const proven = await isAncestor(repoRoot, commit, `${String(config.remote)}/${String(config.baseBranch)}`);
126
+ if (!proven) return blocked("published commit is not proven reachable from remote base", { safetyRef, commit }, preflight);
127
+ const cleanupPlan = await guardianFinishWorkflow({ repoRoot, cwd: repoRoot, mode: "plan", config });
128
+ return { ok: true, status: "published", lane: "primary-main-publish", branch, commit, safetyRef, preflight, cleanupPlan };
129
+ }
@@ -0,0 +1,79 @@
1
+ import { execFile } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { expandWorktreeRoot } from "./config.ts";
7
+ import type { RecordLike } from "./types.ts";
8
+ import { errorCode } from "./types.ts";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ export type StatusEntry = { readonly status: string; readonly path: string; readonly sourcePath?: string };
13
+
14
+ export type FileFingerprint = {
15
+ readonly path: string;
16
+ readonly kind: "file" | "directory" | "other" | "missing";
17
+ readonly size: number | null;
18
+ readonly hash: string | null;
19
+ };
20
+
21
+ export type DirtySnapshot = {
22
+ readonly entries: readonly StatusEntry[];
23
+ readonly paths: readonly string[];
24
+ readonly fingerprints: readonly FileFingerprint[];
25
+ };
26
+
27
+ function isInside(candidate: string, parent: string): boolean {
28
+ const relative = path.relative(parent, candidate);
29
+ return relative === "" || (Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative));
30
+ }
31
+
32
+ export function isGuardianWorktreeStatusPath(repoRoot: string, config: RecordLike, statusPath: string): boolean {
33
+ const guardianRoot = path.resolve(repoRoot, expandWorktreeRoot(String(config.worktreeRoot), repoRoot));
34
+ const absoluteStatusPath = path.resolve(repoRoot, statusPath.replace(/\/$/, ""));
35
+ return isInside(absoluteStatusPath, guardianRoot);
36
+ }
37
+
38
+ export async function statusEntries(repoRoot: string): Promise<StatusEntry[]> {
39
+ const { stdout } = await execFileAsync("git", ["-C", repoRoot, "status", "--porcelain=v1", "--untracked-files=all", "-z"], { maxBuffer: 10 * 1024 * 1024 });
40
+ if (!stdout) return [];
41
+ const rawEntries = stdout.split("\0").filter(Boolean);
42
+ const entries: StatusEntry[] = [];
43
+ for (let index = 0; index < rawEntries.length; index += 1) {
44
+ const entry = rawEntries[index];
45
+ const status = entry.slice(0, 2);
46
+ const filePath = entry.slice(3);
47
+ if (!filePath) continue;
48
+ if (status.includes("R") || status.includes("C")) {
49
+ const sourcePath = rawEntries[index + 1];
50
+ entries.push({ status, path: filePath, sourcePath });
51
+ index += 1;
52
+ } else {
53
+ entries.push({ status, path: filePath });
54
+ }
55
+ }
56
+ return entries;
57
+ }
58
+
59
+ export async function fileFingerprint(repoRoot: string, relativePath: string): Promise<FileFingerprint> {
60
+ const absolutePath = path.join(repoRoot, relativePath);
61
+ try {
62
+ const stat = await fs.lstat(absolutePath);
63
+ if (!stat.isFile()) return { path: relativePath, kind: stat.isDirectory() ? "directory" : "other", size: stat.size, hash: null };
64
+ const content = await fs.readFile(absolutePath);
65
+ return { path: relativePath, kind: "file", size: stat.size, hash: crypto.createHash("sha256").update(content).digest("hex") };
66
+ } catch (error) {
67
+ if (errorCode(error) === "ENOENT") return { path: relativePath, kind: "missing", size: null, hash: null };
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ export async function dirtySnapshot(repoRoot: string, config?: RecordLike): Promise<DirtySnapshot> {
73
+ const allEntries = await statusEntries(repoRoot);
74
+ const entries = config ? allEntries.filter((entry) => !isGuardianWorktreeStatusPath(repoRoot, config, entry.path)) : allEntries;
75
+ const paths = [...new Set(entries.flatMap((entry) => [entry.path, entry.sourcePath].filter((value): value is string => typeof value === "string" && value.length > 0)))].sort((left, right) => left.localeCompare(right));
76
+ const fingerprints = [];
77
+ for (const filePath of paths) fingerprints.push(await fileFingerprint(repoRoot, filePath));
78
+ return { entries, paths, fingerprints };
79
+ }
@@ -0,0 +1,32 @@
1
+ import path from "node:path";
2
+ import { expandWorktreeRoot } from "./config.ts";
3
+ import { guardianFinish } from "./finish.ts";
4
+ import { getHeadCommit } from "./git.ts";
5
+ import { recordSession } from "./state.ts";
6
+ import type { GuardianConfig, MutableRecord } from "./types.ts";
7
+ import { blocked, isInside } from "./done-shared.ts";
8
+
9
+ export async function reattachCurrentGuardianWorktree(repoRoot: string, currentWorktree: string, currentBranch: string | null, config: GuardianConfig, sessionId: string, input: Record<string, unknown>): Promise<MutableRecord> {
10
+ const guardianRoot = path.resolve(repoRoot, expandWorktreeRoot(String(config.worktreeRoot), repoRoot));
11
+ if (!isInside(currentWorktree, guardianRoot)) {
12
+ return blocked("no active Guardian lane is recorded for this session; run guardian_status for recovery details", { lane: "missing-session-lane" });
13
+ }
14
+ if (!currentBranch) return blocked("detached HEAD cannot be reattached by guardian_done", { lane: "missing-session-lane", currentWorktree });
15
+ const protectedBranches = Array.isArray(config.protectedBranches) ? config.protectedBranches : [];
16
+ if (protectedBranches.includes(currentBranch)) {
17
+ return blocked("protected branches cannot be reattached as Guardian-owned worktrees", { lane: "missing-session-lane", currentBranch, currentWorktree });
18
+ }
19
+
20
+ const headCommit = await getHeadCommit(currentWorktree);
21
+ await recordSession(repoRoot, config, {
22
+ session_id: sessionId,
23
+ status: "active",
24
+ branch: currentBranch,
25
+ worktree_path: currentWorktree,
26
+ base_ref: `${String(config.remote)}/${String(config.baseBranch)}`,
27
+ head_commit: headCommit,
28
+ safety_refs: [],
29
+ }, { event: { type: "guardian_done_reattach", session_id: sessionId } });
30
+ const result = await guardianFinish({ ...input, repoRoot, cwd: currentWorktree, sessionId, config });
31
+ return { ...result, lane: "session-finish", reattached: true };
32
+ }
@@ -0,0 +1,28 @@
1
+ import path from "node:path";
2
+ import { isRecordLike } from "./types.ts";
3
+
4
+ export function samePath(left: string, right: string): boolean {
5
+ return path.resolve(left) === path.resolve(right);
6
+ }
7
+
8
+ export function isInside(candidate: string, parent: string): boolean {
9
+ const relative = path.relative(parent, candidate);
10
+ return relative === "" || (Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative));
11
+ }
12
+
13
+ export function blocked(reason: string, details: Record<string, unknown> = {}, preflight?: Record<string, unknown>): Record<string, unknown> {
14
+ if (preflight) preflight.blockers = [...((preflight.blockers as string[] | undefined) ?? []), reason];
15
+ return { ok: false, status: "blocked", reason, ...details, ...(preflight ? { preflight } : {}) };
16
+ }
17
+
18
+ export function text(input: unknown): string {
19
+ return typeof input === "string" ? input : "";
20
+ }
21
+
22
+ export function errorMessage(error: unknown): string {
23
+ if (isRecordLike(error)) {
24
+ if (typeof error.gitStderr === "string" && error.gitStderr.length > 0) return error.gitStderr;
25
+ if (typeof error.message === "string" && error.message.length > 0) return error.message;
26
+ }
27
+ return String(error);
28
+ }
package/src/done.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { loadConfig, normalizeConfig } from "./config.ts";
2
+ import { guardianFinish } from "./finish.ts";
3
+ import { getCurrentBranch, getRepoRoot } from "./git.ts";
4
+ import { isActiveSession } from "./lifecycle.ts";
5
+ import { getGuardianPaths, readState } from "./state.ts";
6
+ import { isRecordLike } from "./types.ts";
7
+ import { reattachCurrentGuardianWorktree } from "./done-reattach.ts";
8
+ import { primaryMainDone } from "./done-primary-publish.ts";
9
+ import { dirtySnapshot } from "./done-primary-snapshot.ts";
10
+ import { blocked, samePath } from "./done-shared.ts";
11
+ import { guardianFinishWorkflow } from "./workflow.ts";
12
+
13
+ export async function guardianDone(input: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
14
+ const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
15
+ const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
16
+ const config = isRecordLike(input.config) ? normalizeConfig(input.config) : (await loadConfig(repoRoot)).config;
17
+ const mode = input.mode ?? "plan";
18
+ if (mode !== "plan" && mode !== "apply") return { ok: false, status: "blocked", reason: "mode must be plan or apply", mode };
19
+
20
+ const currentWorktree = await getRepoRoot(cwd);
21
+ const currentBranch = await getCurrentBranch(currentWorktree);
22
+ const baseBranch = String(config.baseBranch);
23
+ const protectedBranches = Array.isArray(config.protectedBranches) ? config.protectedBranches : [];
24
+ const state = await readState(await getGuardianPaths(repoRoot), { repoRoot, config });
25
+ const sessionId = typeof input.sessionId === "string" && input.sessionId.trim().length > 0 ? input.sessionId : null;
26
+ const currentSession = sessionId ? state.sessions?.[sessionId] : null;
27
+
28
+ if (currentSession && isActiveSession(currentSession) && typeof currentSession.worktree_path === "string") {
29
+ if (!samePath(currentWorktree, currentSession.worktree_path)) {
30
+ const snapshot = samePath(currentWorktree, repoRoot) ? await dirtySnapshot(repoRoot, config) : { paths: [] };
31
+ if (snapshot.paths.length > 0) {
32
+ if (currentBranch === baseBranch && protectedBranches.includes(baseBranch)) {
33
+ return primaryMainDone(repoRoot, currentWorktree, config, input);
34
+ }
35
+ return blocked("changes were made outside the active Guardian lane; consolidate them before finishing", {
36
+ lane: "wrong-lane-dirty-work",
37
+ dirtyFiles: snapshot.paths,
38
+ nextAction: "Review the dirty files, then rerun guardian_done after they are moved into the active lane.",
39
+ });
40
+ }
41
+ const result = await guardianFinish({ ...input, repoRoot, cwd: currentSession.worktree_path, sessionId, config });
42
+ return { ...result, lane: "session-finish" };
43
+ }
44
+ const result = await guardianFinish({ ...input, repoRoot, cwd: currentWorktree, sessionId, config });
45
+ return { ...result, lane: "session-finish" };
46
+ }
47
+
48
+ if (typeof input.sessionId === "string" && !samePath(currentWorktree, repoRoot)) {
49
+ return reattachCurrentGuardianWorktree(repoRoot, currentWorktree, currentBranch, config, input.sessionId, input);
50
+ }
51
+
52
+ if (samePath(currentWorktree, repoRoot) && currentBranch === baseBranch && protectedBranches.includes(baseBranch)) {
53
+ const snapshot = await dirtySnapshot(repoRoot, config);
54
+ if (snapshot.paths.length > 0) return primaryMainDone(repoRoot, currentWorktree, config, input);
55
+ const cleanup = await guardianFinishWorkflow({ ...input, repoRoot, cwd: repoRoot, config });
56
+ return { ...cleanup, lane: "cleanup-only" };
57
+ }
58
+
59
+ if (samePath(currentWorktree, repoRoot) && currentBranch && protectedBranches.includes(currentBranch)) {
60
+ return {
61
+ ok: false,
62
+ status: "blocked",
63
+ lane: "primary-rescue-recommended",
64
+ reason: "dirty protected primary work is not on the configured base branch; rescue it to a Guardian worktree before finishing",
65
+ currentBranch,
66
+ baseBranch,
67
+ suggestedCommands: ["guardian_start createWorktree=true", "guardian_status"],
68
+ };
69
+ }
70
+
71
+ return {
72
+ ok: false,
73
+ status: "blocked",
74
+ lane: "blocked",
75
+ reason: "guardian_done could not choose a safe finish lane; use guardian_status for evidence or guardian_finish from an owned session worktree",
76
+ currentWorktree,
77
+ currentBranch,
78
+ baseBranch,
79
+ };
80
+ }