opencode-feature-factory 0.2.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +877 -0
  3. package/assets/agent/backend-builder.md +73 -0
  4. package/assets/agent/codebase-researcher.md +56 -0
  5. package/assets/agent/design-interpreter.md +37 -0
  6. package/assets/agent/frontend-builder.md +74 -0
  7. package/assets/agent/implementation-validator.md +73 -0
  8. package/assets/agent/security-reviewer.md +60 -0
  9. package/assets/agent/spec-writer.md +94 -0
  10. package/assets/agent/story-reader.md +38 -0
  11. package/assets/agent/story-writer.md +39 -0
  12. package/assets/agent/test-verifier.md +73 -0
  13. package/assets/agent/work-decomposer.md +102 -0
  14. package/assets/agent/work-reviewer.md +77 -0
  15. package/assets/command/feature.md +68 -0
  16. package/assets/skills/feature/SCHEMA.md +990 -0
  17. package/assets/skills/feature/SKILL.md +620 -0
  18. package/dist/tui.js +3641 -0
  19. package/package.json +65 -0
  20. package/src/cleanup-sweep-command.js +75 -0
  21. package/src/cleanup-sweep-eligibility.js +581 -0
  22. package/src/cleanup-sweep-output.js +139 -0
  23. package/src/cleanup-sweep-report.js +548 -0
  24. package/src/cleanup-sweep.js +546 -0
  25. package/src/cli-output.js +251 -0
  26. package/src/cli.js +1152 -0
  27. package/src/config.js +231 -0
  28. package/src/cost-attribution.js +327 -0
  29. package/src/cost-report.js +185 -0
  30. package/src/detached-log-supervisor.js +178 -0
  31. package/src/doctor.js +598 -0
  32. package/src/env-snapshot.js +211 -0
  33. package/src/factory-diagnostics.js +429 -0
  34. package/src/factory-paths.js +40 -0
  35. package/src/factory.js +4769 -0
  36. package/src/feature-command-payload.js +378 -0
  37. package/src/git.js +110 -0
  38. package/src/github.js +252 -0
  39. package/src/hardening/atomic-write.js +954 -0
  40. package/src/hardening/line-output.js +139 -0
  41. package/src/hardening/output-policy.js +365 -0
  42. package/src/hardening/process-verification.js +542 -0
  43. package/src/hardening/sensitive-data.js +341 -0
  44. package/src/hardening/terminal-encoding.js +224 -0
  45. package/src/plugin.js +246 -0
  46. package/src/post-pr-ci.js +754 -0
  47. package/src/process-evidence.js +1139 -0
  48. package/src/refs.js +144 -0
  49. package/src/run-state.js +2411 -0
  50. package/src/steering-conflicts.js +77 -0
  51. package/src/telemetry.js +419 -0
  52. package/src/tui-data.js +499 -0
  53. package/src/tui-rendering.js +148 -0
  54. package/src/utils.js +35 -0
  55. package/src/validate.js +1655 -0
  56. package/src/worktrees.js +56 -0
@@ -0,0 +1,581 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { collectCleanupTargets } from "./factory.js";
5
+ import { git } from "./git.js";
6
+ import { lookupPullRequest } from "./github.js";
7
+ import { inspectLaunchClaim, inspectProcessEvidenceForCleanup } from "./process-evidence.js";
8
+ import { createCandidate, createEmptyEvidence } from "./cleanup-sweep-report.js";
9
+ import { validateFactoryLock, validateHeartbeatState, validateRun } from "./validate.js";
10
+ import { parseWorktreeListPorcelain } from "./worktrees.js";
11
+
12
+ const PROTECTED_STATUS = Object.freeze({
13
+ blocked: "PROTECTED_STATUS_BLOCKED",
14
+ partial: "PROTECTED_STATUS_PARTIAL",
15
+ "needs-human": "PROTECTED_STATUS_NEEDS_HUMAN",
16
+ });
17
+ const SAFE_ENTRY = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/u;
18
+ const FILES = Object.freeze({ run: "run.json", factoryLock: "factory.lock", heartbeat: "heartbeat.json", process: "process.json", runLock: "run-json.lock" });
19
+ const utf8 = (left, right) => Buffer.from(left, "utf8").compare(Buffer.from(right, "utf8"));
20
+
21
+ /**
22
+ * Inspect immediate entries in this repository's factory root. The function
23
+ * never discovers an ancestor factory root and never deletes a temporary ref.
24
+ * Callers own deletion of every ref returned in temporary_refs.
25
+ */
26
+ export function discoverCleanupSweepCandidates(repo, options = {}) {
27
+ const repositoryRoot = inspectFilesystem(resolve(repo), "realpath", options);
28
+ const factoryRoot = join(repositoryRoot, ".opencode", "factory");
29
+ let root;
30
+ try {
31
+ root = inspectFilesystem(factoryRoot, "lstat", options);
32
+ } catch (error) {
33
+ if (error?.code === "ENOENT") return { factory_root: factoryRoot, candidates: [], temporary_refs: [] };
34
+ throw error;
35
+ }
36
+ if (root.isSymbolicLink() || !root.isDirectory() || inspectFilesystem(factoryRoot, "realpath", options) !== factoryRoot) {
37
+ throw new Error("factory root is not a safe physical directory");
38
+ }
39
+
40
+ const entries = inspectFilesystem(factoryRoot, "readdir", options).sort(utf8).map((entryName) => inspectEntry(factoryRoot, entryName, options));
41
+ const collisions = buildClaimInventory(entries, repositoryRoot, options);
42
+ const temporaryRefs = [];
43
+ const candidates = entries.map((entry) => {
44
+ try {
45
+ const result = classifyEntry(repositoryRoot, entry, collisions.get(entry.entryName) ?? false, {
46
+ ...options,
47
+ registerTemporaryRef: (temporaryRef) => temporaryRefs.push(temporaryRef),
48
+ });
49
+ return result.candidate;
50
+ } catch {
51
+ return failedCandidate(entry);
52
+ }
53
+ });
54
+ return { factory_root: factoryRoot, candidates, temporary_refs: [...new Set(temporaryRefs)].sort(utf8) };
55
+ }
56
+
57
+ export function classifyCleanupSweepCandidate(repo, entryName, options = {}) {
58
+ const repositoryRoot = inspectFilesystem(resolve(repo), "realpath", options);
59
+ const factoryRoot = join(repositoryRoot, ".opencode", "factory");
60
+ const entries = inspectFilesystem(factoryRoot, "readdir", options).sort(utf8).map((name) => inspectEntry(factoryRoot, name, options));
61
+ const entry = entries.find((item) => item.entryName === entryName);
62
+ if (!entry) throw new Error("cleanup sweep entry does not exist");
63
+ const collisions = buildClaimInventory(entries, repositoryRoot, options);
64
+ return classifyEntry(repositoryRoot, entry, collisions.get(entryName) ?? false, options);
65
+ }
66
+
67
+ function inspectEntry(factoryRoot, entryName, options) {
68
+ const logicalPath = join(factoryRoot, entryName);
69
+ const entry = { entryName, logicalPath, kind: "inaccessible", stat: null, physicalPath: null, rawRun: null };
70
+ try {
71
+ const value = inspectFilesystem(logicalPath, "lstat", options);
72
+ entry.stat = value;
73
+ entry.kind = value.isSymbolicLink() ? "symlink" : value.isDirectory() ? "directory" : value.isFile() ? "file" : "other";
74
+ if (entry.kind === "directory") entry.physicalPath = inspectFilesystem(logicalPath, "realpath", options);
75
+ } catch {
76
+ return entry;
77
+ }
78
+ if (entry.kind !== "directory") return entry;
79
+ try {
80
+ const read = inspectFilesystem(join(logicalPath, FILES.run), "read-json-no-follow", options);
81
+ if (read.state === "valid") {
82
+ entry.rawRun = read.value;
83
+ }
84
+ } catch {
85
+ // Classification records the invalid manifest; inventory remains synthetic.
86
+ }
87
+ return entry;
88
+ }
89
+
90
+ function buildClaimInventory(entries, repo, options) {
91
+ const owners = new Map();
92
+ const claimsByEntry = new Map();
93
+ for (const entry of entries) {
94
+ const claims = rawClaims(entry, repo, options);
95
+ claimsByEntry.set(entry.entryName, claims);
96
+ for (const key of [...claims.branchKeys, ...claims.worktreeKeys]) {
97
+ const claimants = owners.get(key) ?? new Set();
98
+ claimants.add(entry.entryName);
99
+ owners.set(key, claimants);
100
+ }
101
+ }
102
+ const result = new Map(entries.map((entry) => [entry.entryName, false]));
103
+ for (const [entryName, claims] of claimsByEntry) {
104
+ if ([...claims.branchKeys, ...claims.worktreeKeys].some((key) => owners.get(key)?.size > 1)) result.set(entryName, true);
105
+ }
106
+ return result;
107
+ }
108
+
109
+ function rawClaims(entry, repo, options) {
110
+ const branchValues = [entry.entryName];
111
+ const worktreeValues = [join(repo, ".opencode", "worktrees", entry.entryName)];
112
+ const run = entry.rawRun;
113
+ if (run && typeof run === "object" && !Array.isArray(run)) {
114
+ if (typeof run.branch === "string") branchValues.push(run.branch);
115
+ if (typeof run.worktree === "string") worktreeValues.push(run.worktree);
116
+ if (Array.isArray(run.slices)) for (const slice of run.slices) {
117
+ if (typeof slice?.branch === "string") branchValues.push(slice.branch);
118
+ if (typeof slice?.worktree === "string") worktreeValues.push(slice.worktree);
119
+ }
120
+ }
121
+ const branches = [...new Set(branchValues.filter(nonEmpty))].sort(utf8);
122
+ const worktrees = [...new Set(worktreeValues.filter(nonEmpty).map((value) => resolve(repo, value)))].sort(utf8);
123
+ return {
124
+ branches,
125
+ worktrees,
126
+ branchKeys: branches.map((value) => `branch:${value}`),
127
+ worktreeKeys: worktrees.flatMap((value) => worktreeClaimKeys(value, options)),
128
+ };
129
+ }
130
+
131
+ function worktreeClaimKeys(logical, options) {
132
+ const keys = [`worktree-logical:${logical}`];
133
+ try { keys.push(`worktree-physical:${inspectFilesystem(logical, "realpath", options)}`); } catch { /* logical key remains collision-safe */ }
134
+ return keys;
135
+ }
136
+
137
+ function classifyEntry(repo, entry, sharedClaim, options) {
138
+ const evidence = createEmptyEvidence(entry.entryName, entry.logicalPath);
139
+ evidence.entry = {
140
+ kind: entry.kind,
141
+ logical_path: entry.logicalPath,
142
+ physical_path: entry.physicalPath,
143
+ device: entry.stat ? String(entry.stat.dev) : null,
144
+ inode: entry.stat ? String(entry.stat.ino) : null,
145
+ };
146
+ const claims = rawClaims(entry, repo, options);
147
+ evidence.claims = { branches: claims.branches, worktrees: claims.worktrees };
148
+ evidence.worktree_root = inspectWorktreeRoot(repo, options);
149
+ const finish = (classification, reasonCodes, runId = evidence.run.run_id, temporaryRef = null) => ({
150
+ candidate: createCandidate({ entry_name: entry.entryName, run_id: runId, classification, reason_codes: sharedClaim ? [...reasonCodes, "SHARED_TARGET_CLAIM"] : reasonCodes, evidence }),
151
+ temporary_ref: temporaryRef,
152
+ });
153
+
154
+ if (entry.kind !== "directory" || entry.physicalPath !== entry.logicalPath || !SAFE_ENTRY.test(entry.entryName)) return finish("skipped", ["SKIPPED_UNSAFE_ENTRY"], null);
155
+ const runRead = inspectFilesystem(join(entry.logicalPath, FILES.run), "read-json-no-follow", options);
156
+ if (runRead.state === "missing") return finish("skipped", ["SKIPPED_PRE_MANIFEST"], null);
157
+ if (runRead.state !== "valid") {
158
+ evidence.run.state = runRead.state === "inaccessible" ? "inaccessible" : "invalid";
159
+ evidence.run.hash = runRead.hash;
160
+ return finish("skipped", ["SKIPPED_INVALID_RUN_STATE"], null);
161
+ }
162
+ evidence.run.hash = runRead.hash;
163
+ evidence.run.run_id = typeof runRead.value?.run_id === "string" ? runRead.value.run_id : null;
164
+ evidence.run.status = typeof runRead.value?.status === "string" ? runRead.value.status : null;
165
+ let run;
166
+ try { run = validateRun(runRead.value); } catch {
167
+ evidence.run.state = "invalid";
168
+ return finish("skipped", ["SKIPPED_INVALID_RUN_STATE"]);
169
+ }
170
+ evidence.run.state = "valid";
171
+ if (run.run_id !== entry.entryName) return finish("skipped", ["SKIPPED_RUN_ID_MISMATCH"]);
172
+ const sidecars = inspectSidecars(entry.logicalPath, run, evidence, options);
173
+ const protectedStatus = PROTECTED_STATUS[run.status];
174
+ if (protectedStatus) return finish("protected", [protectedStatus, ...sidecars.protected, ...sidecars.skipped]);
175
+ if (run.status !== "completed") return finish("skipped", ["SKIPPED_NON_TERMINAL_STATUS", ...sidecars.protected, ...sidecars.skipped]);
176
+ if (sharedClaim) return finish("skipped", [...sidecars.protected, ...sidecars.skipped]);
177
+ if (sidecars.protected.length) return finish("protected", [...sidecars.protected, ...sidecars.skipped]);
178
+ if (sidecars.skipped.length) return finish("skipped", sidecars.skipped);
179
+
180
+ const lookup = lookupPullRequest(repo, run, { githubRunner: options.githubRunner, timeout: options.githubTimeout, maxBuffer: options.githubMaxBuffer });
181
+ if (!lookup.ok) {
182
+ evidence.pr.state = lookup.reason === "metadata-mismatch" ? "missing-metadata" : "inaccessible";
183
+ return finish("skipped", [lookup.reason === "metadata-mismatch" ? "SKIPPED_PR_METADATA_MISMATCH" : "SKIPPED_PR_LOOKUP_UNCERTAIN"]);
184
+ }
185
+ const pr = lookup.pullRequest;
186
+ evidence.pr = { state: pr.state.toLowerCase(), url: pr.url, repository: pr.repository, number: pr.number, base_ref: pr.base_ref, base_sha: pr.base_sha };
187
+ if (pr.state === "OPEN") return finish("skipped", ["SKIPPED_PR_OPEN"]);
188
+
189
+ const temporaryRef = temporaryRefFor(entry.entryName, options.invocationId ?? randomUUID());
190
+ options.registerTemporaryRef?.(temporaryRef);
191
+ if (!verifyFetchedBase(repo, pr, temporaryRef, options.gitRunner ?? git)) return finish("skipped", ["SKIPPED_BASE_UNPROVABLE"], run.run_id, temporaryRef);
192
+ const targetResult = inspectTargets(repo, run, temporaryRef, pr.base_sha, evidence, options.gitRunner ?? git, options);
193
+ if (targetResult.reason) return finish("skipped", [targetResult.reason], run.run_id, temporaryRef);
194
+ return finish("eligible", ["ELIGIBLE"], run.run_id, temporaryRef);
195
+ }
196
+
197
+ function inspectSidecars(runDir, run, evidence, options) {
198
+ const protectedReasons = [];
199
+ const skipped = [];
200
+ const factoryLock = inspectValidatedFile(join(runDir, FILES.factoryLock), validateFactoryLock, run.run_id, options);
201
+ evidence.factory_lock = { state: factoryLock.state, hash: factoryLock.hash, active_owner: null };
202
+ if (!["missing", "valid-matching"].includes(factoryLock.state)) skipped.push("SKIPPED_FACTORY_LOCK_INVALID");
203
+
204
+ const heartbeat = inspectHeartbeat(runDir, run.run_id, options);
205
+ evidence.heartbeat = heartbeat.evidence;
206
+ if (["invalid", "mismatched", "indeterminate"].includes(heartbeat.evidence.state)) skipped.push("SKIPPED_HEARTBEAT_UNCERTAIN");
207
+ if (heartbeat.evidence.state === "valid-fresh") protectedReasons.push("PROTECTED_FRESH_HEARTBEAT");
208
+
209
+ const processInspector = options.inspectProcess ?? inspectProcessEvidenceForCleanup;
210
+ let processResult;
211
+ try { processResult = processInspector(runDir, { ...options.processOptions, runId: run.run_id }); } catch { processResult = { state: "indeterminate" }; }
212
+ const processHash = Object.hasOwn(processResult || {}, "hash") ? processResult.hash : safeFileHash(join(runDir, FILES.process), options);
213
+ evidence.process = { state: normalizeProcessState(processResult?.state), hash: processHash };
214
+ if (evidence.process.state === "live-matching") protectedReasons.push("PROTECTED_LIVE_PROCESS");
215
+ else if (!["missing", "absent"].includes(evidence.process.state)) skipped.push("SKIPPED_PROCESS_UNCERTAIN");
216
+
217
+ const launchClaimInspector = options.inspectLaunchClaim ?? inspectLaunchClaim;
218
+ let launchClaim;
219
+ try { launchClaim = launchClaimInspector(runDir, { ...options.processOptions, runId: run.run_id }); } catch { launchClaim = { state: "indeterminate" }; }
220
+ evidence.launch_claim = launchClaimEvidence(launchClaim);
221
+ if (evidence.launch_claim.state === "live-matching") protectedReasons.push("PROTECTED_LIVE_LAUNCH_CLAIM");
222
+ else if (evidence.launch_claim.state !== "missing") skipped.push("SKIPPED_LAUNCH_CLAIM_UNCERTAIN");
223
+
224
+ if (factoryLock.state === "valid-matching" && (heartbeat.evidence.state === "valid-fresh" || evidence.process.state === "live-matching")) {
225
+ evidence.factory_lock.active_owner = true;
226
+ protectedReasons.unshift("PROTECTED_ACTIVE_FACTORY_OWNER");
227
+ } else if (factoryLock.state === "valid-matching") evidence.factory_lock.active_owner = false;
228
+
229
+ const lockState = inspectRunLock(join(runDir, FILES.runLock), options.heldRunId === run.run_id, options);
230
+ evidence.run_lock = { observed_before_acquire: lockState, held_by_sweep: options.heldRunId === run.run_id };
231
+ if (lockState === "invalid") skipped.push("SKIPPED_RUN_LOCK_INVALID");
232
+ else if (lockState === "present" && options.heldRunId !== run.run_id) {
233
+ skipped.push(options.runLockContended ? "SKIPPED_RUN_LOCK_CONTENDED" : "SKIPPED_RUN_LOCK_PRESENT_PREVIEW");
234
+ }
235
+ return { protected: dedupe(protectedReasons), skipped: dedupe(skipped) };
236
+ }
237
+
238
+ function launchClaimEvidence(result) {
239
+ const ownerState = ["missing", "live-matching", "dead", "mismatched", "invalid", "indeterminate"].includes(result?.state) ? result.state
240
+ : result?.missing ? "missing"
241
+ : result?.ok && result.owner_status === "live" ? "live-matching"
242
+ : result?.ok && ["dead", "mismatched", "indeterminate"].includes(result.owner_status) ? result.owner_status
243
+ : "invalid";
244
+ return {
245
+ state: ownerState,
246
+ hash: typeof result?.hash === "string" ? result.hash : null,
247
+ dir_device: identityPart(result?.identity?.dir?.dev),
248
+ dir_inode: identityPart(result?.identity?.dir?.ino),
249
+ file_device: identityPart(result?.identity?.file?.dev),
250
+ file_inode: identityPart(result?.identity?.file?.ino),
251
+ };
252
+ }
253
+
254
+ function identityPart(value) {
255
+ if (typeof value === "string" && /^\d+$/u.test(value)) return value;
256
+ return typeof value === "bigint" || Number.isSafeInteger(value) ? String(value) : null;
257
+ }
258
+
259
+ function inspectHeartbeat(runDir, runId, options) {
260
+ const read = inspectFilesystem(join(runDir, FILES.heartbeat), "read-json-no-follow", options);
261
+ if (read.state === "missing") return { evidence: { state: "missing", hash: null, fresh: null } };
262
+ if (read.state !== "valid") return { evidence: { state: read.state === "inaccessible" ? "indeterminate" : "invalid", hash: read.hash, fresh: null } };
263
+ let heartbeat;
264
+ try { heartbeat = validateHeartbeatState(read.value); } catch { return { evidence: { state: "invalid", hash: read.hash, fresh: null } }; }
265
+ if (heartbeat.run_id !== runId) return { evidence: { state: "mismatched", hash: read.hash, fresh: null } };
266
+ const nowMs = clockMs(options.clock);
267
+ const tickMs = Date.parse(heartbeat.last_tick_at);
268
+ const interval = Number.isInteger(heartbeat.interval_ms) && heartbeat.interval_ms > 0 ? heartbeat.interval_ms : 30_000;
269
+ if (!Number.isFinite(nowMs) || !Number.isFinite(tickMs) || tickMs > nowMs + 5_000) return { evidence: { state: "indeterminate", hash: read.hash, fresh: null } };
270
+ const fresh = nowMs - tickMs <= Math.max(2 * interval, 120_000);
271
+ return { evidence: { state: fresh ? "valid-fresh" : "valid-stale", hash: read.hash, fresh } };
272
+ }
273
+
274
+ function inspectTargets(repo, run, temporaryRef, baseOid, evidence, gitRunner, options) {
275
+ const targets = collectCleanupTargets(run);
276
+ const worktreeTargets = recordedWorktreeAssociations(run);
277
+ if (targets.worktrees.length > 0 && evidence.worktree_root.state !== "valid") return { reason: "SKIPPED_WORKTREE_UNSAFE" };
278
+ let registered = [];
279
+ if (targets.branches.length > 0 || targets.worktrees.length > 0) {
280
+ const listed = gitRunner(repo, ["worktree", "list", "--porcelain"]);
281
+ if (!listed?.ok) return { reason: "SKIPPED_BRANCH_UNPROVABLE" };
282
+ registered = parseWorktreeListPorcelain(listed.stdout);
283
+ }
284
+ const associationConflict = conflictingWorktreeAssociation(repo, worktreeTargets, options);
285
+ if (associationConflict) {
286
+ appendConflictingWorktreeEvidence(evidence, repo, associationConflict, options);
287
+ return { reason: "SKIPPED_WORKTREE_IDENTITY" };
288
+ }
289
+ const registration = inspectRegisteredWorktreeClaims(repo, registered, worktreeTargets, targets.branches, options);
290
+ for (const branch of [...targets.branches].sort(utf8)) {
291
+ const record = { name: branch, expected_head: null, state: "unprovable", base_oid: baseOid };
292
+ evidence.branches.push(record);
293
+ if (!validBranch(repo, branch, gitRunner)) { record.state = "unsafe"; return { reason: "SKIPPED_BRANCH_UNSAFE" }; }
294
+ const head = gitRunner(repo, ["rev-parse", "--verify", `refs/heads/${branch}^{commit}`]);
295
+ if (!head?.ok || !oid(head.stdout.trim())) { record.state = "missing"; return { reason: "SKIPPED_BRANCH_MISSING" }; }
296
+ record.expected_head = head.stdout.trim();
297
+ const checkout = registration.unrecordedByBranch.get(branch);
298
+ if (checkout) {
299
+ record.state = checkout.current ? "current" : "checked-out-unrecorded";
300
+ return { reason: "SKIPPED_BRANCH_CHECKED_OUT" };
301
+ }
302
+ const ancestry = gitRunner(repo, ["merge-base", "--is-ancestor", `refs/heads/${branch}`, temporaryRef]);
303
+ if (ancestry?.status === 1) { record.state = "unmerged"; return { reason: "SKIPPED_BRANCH_UNMERGED" }; }
304
+ if (!ancestry?.ok) { record.state = "unprovable"; return { reason: "SKIPPED_BRANCH_UNPROVABLE" }; }
305
+ record.state = "verified-ancestor";
306
+ }
307
+ if (registration.identityConflict) {
308
+ appendConflictingWorktreeEvidence(evidence, repo, registration.identityConflict, options);
309
+ return { reason: "SKIPPED_WORKTREE_IDENTITY" };
310
+ }
311
+ for (const target of dedupeWorktreeAssociations(worktreeTargets)) {
312
+ const result = inspectWorktree(repo, target, registered, evidence.branches, evidence.worktree_root, options);
313
+ evidence.worktrees.push(result.record);
314
+ if (result.reason) return { reason: result.reason };
315
+ }
316
+ return { reason: null };
317
+ }
318
+
319
+ function recordedWorktreeAssociations(run) {
320
+ const entries = [];
321
+ if (run.worktree) entries.push({ worktree: run.worktree, branch: run.branch });
322
+ if (Array.isArray(run.slices)) {
323
+ for (const slice of run.slices) if (slice?.worktree) entries.push({ worktree: slice.worktree, branch: slice.branch });
324
+ }
325
+ return entries.sort((left, right) => utf8(String(left.worktree), String(right.worktree)) || utf8(String(left.branch ?? ""), String(right.branch ?? "")));
326
+ }
327
+
328
+ function conflictingWorktreeAssociation(repo, targets, options) {
329
+ const associations = new Map();
330
+ for (const target of targets) {
331
+ const logical = resolve(repo, target.worktree);
332
+ const keys = new Set([logical]);
333
+ const physical = physicalPathOrNull(logical, options);
334
+ if (physical) keys.add(physical);
335
+ for (const key of keys) {
336
+ const byBranch = associations.get(key) ?? new Map();
337
+ const branch = target.branch ?? null;
338
+ const entries = byBranch.get(branch) ?? [];
339
+ entries.push(target);
340
+ byBranch.set(branch, entries);
341
+ associations.set(key, byBranch);
342
+ }
343
+ }
344
+ for (const byBranch of associations.values()) {
345
+ if (byBranch.size > 1) return [...byBranch.values()].flat().sort((left, right) => utf8(String(left.worktree), String(right.worktree)) || utf8(String(left.branch ?? ""), String(right.branch ?? "")));
346
+ }
347
+ return null;
348
+ }
349
+
350
+ function inspectRegisteredWorktreeClaims(repo, registered, targets, branches, options) {
351
+ const recordedBranches = new Set(branches);
352
+ const associationsByBranch = new Map(branches.map((branch) => [branch, new Set()]));
353
+ const associationsByPath = new Map();
354
+ for (const target of targets) {
355
+ const keys = pathIdentityKeys(repo, target.worktree, options);
356
+ const branch = target.branch ?? null;
357
+ if (recordedBranches.has(branch)) for (const key of keys) associationsByBranch.get(branch).add(key);
358
+ for (const key of keys) {
359
+ const expected = associationsByPath.get(key) ?? new Set();
360
+ expected.add(branch);
361
+ associationsByPath.set(key, expected);
362
+ }
363
+ }
364
+
365
+ const relevant = [];
366
+ for (const item of registered) {
367
+ const keys = pathIdentityKeys(repo, item.path, options);
368
+ const expectedBranches = new Set([...keys].flatMap((key) => [...(associationsByPath.get(key) ?? [])]));
369
+ if (recordedBranches.has(item.branch) || expectedBranches.size > 0) relevant.push({ item, keys, expectedBranches });
370
+ }
371
+
372
+ const unrecordedByBranch = new Map();
373
+ for (const branch of recordedBranches) {
374
+ const checkouts = relevant.filter(({ item }) => item.branch === branch);
375
+ const allowed = associationsByBranch.get(branch);
376
+ const outside = checkouts.find(({ keys }) => ![...keys].some((key) => allowed.has(key)));
377
+ if (outside) {
378
+ unrecordedByBranch.set(branch, { current: outside.keys.has(repo) });
379
+ } else if (checkouts.length > 1) {
380
+ unrecordedByBranch.set(branch, { current: checkouts.some(({ keys }) => keys.has(repo)) });
381
+ }
382
+ }
383
+
384
+ for (let left = 0; left < relevant.length; left += 1) {
385
+ for (let right = left + 1; right < relevant.length; right += 1) {
386
+ if ([...relevant[left].keys].some((key) => relevant[right].keys.has(key))) {
387
+ return { unrecordedByBranch, identityConflict: targetsForRegistrationConflict([relevant[left], relevant[right]], targets, repo, options) };
388
+ }
389
+ }
390
+ }
391
+ for (const record of relevant) {
392
+ if (record.expectedBranches.size > 0
393
+ && (!record.expectedBranches.has(record.item.branch) || !oid(record.item.head))) {
394
+ return { unrecordedByBranch, identityConflict: targetsForRegistrationConflict([record], targets, repo, options) };
395
+ }
396
+ }
397
+ return { unrecordedByBranch, identityConflict: null };
398
+ }
399
+
400
+ function targetsForRegistrationConflict(records, targets, repo, options) {
401
+ const keys = new Set(records.flatMap((record) => [...record.keys]));
402
+ const matching = targets.filter((target) => [...pathIdentityKeys(repo, target.worktree, options)].some((key) => keys.has(key)));
403
+ return matching.length > 0 ? matching : targets;
404
+ }
405
+
406
+ function appendConflictingWorktreeEvidence(evidence, repo, targets, options) {
407
+ for (const target of targets) {
408
+ const logical = resolve(repo, target.worktree);
409
+ const directoryIdentity = noFollowDirectoryIdentity(logical, options);
410
+ evidence.worktrees.push({
411
+ recorded_path: String(target.worktree),
412
+ physical_path: physicalPathOrNull(logical, options),
413
+ device: directoryIdentity?.device ?? null,
414
+ inode: directoryIdentity?.inode ?? null,
415
+ branch: target.branch ?? null,
416
+ head: null,
417
+ state: "branch-mismatch",
418
+ });
419
+ }
420
+ }
421
+
422
+ function noFollowDirectoryIdentity(path, options) {
423
+ try {
424
+ const entry = inspectFilesystem(path, "lstat", options);
425
+ if (entry.isSymbolicLink() || !entry.isDirectory()) return null;
426
+ return { device: String(entry.dev), inode: String(entry.ino) };
427
+ } catch {
428
+ return null;
429
+ }
430
+ }
431
+
432
+ function pathIdentityKeys(repo, path, options) {
433
+ if (!nonEmpty(path)) return new Set();
434
+ const logical = resolve(repo, path);
435
+ const keys = new Set([logical]);
436
+ const physical = physicalPathOrNull(logical, options);
437
+ if (physical) keys.add(physical);
438
+ return keys;
439
+ }
440
+
441
+ function dedupeWorktreeAssociations(targets) {
442
+ const seen = new Set();
443
+ return targets.filter((target) => {
444
+ const key = `${String(target.worktree)}\0${String(target.branch ?? "")}`;
445
+ if (seen.has(key)) return false;
446
+ seen.add(key);
447
+ return true;
448
+ });
449
+ }
450
+
451
+ function physicalPathOrNull(path, options) {
452
+ try { return inspectFilesystem(path, "realpath", options); } catch { return null; }
453
+ }
454
+
455
+ function inspectWorktree(repo, target, registered, branches, worktreeRoot, options) {
456
+ const recordedPath = String(target.worktree);
457
+ const logical = resolve(repo, recordedPath);
458
+ const record = { recorded_path: recordedPath, physical_path: null, device: null, inode: null, branch: target.branch ?? null, head: null, state: "unprovable" };
459
+ const root = worktreeRoot.logical_path ?? join(repo, ".opencode", "worktrees");
460
+ if (!contained(root, logical)) { record.state = "outside-root"; return { record, reason: "SKIPPED_WORKTREE_UNSAFE" }; }
461
+ let value;
462
+ try { value = inspectFilesystem(logical, "lstat", options); } catch { record.state = "missing"; return { record, reason: "SKIPPED_WORKTREE_MISSING" }; }
463
+ if (value.isSymbolicLink()) { record.state = "symlink"; return { record, reason: "SKIPPED_WORKTREE_UNSAFE" }; }
464
+ if (!value.isDirectory()) { record.state = "missing"; return { record, reason: "SKIPPED_WORKTREE_MISSING" }; }
465
+ record.device = String(value.dev);
466
+ record.inode = String(value.ino);
467
+ try { record.physical_path = inspectFilesystem(logical, "realpath", options); } catch { record.state = "unprovable"; return { record, reason: "SKIPPED_WORKTREE_UNSAFE" }; }
468
+ if (worktreeRoot.state !== "valid" || worktreeRoot.physical_path !== root || !contained(root, record.physical_path)) { record.state = "outside-root"; return { record, reason: "SKIPPED_WORKTREE_UNSAFE" }; }
469
+ const registeredEntries = registered.filter((item) => pathIdentityKeys(repo, item.path, options).has(record.physical_path));
470
+ if (registeredEntries.length > 1) { record.state = "branch-mismatch"; return { record, reason: "SKIPPED_WORKTREE_IDENTITY" }; }
471
+ const registeredEntry = registeredEntries[0];
472
+ if (!registeredEntry) { record.state = "unregistered"; return { record, reason: "SKIPPED_WORKTREE_UNREGISTERED" }; }
473
+ record.branch = registeredEntry.branch;
474
+ record.head = registeredEntry.head;
475
+ const branch = branches.find((item) => item.name === target.branch);
476
+ if (!target.branch || registeredEntry.branch !== target.branch) { record.state = "branch-mismatch"; return { record, reason: "SKIPPED_WORKTREE_IDENTITY" }; }
477
+ if (!branch?.expected_head || registeredEntry.head !== branch.expected_head) { record.state = "head-mismatch"; return { record, reason: "SKIPPED_WORKTREE_IDENTITY" }; }
478
+ record.state = "verified";
479
+ return { record, reason: null };
480
+ }
481
+
482
+ function verifyFetchedBase(repo, pr, temporaryRef, gitRunner) {
483
+ if (!validBranch(repo, pr.base_ref, gitRunner) || !oid(pr.base_sha)) return false;
484
+ if (gitRunner(repo, ["check-ref-format", temporaryRef])?.ok !== true) return false;
485
+ const fetch = gitRunner(repo, ["fetch", "--no-tags", "--no-recurse-submodules", "--no-write-fetch-head", "--force", `https://github.com/${pr.repository}.git`, `+refs/heads/${pr.base_ref}:${temporaryRef}`]);
486
+ if (!fetch?.ok) return false;
487
+ const resolved = gitRunner(repo, ["rev-parse", "--verify", `${temporaryRef}^{commit}`]);
488
+ return Boolean(resolved?.ok && resolved.stdout.trim() === pr.base_sha);
489
+ }
490
+
491
+ function validBranch(repo, branch, gitRunner) {
492
+ if (!nonEmpty(branch) || branch.startsWith("-") || branch.includes("\0")) return false;
493
+ return gitRunner(repo, ["check-ref-format", "--branch", branch])?.ok === true;
494
+ }
495
+
496
+ function inspectValidatedFile(path, validator, runId, options) {
497
+ const read = inspectFilesystem(path, "read-json-no-follow", options);
498
+ if (read.state === "missing") return { state: "missing", hash: null };
499
+ if (read.state !== "valid") return { state: read.state === "inaccessible" ? "inaccessible" : "invalid", hash: read.hash };
500
+ try {
501
+ const value = validator(read.value);
502
+ return { state: value.run_id === runId ? "valid-matching" : "valid-mismatched", hash: read.hash };
503
+ } catch { return { state: "invalid", hash: read.hash }; }
504
+ }
505
+
506
+ function inspectRunLock(path, held, options) {
507
+ try {
508
+ const value = inspectFilesystem(path, "lstat", options);
509
+ if (value.isSymbolicLink() || !value.isDirectory()) return "invalid";
510
+ return held ? "missing" : "present";
511
+ } catch (error) { return error?.code === "ENOENT" ? "missing" : "invalid"; }
512
+ }
513
+
514
+ function readJsonNoFollow(path) {
515
+ let descriptor;
516
+ try {
517
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
518
+ if (!fstatSync(descriptor).isFile()) return { state: "invalid", value: null, hash: null };
519
+ const bytes = readFileSync(descriptor);
520
+ const hash = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
521
+ let value;
522
+ try { value = JSON.parse(bytes.toString("utf8")); }
523
+ catch { return { state: "invalid", value: null, hash }; }
524
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { state: "invalid", value, hash };
525
+ return { state: "valid", value, hash };
526
+ } catch (error) {
527
+ if (error?.code === "ENOENT") return { state: "missing", value: null, hash: null };
528
+ if (["ELOOP", "EFTYPE"].includes(error?.code)) return { state: "invalid", value: null, hash: null };
529
+ return { state: "inaccessible", value: null, hash: null };
530
+ } finally { if (descriptor !== undefined) closeSync(descriptor); }
531
+ }
532
+
533
+ function inspectWorktreeRoot(repo, options) {
534
+ const logicalPath = join(repo, ".opencode", "worktrees");
535
+ const evidence = { state: "unsafe", logical_path: logicalPath, physical_path: null, device: null, inode: null };
536
+ try {
537
+ const entry = inspectFilesystem(logicalPath, "lstat", options);
538
+ if (entry.isSymbolicLink() || !entry.isDirectory()) return evidence;
539
+ const physicalPath = inspectFilesystem(logicalPath, "realpath", options);
540
+ if (physicalPath !== logicalPath) return evidence;
541
+ return { state: "valid", logical_path: logicalPath, physical_path: physicalPath, device: String(entry.dev), inode: String(entry.ino) };
542
+ } catch (error) {
543
+ return { ...evidence, state: error?.code === "ENOENT" ? "missing" : "unsafe" };
544
+ }
545
+ }
546
+
547
+ function failedCandidate(entry) {
548
+ const evidence = createEmptyEvidence(entry.entryName, entry.logicalPath);
549
+ evidence.entry = { kind: entry.kind, logical_path: entry.logicalPath, physical_path: entry.physicalPath, device: entry.stat ? String(entry.stat.dev) : null, inode: entry.stat ? String(entry.stat.ino) : null };
550
+ return createCandidate({ entry_name: entry.entryName, run_id: null, classification: "failed", reason_codes: ["FAILED_INSPECTION"], failure_stage: "inspection", evidence });
551
+ }
552
+
553
+ function temporaryRefFor(runId, invocationId) {
554
+ const safeInvocation = String(invocationId).replace(/[^A-Za-z0-9._-]/gu, "-");
555
+ const hash = createHash("sha256").update(runId).digest("hex");
556
+ return `refs/feature-factory/cleanup-sweep/v1/${safeInvocation}/${hash}`;
557
+ }
558
+ function inspectFilesystem(path, operation, options = {}) {
559
+ const inspectDefault = () => defaultFilesystemInspection(path, operation);
560
+ const injected = options?.fsInspector;
561
+ if (typeof injected === "function") {
562
+ const result = injected(path, { operation, inspectDefault });
563
+ return result === undefined ? inspectDefault() : result;
564
+ }
565
+ if (injected && typeof injected[operation] === "function") return injected[operation](path, { inspectDefault });
566
+ return inspectDefault();
567
+ }
568
+ function defaultFilesystemInspection(path, operation) {
569
+ if (operation === "lstat") return lstatSync(path);
570
+ if (operation === "realpath") return realpathSync(path);
571
+ if (operation === "readdir") return readdirSync(path);
572
+ if (operation === "read-json-no-follow") return readJsonNoFollow(path);
573
+ throw new TypeError(`unsupported filesystem inspection operation: ${operation}`);
574
+ }
575
+ function safeFileHash(path, options) { const read = inspectFilesystem(path, "read-json-no-follow", options); return read.hash; }
576
+ function normalizeProcessState(value) { return ["missing", "live-matching", "absent", "mismatched", "invalid", "indeterminate"].includes(value) ? value : "indeterminate"; }
577
+ function clockMs(clock) { try { const value = typeof clock === "function" ? clock() : Date.now(); return value instanceof Date ? value.getTime() : Number(value); } catch { return Number.NaN; } }
578
+ function oid(value) { return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(String(value)); }
579
+ function nonEmpty(value) { return typeof value === "string" && value.trim() !== ""; }
580
+ function dedupe(values) { return [...new Set(values)]; }
581
+ function contained(root, target) { const rel = relative(resolve(root), resolve(target)); return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); }