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,230 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { expandWorktreeRoot, loadConfig } from "./config.ts";
5
+ import { collectCleanupFingerprint } from "./deletion-fingerprint.ts";
6
+ import { getRepoRoot, listWorktrees, runGit } from "./git.ts";
7
+ import { isEnoent, isSameOrInside, lstatOrMissing, normalizeRelativePath, parseNullSeparated, recordValue, relativePath, stringArray, uniqueSorted } from "./filesystem-boundaries.ts";
8
+ import { getGuardianPaths, readState } from "./state.ts";
9
+ import { protectedDirReason, scanWorkspaceHygiene } from "./hygiene-scan.ts";
10
+ import type { HygieneCategory, HygieneSeverity } from "./hygiene-scan.ts";
11
+
12
+ type CleanupPathKind = "directory" | "file" | "other";
13
+ type CleanupBlocker = { path?: string; category?: string; reason: string; fatal: boolean };
14
+ type CleanupTarget = {
15
+ path: string;
16
+ absolutePath: string;
17
+ category: HygieneCategory;
18
+ severity: HygieneSeverity;
19
+ reason: string;
20
+ kind: CleanupPathKind;
21
+ fingerprint: Array<Record<string, string | number>>;
22
+ };
23
+
24
+ const CLEANUP_CATEGORIES = new Set<HygieneCategory>(["known-cleanable", "nested-git", "suspicious"]);
25
+
26
+ function cleanupPathKindFromStat(stat: { isDirectory(): boolean; isFile(): boolean }): CleanupPathKind {
27
+ if (stat.isDirectory()) return "directory";
28
+ if (stat.isFile()) return "file";
29
+ return "other";
30
+ }
31
+
32
+ function resolveCleanupPath(repoRoot: string, cleanupPath: string) {
33
+ const absolutePath = path.isAbsolute(cleanupPath) ? path.resolve(cleanupPath) : path.resolve(repoRoot, cleanupPath);
34
+ const relative = relativePath(repoRoot, absolutePath);
35
+ return { absolutePath, relative };
36
+ }
37
+
38
+ async function listTrackedContents(repoRoot: string, relative: string) {
39
+ const result = await runGit(repoRoot, ["ls-files", "-z", "--", relative]);
40
+ return parseNullSeparated(result.stdout).map((entry) => normalizeRelativePath(entry)).sort((left, right) => left.localeCompare(right));
41
+ }
42
+
43
+ async function collectCleanupProtectedRoots(repoRoot: string, config: Record<string, unknown>) {
44
+ const roots = new Map<string, string>();
45
+ const configuredWorktreeRoot = path.resolve(repoRoot, expandWorktreeRoot(String(config.worktreeRoot), repoRoot));
46
+ roots.set(configuredWorktreeRoot, "configured Guardian worktree root");
47
+ for (const entry of await listWorktrees(repoRoot)) {
48
+ const worktreePath = path.resolve(String(entry.path));
49
+ if (worktreePath !== path.resolve(repoRoot)) roots.set(worktreePath, "registered Git worktree path");
50
+ }
51
+ try {
52
+ const state = await readState(await getGuardianPaths(repoRoot), { repoRoot, config });
53
+ for (const session of Object.values(recordValue(state.sessions))) {
54
+ const sessionRecord = recordValue(session);
55
+ if (typeof sessionRecord.worktree_path === "string" && path.resolve(sessionRecord.worktree_path) !== path.resolve(repoRoot)) {
56
+ roots.set(path.resolve(sessionRecord.worktree_path), "registered Guardian session worktree path");
57
+ }
58
+ }
59
+ } catch {}
60
+ return [...roots.entries()].map(([root, reason]) => ({ root, reason })).sort((left, right) => left.root.localeCompare(right.root));
61
+ }
62
+
63
+ function protectedRootBlocker(absolutePath: string, protectedRoots: Array<{ root: string; reason: string }>) {
64
+ return protectedRoots.find((entry) => isSameOrInside(absolutePath, entry.root) || isSameOrInside(entry.root, absolutePath));
65
+ }
66
+
67
+ function createCleanupConfirmToken(preflight: Record<string, unknown>) {
68
+ const material = {
69
+ tool: "guardian_hygiene",
70
+ repoRoot: preflight.repoRoot,
71
+ cleanupPaths: preflight.cleanupPaths,
72
+ allowCategories: preflight.allowCategories,
73
+ allowDirtyNestedGit: preflight.allowDirtyNestedGit,
74
+ targets: (preflight.targets as CleanupTarget[] | undefined ?? []).map((target) => ({
75
+ path: target.path,
76
+ category: target.category,
77
+ kind: target.kind,
78
+ fingerprint: target.fingerprint,
79
+ })),
80
+ };
81
+ return crypto.createHash("sha256").update(JSON.stringify(material)).digest("hex");
82
+ }
83
+
84
+ function cleanupSummary(targets: CleanupTarget[], blockers: CleanupBlocker[], findingCount: number, removedTargets: CleanupTarget[] = []) {
85
+ const byCategory: Record<string, number> = { "known-cleanable": 0, "nested-git": 0, suspicious: 0 };
86
+ for (const target of targets) byCategory[target.category] = (byCategory[target.category] ?? 0) + 1;
87
+ return {
88
+ findingCount,
89
+ approvedTargetCount: targets.length,
90
+ blockedTargetCount: blockers.length,
91
+ fatalBlockerCount: blockers.filter((blocker) => blocker.fatal).length,
92
+ removedTargetCount: removedTargets.length,
93
+ byCategory,
94
+ };
95
+ }
96
+
97
+ function cleanupReport(result: Record<string, unknown>, preflight: Record<string, unknown>, removedTargets: CleanupTarget[] = []) {
98
+ return {
99
+ ...result,
100
+ preflight,
101
+ report: {
102
+ action: result.status,
103
+ mode: preflight.mode,
104
+ repoRoot: preflight.repoRoot,
105
+ cleanupPaths: preflight.cleanupPaths,
106
+ approvedTargets: (preflight.targets as CleanupTarget[] | undefined ?? []).map((target) => target.path),
107
+ removedTargets: removedTargets.map((target) => target.path),
108
+ blockers: preflight.blockers,
109
+ summary: result.summary,
110
+ },
111
+ };
112
+ }
113
+
114
+ async function buildHygieneCleanupPreflight(input: Record<string, unknown>) {
115
+ const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
116
+ const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
117
+ const loadedConfig = input.config && typeof input.config === "object" ? { config: input.config as Record<string, unknown> } : await loadConfig(repoRoot);
118
+ const config = loadedConfig.config;
119
+ const rawAllowCategories = stringArray(input.allowCategories);
120
+ const invalidAllowCategories = rawAllowCategories.filter((category) => !CLEANUP_CATEGORIES.has(category as HygieneCategory));
121
+ const allowCategories = rawAllowCategories.length > 0
122
+ ? uniqueSorted(rawAllowCategories.filter((category) => CLEANUP_CATEGORIES.has(category as HygieneCategory)))
123
+ : uniqueSorted([...CLEANUP_CATEGORIES]);
124
+ const allowDirtyNestedGit = input.allowDirtyNestedGit === true;
125
+ const selectedInput = stringArray(input.cleanupPaths);
126
+ const scan = await scanWorkspaceHygiene({ ...input, repoRoot, config });
127
+ const findings = (scan.findings as Array<Record<string, unknown>> | undefined ?? []).filter((finding) => typeof finding.path === "string");
128
+ const findingsByPath = new Map(findings.map((finding) => [String(finding.path), finding]));
129
+ const blockers: CleanupBlocker[] = invalidAllowCategories.map((category) => ({ category, reason: `unsupported allowCategories entry: ${category}`, fatal: true }));
130
+ if (scan.ok === false) blockers.push({ reason: `guardian_hygiene scan failed: ${String(scan.reason ?? "unknown error")}`, fatal: true });
131
+ const protectedRoots = await collectCleanupProtectedRoots(repoRoot, config);
132
+ const selectedPaths = selectedInput.length > 0
133
+ ? uniqueSorted(selectedInput)
134
+ : findings
135
+ .filter((finding) => allowCategories.includes(String(finding.category)))
136
+ .filter((finding) => allowDirtyNestedGit || !(finding.category === "nested-git" && recordValue(finding.metadata).dirty === true))
137
+ .map((finding) => String(finding.path));
138
+
139
+ if (selectedInput.length === 0) {
140
+ for (const finding of findings) {
141
+ const category = String(finding.category);
142
+ const metadata = recordValue(finding.metadata);
143
+ if (!allowCategories.includes(category)) blockers.push({ path: String(finding.path), category, reason: `category ${category} is not allowed for hygiene cleanup`, fatal: false });
144
+ else if (category === "nested-git" && metadata.dirty === true && !allowDirtyNestedGit) blockers.push({ path: String(finding.path), category, reason: "dirty nested Git repositories require allowDirtyNestedGit=true", fatal: false });
145
+ }
146
+ }
147
+
148
+ const targets: CleanupTarget[] = [];
149
+ const targetPaths = new Set<string>();
150
+ for (const cleanupPath of selectedPaths) {
151
+ const { absolutePath, relative } = resolveCleanupPath(repoRoot, cleanupPath);
152
+ const finding = findingsByPath.get(relative);
153
+ const pathBlockers: CleanupBlocker[] = [];
154
+ if (!isSameOrInside(absolutePath, path.resolve(repoRoot))) pathBlockers.push({ path: cleanupPath, reason: "cleanup path is outside the repository root", fatal: true });
155
+ if (relative === "." || relative === ".git" || relative.startsWith(".git/")) pathBlockers.push({ path: relative, reason: "repository root and .git metadata cannot be cleanup roots", fatal: true });
156
+ const protectedReason = protectedDirReason(relative);
157
+ if (protectedReason) pathBlockers.push({ path: relative, reason: protectedReason, fatal: true });
158
+ const protectedRoot = protectedRootBlocker(absolutePath, protectedRoots);
159
+ if (protectedRoot) pathBlockers.push({ path: relative, reason: protectedRoot.reason, fatal: true });
160
+ const stat = pathBlockers.length > 0 ? null : await lstatOrMissing(absolutePath);
161
+ if (pathBlockers.length === 0 && stat == null) pathBlockers.push({ path: relative, reason: "selected cleanup path is missing", fatal: true });
162
+ if (stat?.isSymbolicLink()) pathBlockers.push({ path: relative, reason: "symlink cleanup roots are not allowed", fatal: true });
163
+ const trackedContents = pathBlockers.some((blocker) => blocker.fatal) ? [] : await listTrackedContents(repoRoot, relative);
164
+ if (trackedContents.length > 0) pathBlockers.push({ path: relative, reason: "selected cleanup root contains tracked files", fatal: true });
165
+ if (!finding) pathBlockers.push({ path: relative, reason: "selected path is not a current guardian_hygiene finding", fatal: true });
166
+ const category = String(finding?.category ?? "");
167
+ const metadata = recordValue(finding?.metadata);
168
+ if (finding && !allowCategories.includes(category)) pathBlockers.push({ path: relative, category, reason: `category ${category} is not allowed for hygiene cleanup`, fatal: selectedInput.length > 0 });
169
+ if (finding && category === "nested-git" && metadata.dirty === true && !allowDirtyNestedGit) pathBlockers.push({ path: relative, category, reason: "dirty nested Git repositories require allowDirtyNestedGit=true", fatal: true });
170
+ if (pathBlockers.length > 0) {
171
+ blockers.push(...pathBlockers);
172
+ continue;
173
+ }
174
+ if (!stat || !finding || targetPaths.has(relative)) continue;
175
+ try {
176
+ targets.push({ path: relative, absolutePath, category: category as HygieneCategory, severity: String(finding.severity) as HygieneSeverity, reason: String(finding.reason), kind: cleanupPathKindFromStat(stat), fingerprint: await collectCleanupFingerprint(repoRoot, absolutePath) });
177
+ targetPaths.add(relative);
178
+ } catch (error) {
179
+ if (!isEnoent(error)) throw error;
180
+ blockers.push({ path: relative, reason: "cleanup path disappeared during preflight", fatal: selectedInput.length > 0 });
181
+ }
182
+ }
183
+
184
+ for (const target of targets) {
185
+ const overlap = targets.find((candidate) => candidate.path !== target.path && isSameOrInside(path.resolve(repoRoot, candidate.path), path.resolve(repoRoot, target.path)));
186
+ if (overlap) blockers.push({ path: target.path, reason: `cleanup paths overlap with ${overlap.path}`, fatal: true });
187
+ }
188
+ const preflight: Record<string, unknown> = { repoRoot: path.resolve(repoRoot), mode: input.mode, cleanupPaths: selectedPaths, allowCategories, allowDirtyNestedGit, targets, blockers, scannedAt: scan.scannedAt, scanSummary: scan.summary };
189
+ preflight.summary = cleanupSummary(targets, blockers, findings.length);
190
+ return preflight;
191
+ }
192
+
193
+ async function removeCleanupTarget(target: CleanupTarget) {
194
+ try {
195
+ await fs.rm(target.absolutePath, { recursive: target.kind === "directory", force: false });
196
+ } catch (error) {
197
+ if (!isEnoent(error)) throw error;
198
+ }
199
+ }
200
+
201
+ export async function runGuardianHygieneMode(input: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
202
+ const mode = input.mode;
203
+ const preflight = await buildHygieneCleanupPreflight(input);
204
+ if (mode !== "plan" && mode !== "apply") {
205
+ const blocker = { reason: "mode must be plan or apply", fatal: true };
206
+ const blockers = [blocker];
207
+ const summary = cleanupSummary([], blockers, 0);
208
+ return cleanupReport({ ok: false, status: "blocked", reason: blocker.reason, summary, targets: [], blockers }, { ...preflight, blockers, summary });
209
+ }
210
+ const targets = preflight.targets as CleanupTarget[];
211
+ const blockers = preflight.blockers as CleanupBlocker[];
212
+ const summary = preflight.summary as Record<string, unknown>;
213
+ const fatalBlockers = blockers.filter((blocker) => blocker.fatal);
214
+ if (fatalBlockers.length > 0 || targets.length === 0) {
215
+ const reason = fatalBlockers.length > 0 ? "hygiene cleanup preflight has fatal blockers" : "no approved hygiene cleanup targets";
216
+ return cleanupReport({ ok: false, status: "blocked", reason, summary, targets, blockers }, preflight);
217
+ }
218
+ const confirmToken = createCleanupConfirmToken(preflight);
219
+ if (mode === "plan") return cleanupReport({ ok: true, status: "planned", confirmToken, summary, targets, blockers, suggestedCommands: ["guardian_hygiene"] }, preflight);
220
+ if (input.confirmToken !== confirmToken) {
221
+ return cleanupReport({ ok: false, status: "blocked", reason: "confirm token mismatch; re-run mode=plan and use the returned confirmToken", tokenMatched: false, summary, targets, blockers }, preflight);
222
+ }
223
+ const removedTargets: CleanupTarget[] = [];
224
+ for (const target of targets) {
225
+ await removeCleanupTarget(target);
226
+ removedTargets.push(target);
227
+ }
228
+ const finalSummary = cleanupSummary(targets, blockers, Number((preflight.scanSummary as Record<string, unknown> | undefined)?.findingCount ?? targets.length), removedTargets);
229
+ return cleanupReport({ ok: true, status: "cleaned", summary: finalSummary, targets, removedTargets, blockers, suggestedCommands: ["guardian_hygiene", "guardian_status"] }, { ...preflight, summary: finalSummary }, removedTargets);
230
+ }
@@ -0,0 +1,200 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { expandWorktreeRoot, loadConfig } from "./config.ts";
4
+ import { getRepoRoot, listWorktrees, runGitNullSeparated, tryGit } from "./git.ts";
5
+ import { isEnoent, isSameOrInside, normalizeRelativePath, relativePath } from "./filesystem-boundaries.ts";
6
+
7
+ export type HygieneSeverity = "warn" | "fail";
8
+ export type HygieneCategory = "known-cleanable" | "nested-git" | "suspicious";
9
+
10
+ const PROTECTED_DIR_NAMES = new Set([
11
+ "node_modules",
12
+ "vendor",
13
+ "target",
14
+ "dist",
15
+ "build",
16
+ "coverage",
17
+ ".cache",
18
+ ".next",
19
+ ".turbo",
20
+ ".vite",
21
+ ".parcel-cache",
22
+ ".pnpm-store",
23
+ "out",
24
+ "tmp",
25
+ "temp",
26
+ ]);
27
+
28
+ const SUSPICIOUS_NAME_PATTERN = /(^|[-_.])(clone|clones|research|dump|dumps|scratch|sandbox|experiment|prototype|poc|checkout|repo)([-_.]|$)/i;
29
+ const RESIDUE_ROOT_PATTERN = /^(guardian-[^/]+|guardian-origin-[^/]+|opencode-temp-[^/]+|omo-research-[^/]+|opencode-research-[^/]+|git-docs-research)$/;
30
+ const LOCAL_AGENT_STATE_DIRS = new Set([".omo", ".omc", ".omx", ".sisyphus", ".milestones"]);
31
+
32
+ function errorMessage(error: unknown) {
33
+ return error instanceof Error ? error.message : String(error);
34
+ }
35
+
36
+ async function listCandidatePaths(repoRoot: string) {
37
+ const untracked = await runGitNullSeparated(repoRoot, ["ls-files", "--others", "--exclude-standard", "-z"]);
38
+ const ignored = await runGitNullSeparated(repoRoot, ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"]);
39
+ return [...new Set([...untracked, ...ignored])]
40
+ .map((entry) => normalizeRelativePath(entry))
41
+ .sort((left, right) => left.localeCompare(right));
42
+ }
43
+
44
+ export function protectedDirReason(relative: string) {
45
+ const parts = relative.split("/").filter(Boolean);
46
+ if (relative === ".git" || relative.startsWith(".git/")) {
47
+ return relative === ".git/worktrees" || relative.startsWith(".git/worktrees/") ? "git worktree metadata" : "git metadata";
48
+ }
49
+ const protectedPart = parts.find((part) => PROTECTED_DIR_NAMES.has(part));
50
+ return protectedPart ? `protected ${protectedPart} directory` : null;
51
+ }
52
+
53
+ function knownCleanableMatch(relative: string) {
54
+ const parts = relative.split("/").filter(Boolean);
55
+ if (LOCAL_AGENT_STATE_DIRS.has(parts[0] ?? "")) return { path: parts[0], reason: "local agent state directory" };
56
+ if (parts.length === 1 && /^[^/]+\.tsv$/i.test(parts[0] ?? "")) return { path: parts[0], reason: "generated TSV artifact" };
57
+ if (parts[0] === "data" && /^test-wal-[^/]+$/.test(parts[1] ?? "")) return { path: `data/${parts[1]}`, reason: "known test WAL scratch artifact" };
58
+ for (const [index, part] of parts.entries()) {
59
+ const artifactPath = parts.slice(0, index + 1).join("/");
60
+ if (part === "node-compile-cache") return { path: artifactPath, reason: "generated Node compile cache" };
61
+ if (/^node-coverage-[^/]+$/.test(part)) return { path: artifactPath, reason: "generated Node coverage cache" };
62
+ if (/^tsx-\d+$/.test(part)) return { path: artifactPath, reason: "generated tsx runtime cache" };
63
+ if (/^librarian-[^/]+$/.test(part)) return { path: artifactPath, reason: "known librarian scratch artifact" };
64
+ if (/^[^/]+-librarian$/.test(part)) return { path: artifactPath, reason: "known librarian scratch artifact" };
65
+ if (/^hyperf-[^/]+$/.test(part)) return { path: artifactPath, reason: "known Hyperf scratch artifact" };
66
+ if (part === "test-phpkafka") return { path: artifactPath, reason: "known phpkafka test scratch artifact" };
67
+ if (part === "test-hyperf-kafka") return { path: artifactPath, reason: "known Hyperf Kafka test scratch artifact" };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function suspiciousPath(relative: string) {
73
+ const parts = relative.split("/").filter(Boolean);
74
+ if (RESIDUE_ROOT_PATTERN.test(parts[0] ?? "")) return parts[0];
75
+ const index = parts.findIndex((part) => SUSPICIOUS_NAME_PATTERN.test(part));
76
+ return index >= 0 ? parts.slice(0, index + 1).join("/") : relative;
77
+ }
78
+
79
+ export function residueRoot(relative: string) {
80
+ const root = relative.split("/").filter(Boolean)[0] ?? "";
81
+ return RESIDUE_ROOT_PATTERN.test(root) ? root : null;
82
+ }
83
+
84
+ function shellQuote(value: string) {
85
+ return /^[A-Za-z0-9_./:-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
86
+ }
87
+
88
+ async function pathKind(candidate: string) {
89
+ try {
90
+ const stat = await fs.lstat(candidate);
91
+ return stat.isDirectory() ? "directory" : "file";
92
+ } catch (error) {
93
+ if (isEnoent(error)) return "missing";
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ async function findNestedGitRoot(repoRoot: string, candidatePath: string) {
99
+ let current = await pathKind(candidatePath) === "directory" ? candidatePath : path.dirname(candidatePath);
100
+ const root = path.resolve(repoRoot);
101
+ while (isSameOrInside(current, root) && path.resolve(current) !== root) {
102
+ const marker = path.join(current, ".git");
103
+ try {
104
+ await fs.lstat(marker);
105
+ return current;
106
+ } catch (error) {
107
+ if (!isEnoent(error)) throw error;
108
+ }
109
+ const parent = path.dirname(current);
110
+ if (parent === current) return null;
111
+ current = parent;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ async function nestedGitMetadata(gitRoot: string) {
117
+ const status = await tryGit(gitRoot, ["status", "--porcelain"]);
118
+ const dirty = status.ok && status.stdout.length > 0;
119
+ return { dirty, manualReview: true, hardDeny: dirty, statusAvailable: status.ok };
120
+ }
121
+
122
+ export async function scanWorkspaceHygiene(input: Record<string, unknown> = {}) {
123
+ const scannedAt = input.scannedAt instanceof Date ? input.scannedAt.toISOString() : new Date().toISOString();
124
+ try {
125
+ const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
126
+ const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
127
+ const loadedConfig = input.config && typeof input.config === "object" ? { config: input.config as Record<string, string> } : await loadConfig(repoRoot);
128
+ const config = loadedConfig.config;
129
+ const worktrees = await listWorktrees(repoRoot);
130
+ const configuredWorktreeRoot = path.resolve(repoRoot, expandWorktreeRoot(String(config.worktreeRoot), repoRoot));
131
+ const protectedRoots = worktrees.map((entry) => path.resolve(String(entry.path))).filter((entry) => entry !== path.resolve(repoRoot));
132
+ protectedRoots.push(configuredWorktreeRoot);
133
+ const findings: Array<Record<string, unknown>> = [];
134
+ const exclusionsByPath = new Map<string, Record<string, unknown>>();
135
+ const seenFindings = new Set<string>();
136
+ const candidates = await listCandidatePaths(repoRoot);
137
+ for (const candidate of candidates) {
138
+ const absolutePath = path.resolve(repoRoot, candidate);
139
+ const relative = relativePath(repoRoot, absolutePath);
140
+ const protectedReason = protectedDirReason(relative);
141
+ const protectedRoot = protectedRoots.find((root) => isSameOrInside(absolutePath, root));
142
+ if (protectedReason || protectedRoot) {
143
+ const exclusionPath = protectedRoot ? relativePath(repoRoot, protectedRoot) : relative.split("/")[0];
144
+ exclusionsByPath.set(exclusionPath, { path: exclusionPath, reason: protectedReason ?? "configured or registered Git worktree path" });
145
+ continue;
146
+ }
147
+ const nestedRoot = await findNestedGitRoot(repoRoot, absolutePath);
148
+ if (nestedRoot) {
149
+ const nestedRelative = residueRoot(relative) ?? relativePath(repoRoot, nestedRoot);
150
+ const key = `nested-git:${nestedRelative}`;
151
+ if (!seenFindings.has(key)) {
152
+ const metadata = await nestedGitMetadata(nestedRoot);
153
+ findings.push({ path: nestedRelative, category: "nested-git" satisfies HygieneCategory, severity: metadata.dirty ? "fail" satisfies HygieneSeverity : "warn" satisfies HygieneSeverity, reason: metadata.dirty ? "nested Git repository has uncommitted changes" : "nested Git repository requires manual review", source: "git ls-files --others/--ignored", metadata });
154
+ seenFindings.add(key);
155
+ }
156
+ continue;
157
+ }
158
+ const knownMatch = knownCleanableMatch(relative);
159
+ if (knownMatch) {
160
+ const key = `known-cleanable:${knownMatch.path}`;
161
+ if (!seenFindings.has(key)) {
162
+ findings.push({ path: knownMatch.path, category: "known-cleanable" satisfies HygieneCategory, severity: "warn" satisfies HygieneSeverity, reason: knownMatch.reason, source: "git ls-files --others/--ignored" });
163
+ seenFindings.add(key);
164
+ }
165
+ continue;
166
+ }
167
+ const residue = residueRoot(relative);
168
+ if (residue) {
169
+ const key = `suspicious:${residue}`;
170
+ if (!seenFindings.has(key)) {
171
+ findings.push({ path: residue, category: "suspicious" satisfies HygieneCategory, severity: "warn" satisfies HygieneSeverity, reason: "untracked path resembles a clone, research dump, or scratch workspace", source: "git ls-files --others/--ignored" });
172
+ seenFindings.add(key);
173
+ }
174
+ continue;
175
+ }
176
+ const baseName = path.basename(relative);
177
+ if (SUSPICIOUS_NAME_PATTERN.test(relative) || SUSPICIOUS_NAME_PATTERN.test(baseName)) {
178
+ const findingPath = suspiciousPath(relative);
179
+ const key = `suspicious:${findingPath}`;
180
+ if (!seenFindings.has(key)) {
181
+ findings.push({ path: findingPath, category: "suspicious" satisfies HygieneCategory, severity: "warn" satisfies HygieneSeverity, reason: "untracked path resembles a clone, research dump, or scratch workspace", source: "git ls-files --others/--ignored" });
182
+ seenFindings.add(key);
183
+ }
184
+ }
185
+ }
186
+ findings.sort((left, right) => String(left.path).localeCompare(String(right.path)) || String(left.category).localeCompare(String(right.category)));
187
+ const exclusions = [...exclusionsByPath.values()].sort((left, right) => String(left.path).localeCompare(String(right.path)));
188
+ const summary = { candidateCount: candidates.length, findingCount: findings.length, exclusionCount: exclusions.length, bySeverity: { warn: 0, fail: 0 } as Record<string, number>, byCategory: { "known-cleanable": 0, "nested-git": 0, suspicious: 0 } as Record<string, number> };
189
+ for (const finding of findings) {
190
+ const severity = String(finding.severity);
191
+ const category = String(finding.category);
192
+ summary.bySeverity[severity] = (summary.bySeverity[severity] ?? 0) + 1;
193
+ summary.byCategory[category] = (summary.byCategory[category] ?? 0) + 1;
194
+ }
195
+ const nestedCommands = findings.filter((finding) => finding.category === "nested-git").map((finding) => `git -C ${shellQuote(String(finding.path))} status --short`);
196
+ return { ok: true, repoRoot, summary, findings, exclusions, scannedAt, suggestedCommands: ["guardian_hygiene", "guardian_status", "git status --short --ignored", ...nestedCommands] };
197
+ } catch (error) {
198
+ return { ok: false, status: "failed", reason: errorMessage(error), failureReason: errorMessage(error), summary: { scanFailed: true, candidateCount: 0, findingCount: 0, exclusionCount: 0, bySeverity: { warn: 0, fail: 0 }, byCategory: { "known-cleanable": 0, "nested-git": 0, suspicious: 0 } }, findings: [], exclusions: [], scannedAt, suggestedCommands: ["guardian_hygiene", "guardian_status"] };
199
+ }
200
+ }
package/src/hygiene.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { runGuardianHygieneMode } from "./hygiene-apply.ts";
2
+ import { scanWorkspaceHygiene } from "./hygiene-scan.ts";
3
+
4
+ export type { HygieneCategory, HygieneSeverity } from "./hygiene-scan.ts";
5
+ export { scanWorkspaceHygiene } from "./hygiene-scan.ts";
6
+
7
+ export async function guardianHygiene(input: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
8
+ if (input.mode != null) return runGuardianHygieneMode(input);
9
+ return scanWorkspaceHygiene(input);
10
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ import WorktreeGuardianPlugin from "./plugin/server.ts";
2
+
3
+ export { DEFAULT_CONFIG, FINISH_MODES, loadConfig, normalizeConfig } from "./config.ts";
4
+ export { classifyGuardCommand, classifyNormalAgentGitCommand, classifyReadOnlyInspectionCommand, extractCommandText, tokenizeCommand } from "./guards.ts";
5
+ export { guardianDeletePaths } from "./delete-paths.ts";
6
+ export { guardianDeleteWorktree } from "./delete.ts";
7
+ export { guardianDone } from "./done.ts";
8
+ export { buildPreservedRef, buildSafetyRef, createSafetyRef, deleteBranch, getRepoRoot, listWorktrees, removeWorktree, runGit } from "./git.ts";
9
+ export { scanWorkspaceHygiene } from "./hygiene.ts";
10
+ export { acquireStateLock, appendEvent, getGuardianPaths, readState, recordSession, updateState, writeReportAtomic, writeStateAtomic } from "./state.ts";
11
+ export { guardianFinish } from "./finish.ts";
12
+ export { guardianRecover, guardianStatus } from "./recover.ts";
13
+ export { guardianReportHtml, renderGuardianReportHtml } from "./report.ts";
14
+ export { buildInvisiblePolicy, collectKnownWorktreePaths, guardianPreserve, guardianStart, injectInvisiblePolicy, recordLastSafeState, resolveSessionWorktree, rewriteGuardianCommand, runGuardianTool } from "./tools.ts";
15
+ export { guardianUnblockFinish } from "./unblock-finish.ts";
16
+ export type * from "./types.ts";
17
+
18
+ export default WorktreeGuardianPlugin;
@@ -0,0 +1,31 @@
1
+ import type { MutableRecord } from "./types.ts";
2
+
3
+ export const TERMINAL_SESSION_STATUS_VALUES = ["deleted", "abandoned", "finished", "preserved", "superseded"] as const;
4
+ export type TerminalSessionStatus = typeof TERMINAL_SESSION_STATUS_VALUES[number];
5
+ export const TERMINAL_SESSION_STATUSES = new Set<string>(TERMINAL_SESSION_STATUS_VALUES);
6
+
7
+ export function isTerminalSessionStatus(status: unknown): status is TerminalSessionStatus {
8
+ return typeof status === "string" && TERMINAL_SESSION_STATUSES.has(status);
9
+ }
10
+
11
+ export function isTerminalSession(session: { status?: unknown } | undefined | null): boolean {
12
+ return isTerminalSessionStatus(session?.status);
13
+ }
14
+
15
+ export function isActiveSession<T extends { status?: unknown }>(session: T | undefined | null): session is T & { readonly status: "active" } {
16
+ return session?.status === "active";
17
+ }
18
+
19
+ export function clearTerminalLifecycleFields(session: MutableRecord) {
20
+ if (session.status !== "active") return session;
21
+ const next = { ...session };
22
+ delete next.deleted_worktree_path;
23
+ delete next.deleted_branch;
24
+ delete next.branch_only_delete;
25
+ delete next.branch_delete_failed;
26
+ delete next.branch_delete_error;
27
+ delete next.abandon_unmerged;
28
+ delete next.abandoned_branch;
29
+ delete next.unmerged_commits;
30
+ return next;
31
+ }
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+ import type { GuardCommandPayload, SessionWorktreeResult } from "../types.ts";
3
+ import { errorMessage, isMutableRecord } from "../types.ts";
4
+ import { validateRecordedSessionTarget } from "./session-routing.ts";
5
+
6
+ const DIRECT_FILE_MUTATION_TOOLS = new Set([
7
+ "write",
8
+ "edit",
9
+ "multiedit",
10
+ "patch",
11
+ "apply_patch",
12
+ "functions.apply_patch",
13
+ ]);
14
+ const DIRECT_FILE_PATH_KEYS = ["filePath", "filepath", "path", "target", "filename"];
15
+
16
+ function normalizePathForCompare(candidate: string) {
17
+ return path.resolve(candidate);
18
+ }
19
+
20
+ function isPathInside(parent: string, candidate: string) {
21
+ const relative = path.relative(normalizePathForCompare(parent), normalizePathForCompare(candidate));
22
+ return relative === "" || Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
23
+ }
24
+
25
+ export function directFileMutationPathArg(input: GuardCommandPayload = {}, output: GuardCommandPayload = {}) {
26
+ const toolName = String(input.tool ?? "");
27
+ if (!DIRECT_FILE_MUTATION_TOOLS.has(toolName)) return null;
28
+ const args = isMutableRecord(output.args) ? output.args : isMutableRecord(input.args) ? input.args : null;
29
+ if (!args) return null;
30
+ for (const key of DIRECT_FILE_PATH_KEYS) {
31
+ if (typeof args[key] === "string" && path.isAbsolute(args[key])) return { args, key, value: args[key] };
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export async function routeDirectFileMutation(input: GuardCommandPayload, output: GuardCommandPayload, sessionWorktree: SessionWorktreeResult | null, repoRoot: string | undefined, cache: Map<string, string>) {
37
+ const pathArg = directFileMutationPathArg(input, output);
38
+ if (!pathArg) return { routed: false, blocked: false, reason: null };
39
+ if (!repoRoot) return { routed: false, blocked: true, reason: "direct file mutation cannot be checked without a Guardian repo root" };
40
+ if (!isPathInside(repoRoot, pathArg.value)) return { routed: false, blocked: false, reason: null };
41
+ if (sessionWorktree?.sessionId == null) return { routed: false, blocked: false, reason: null };
42
+ if (typeof sessionWorktree?.expectedWorktree !== "string") {
43
+ return { routed: false, blocked: true, reason: "direct file mutation cannot be checked against a recorded Guardian worktree" };
44
+ }
45
+ if (isPathInside(sessionWorktree.expectedWorktree, pathArg.value)) return { routed: false, blocked: false, reason: null };
46
+
47
+ let routedSession = sessionWorktree;
48
+ if (routedSession.ok !== true) {
49
+ try {
50
+ routedSession = await validateRecordedSessionTarget(input, routedSession, repoRoot, cache);
51
+ } catch (error) {
52
+ return { routed: false, blocked: true, reason: errorMessage(error) };
53
+ }
54
+ }
55
+
56
+ const relative = path.relative(normalizePathForCompare(repoRoot), normalizePathForCompare(pathArg.value));
57
+ const expectedWorktree = routedSession.expectedWorktree;
58
+ if (typeof expectedWorktree !== "string") {
59
+ return { routed: false, blocked: true, reason: "direct file mutation cannot be routed without a recorded Guardian worktree" };
60
+ }
61
+ const routedPath = path.join(expectedWorktree, relative);
62
+ if (!isPathInside(expectedWorktree, routedPath)) {
63
+ return { routed: false, blocked: true, reason: "direct file mutation path cannot be safely rewritten into the Guardian worktree" };
64
+ }
65
+ pathArg.args[pathArg.key] = routedPath;
66
+ if (!isMutableRecord(output.args)) output.args = pathArg.args;
67
+ return { routed: true, blocked: false, reason: null, originalPath: pathArg.value, routedPath };
68
+ }
@@ -0,0 +1,60 @@
1
+ import type { HookContext, PluginClient, RecordLike } from "../types.ts";
2
+ import { isRecordLike } from "../types.ts";
3
+
4
+ const SERVICE = "worktree-guardian";
5
+ const MAX_STRING_LENGTH = 160;
6
+ const SECRET_KEY_PATTERN = /(token|secret|password|passwd|authorization|cookie|api[-_]?key|credential)/i;
7
+
8
+ function truncate(value: string) {
9
+ if (typeof value !== "string") return value;
10
+ if (value.length <= MAX_STRING_LENGTH) return value;
11
+ return `${value.slice(0, MAX_STRING_LENGTH)}...<truncated:${value.length}>`;
12
+ }
13
+
14
+ function redactString(value: string) {
15
+ return truncate(value)
16
+ .replace(/(authorization:\s*)(bearer\s+)?\S+/gi, "$1$2<redacted>")
17
+ .replace(/(token|secret|password|passwd|api[-_]?key|cookie|credential)=([^\s&]+)/gi, "$1=<redacted>");
18
+ }
19
+
20
+ function summarize(value: unknown, key = ""): unknown {
21
+ if (SECRET_KEY_PATTERN.test(key)) return "<redacted>";
22
+ if (value == null) return value;
23
+ if (typeof value === "string") return redactString(value);
24
+ if (typeof value === "number" || typeof value === "boolean") return value;
25
+ if (Array.isArray(value)) {
26
+ return {
27
+ type: "array",
28
+ length: value.length,
29
+ preview: value.slice(0, 5).map((entry) => summarize(entry, key)),
30
+ };
31
+ }
32
+ if (isRecordLike(value)) {
33
+ const entries = Object.entries(value).slice(0, 12);
34
+ return Object.fromEntries(entries.map(([entryKey, entryValue]) => [entryKey, summarize(entryValue, entryKey)]));
35
+ }
36
+ return typeof value;
37
+ }
38
+
39
+ export async function writeLog(client: PluginClient | undefined, event: RecordLike) {
40
+ if (typeof client?.app?.log === "function") {
41
+ await client.app.log({ body: event });
42
+ return;
43
+ }
44
+
45
+ console.error(JSON.stringify(event));
46
+ }
47
+
48
+ export function createEvent(message: string, input: unknown, output: unknown, context: HookContext, extra: RecordLike = {}): RecordLike {
49
+ const summarizedExtra = summarize(extra);
50
+ return {
51
+ service: SERVICE,
52
+ level: "info",
53
+ message,
54
+ directory: context.directory,
55
+ worktree: context.worktree,
56
+ input: summarize(input),
57
+ output: summarize(output),
58
+ ...(isRecordLike(summarizedExtra) ? summarizedExtra : {}),
59
+ };
60
+ }