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,548 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute } from "node:path";
3
+
4
+ export const CLEANUP_SWEEP_SCHEMA_VERSION = 1;
5
+ export const CLEANUP_SWEEP_DIGEST_PREFIX = "ff-cleanup-v1";
6
+ export const CLEANUP_SWEEP_CLASSIFICATIONS = Object.freeze(["eligible", "protected", "skipped", "failed", "deleted"]);
7
+ export const CLEANUP_SWEEP_REPORT_MODES = Object.freeze(["preview", "execute"]);
8
+ export const CLEANUP_SWEEP_REPORT_STATUSES = Object.freeze(["previewed", "completed", "refused", "failed"]);
9
+ export const CLEANUP_SWEEP_REFUSAL_CODES = Object.freeze(["DIGEST_FOREIGN", "DIGEST_STALE"]);
10
+ export const CLEANUP_SWEEP_REPORT_ERROR_CODES = Object.freeze(["FAILED_FACTORY_ROOT", "FAILED_TEMP_REF_CLEANUP", "FAILED_ORCHESTRATION"]);
11
+
12
+ const REASON_ENTRIES = [
13
+ ["SKIPPED_UNSAFE_ENTRY", "The factory entry is not a safe readable run directory."],
14
+ ["SKIPPED_PRE_MANIFEST", "The factory entry has no run manifest and requires manual handling."],
15
+ ["SKIPPED_INVALID_RUN_STATE", "The run manifest is invalid or cannot be read safely."],
16
+ ["SKIPPED_RUN_ID_MISMATCH", "The run manifest identity does not match its factory entry."],
17
+ ["PROTECTED_STATUS_BLOCKED", "The run contains blocked recoverable work."],
18
+ ["PROTECTED_STATUS_PARTIAL", "The run contains partial recoverable work."],
19
+ ["PROTECTED_STATUS_NEEDS_HUMAN", "The run requires human recovery."],
20
+ ["SKIPPED_NON_TERMINAL_STATUS", "The run is not completed."],
21
+ ["SHARED_TARGET_CLAIM", "A cleanup target is claimed by more than one factory entry."],
22
+ ["SKIPPED_FACTORY_LOCK_INVALID", "Factory ownership evidence is invalid or contradictory."],
23
+ ["PROTECTED_ACTIVE_FACTORY_OWNER", "Active factory ownership is positively established."],
24
+ ["PROTECTED_FRESH_HEARTBEAT", "The run has a fresh heartbeat."],
25
+ ["SKIPPED_HEARTBEAT_UNCERTAIN", "Heartbeat evidence is invalid, mismatched, or indeterminate."],
26
+ ["PROTECTED_LIVE_PROCESS", "A live identity-matching factory process references the run."],
27
+ ["SKIPPED_PROCESS_UNCERTAIN", "Process evidence is invalid, mismatched, or indeterminate."],
28
+ ["PROTECTED_LIVE_LAUNCH_CLAIM", "A live identity-matching launch claimant owns the run."],
29
+ ["SKIPPED_LAUNCH_CLAIM_UNCERTAIN", "Launch ownership evidence is present but invalid, mismatched, dead, ownerless, or indeterminate."],
30
+ ["SKIPPED_RUN_LOCK_PRESENT_PREVIEW", "A run-state lock is present during preview."],
31
+ ["SKIPPED_RUN_LOCK_INVALID", "Run-state lock evidence is unsafe or indeterminate."],
32
+ ["SKIPPED_RUN_LOCK_CONTENDED", "The run-state lock became contended before cleanup."],
33
+ ["SKIPPED_PR_METADATA_MISMATCH", "Canonical recorded pull-request metadata is missing or inconsistent."],
34
+ ["SKIPPED_PR_LOOKUP_UNCERTAIN", "Current pull-request state could not be verified."],
35
+ ["SKIPPED_PR_OPEN", "The recorded pull request is still open."],
36
+ ["SKIPPED_BASE_UNPROVABLE", "A trustworthy freshly fetched pull-request base could not be established."],
37
+ ["SKIPPED_BRANCH_UNSAFE", "A recorded branch name is unsafe."],
38
+ ["SKIPPED_BRANCH_MISSING", "A recorded local branch is missing or has no resolvable commit."],
39
+ ["SKIPPED_BRANCH_CHECKED_OUT", "A recorded branch is current or checked out outside its recorded worktree."],
40
+ ["SKIPPED_BRANCH_UNMERGED", "A recorded branch contains commits outside the fetched pull-request base."],
41
+ ["SKIPPED_BRANCH_UNPROVABLE", "Recorded branch ancestry could not be proven."],
42
+ ["SKIPPED_WORKTREE_UNSAFE", "A recorded worktree path is outside the safe worktree root."],
43
+ ["SKIPPED_WORKTREE_MISSING", "A recorded worktree is missing or inaccessible."],
44
+ ["SKIPPED_WORKTREE_UNREGISTERED", "A recorded worktree is not registered by Git."],
45
+ ["SKIPPED_WORKTREE_IDENTITY", "A recorded worktree does not match its expected branch and commit."],
46
+ ["ELIGIBLE", "All cleanup eligibility conditions were positively established."],
47
+ ["SKIPPED_CHANGED_DURING_EXECUTION", "Cleanup evidence changed during execution revalidation."],
48
+ ["FAILED_CLEANUP_WORKTREE", "A validated worktree could not be removed."],
49
+ ["FAILED_CLEANUP_BRANCH", "A validated branch could not be deleted at its expected commit."],
50
+ ["DELETED", "The eligible run was deleted."],
51
+ ["FAILED_CLEANUP_RUN_DIR", "Validated targets were removed but the run directory could not be removed."],
52
+ ["RETAINED_AFTER_PARTIAL_FAILURE", "The run directory was retained after an earlier cleanup failure."],
53
+ ["FAILED_CLEANUP_UNEXPECTED", "An unexpected error occurred after cleanup mutation began."],
54
+ ["FAILED_INSPECTION", "Candidate inspection could not be completed safely."],
55
+ ];
56
+
57
+ export const CLEANUP_SWEEP_REASON_REGISTRY = Object.freeze(Object.fromEntries(
58
+ REASON_ENTRIES.map(([code, message]) => [code, Object.freeze({ code, message })]),
59
+ ));
60
+
61
+ export const CLEANUP_SWEEP_REPORT_ERROR_REGISTRY = Object.freeze({
62
+ FAILED_FACTORY_ROOT: Object.freeze({ code: "FAILED_FACTORY_ROOT", message: "The selected repository factory root could not be inspected as a safe physical directory." }),
63
+ FAILED_TEMP_REF_CLEANUP: Object.freeze({ code: "FAILED_TEMP_REF_CLEANUP", message: "A cleanup-sweep temporary Git reference could not be removed safely." }),
64
+ FAILED_ORCHESTRATION: Object.freeze({ code: "FAILED_ORCHESTRATION", message: "Cleanup sweep orchestration failed outside candidate inspection or cleanup." }),
65
+ });
66
+
67
+ export const CLEANUP_SWEEP_REFUSAL_REGISTRY = Object.freeze({
68
+ DIGEST_FOREIGN: Object.freeze({ code: "DIGEST_FOREIGN", message: "The supplied digest was created for a different repository." }),
69
+ DIGEST_STALE: Object.freeze({ code: "DIGEST_STALE", message: "Repository cleanup evidence changed after the supplied digest was created." }),
70
+ });
71
+
72
+ const REPOSITORY_KEYS = ["schema_version", "root_path", "root_device", "root_inode", "git_common_dir_path", "git_common_dir_device", "git_common_dir_inode", "object_format"];
73
+ const EVIDENCE_KEYS = ["entry", "claims", "run", "factory_lock", "heartbeat", "process", "launch_claim", "run_lock", "pr", "worktree_root", "branches", "worktrees"];
74
+ const HASH = /^sha256:[0-9a-f]{64}$/u;
75
+ const DIGEST = /^ff-cleanup-v1\.([0-9a-f]{64})\.([0-9a-f]{64})$/u;
76
+ const REASON_ORDER = new Map(REASON_ENTRIES.map(([code], index) => [code, index]));
77
+ const utf8Collator = (left, right) => Buffer.from(left, "utf8").compare(Buffer.from(right, "utf8"));
78
+
79
+ export function canonicalJson(value) {
80
+ if (value === null || typeof value !== "object") {
81
+ const encoded = JSON.stringify(value);
82
+ if (encoded === undefined) throw new TypeError("canonical JSON does not support undefined or non-JSON primitive values");
83
+ return encoded;
84
+ }
85
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
86
+ const keys = Object.keys(value).sort(utf8Collator);
87
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
88
+ }
89
+
90
+ export function sha256Canonical(value) {
91
+ return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
92
+ }
93
+
94
+ export function createRepositoryIdentity(input) {
95
+ assertRecord(input, "repository");
96
+ assertExactKeys(input, REPOSITORY_KEYS, "repository");
97
+ if (input.schema_version !== 1) throw new TypeError("repository.schema_version must be 1");
98
+ for (const key of REPOSITORY_KEYS.slice(1)) assertNonEmptyString(input[key], `repository.${key}`);
99
+ for (const key of ["root_path", "git_common_dir_path"]) if (!isAbsolute(input[key])) throw new TypeError(`repository.${key} must be an absolute path`);
100
+ for (const key of ["root_device", "root_inode", "git_common_dir_device", "git_common_dir_inode"]) {
101
+ if (!/^\d+$/u.test(input[key])) throw new TypeError(`repository.${key} must be a decimal string`);
102
+ }
103
+ if (!/^(?:sha1|sha256)$/u.test(input.object_format)) throw new TypeError("repository.object_format must be sha1 or sha256");
104
+ return Object.fromEntries(REPOSITORY_KEYS.map((key) => [key, input[key]]));
105
+ }
106
+
107
+ export function repositoryDigest(repository) {
108
+ return sha256Canonical(createRepositoryIdentity(repository));
109
+ }
110
+
111
+ export function createReason(code) {
112
+ const reason = CLEANUP_SWEEP_REASON_REGISTRY[code];
113
+ if (!reason) throw new TypeError(`unknown cleanup sweep reason code: ${String(code)}`);
114
+ return { ...reason };
115
+ }
116
+
117
+ export function createReportError(code) {
118
+ const error = CLEANUP_SWEEP_REPORT_ERROR_REGISTRY[code];
119
+ if (!error) throw new TypeError(`unknown cleanup sweep report error code: ${String(code)}`);
120
+ return { ...error };
121
+ }
122
+
123
+ export function createEmptyEvidence(entryName, logicalPath = entryName) {
124
+ assertNonEmptyString(entryName, "entryName");
125
+ assertNonEmptyString(logicalPath, "logicalPath");
126
+ return {
127
+ entry: { kind: "inaccessible", logical_path: logicalPath, physical_path: null, device: null, inode: null },
128
+ claims: { branches: [], worktrees: [] },
129
+ run: { state: "missing", hash: null, run_id: null, status: null },
130
+ factory_lock: { state: "missing", hash: null, active_owner: null },
131
+ heartbeat: { state: "missing", hash: null, fresh: null },
132
+ process: { state: "missing", hash: null },
133
+ launch_claim: { state: "missing", hash: null, dir_device: null, dir_inode: null, file_device: null, file_inode: null },
134
+ run_lock: { observed_before_acquire: "missing", held_by_sweep: false },
135
+ pr: { state: "not-checked", url: null, repository: null, number: null, base_ref: null, base_sha: null },
136
+ worktree_root: { state: "missing", logical_path: null, physical_path: null, device: null, inode: null },
137
+ branches: [],
138
+ worktrees: [],
139
+ };
140
+ }
141
+
142
+ export function createCandidate(input) {
143
+ assertRecord(input, "candidate input");
144
+ const entryName = requiredString(input.entry_name, "candidate.entry_name");
145
+ const classification = enumValue(input.classification, CLEANUP_SWEEP_CLASSIFICATIONS, "candidate.classification");
146
+ const runId = nullableString(input.run_id, "candidate.run_id");
147
+ const reasonCodes = dedupeReasons(input.reason_codes ?? []);
148
+ const evidence = normalizeEvidence(input.evidence ?? createEmptyEvidence(entryName));
149
+ const evidenceDigest = `sha256:${sha256Canonical(evidence)}`;
150
+ if (input.evidence_digest !== undefined && input.evidence_digest !== evidenceDigest) {
151
+ throw new TypeError("candidate.evidence_digest does not match canonical evidence");
152
+ }
153
+ const failureStage = input.failure_stage ?? null;
154
+ if (failureStage !== null && failureStage !== "inspection" && failureStage !== "cleanup") throw new TypeError("candidate.failure_stage must be inspection, cleanup, or null");
155
+ const attemptedCleanup = input.attempted_cleanup ?? false;
156
+ if (typeof attemptedCleanup !== "boolean") throw new TypeError("candidate.attempted_cleanup must be boolean");
157
+ const cleanup = normalizeCleanup(input.cleanup ?? null);
158
+ assertCandidateMutationState({ classification, failureStage, attemptedCleanup, cleanup });
159
+ return {
160
+ schema_version: 1,
161
+ kind: "factory-cleanup-candidate",
162
+ entry_name: entryName,
163
+ run_id: runId,
164
+ classification,
165
+ reason_codes: reasonCodes,
166
+ reasons: reasonCodes.map(createReason),
167
+ failure_stage: failureStage,
168
+ attempted_cleanup: attemptedCleanup,
169
+ evidence_digest: evidenceDigest,
170
+ evidence,
171
+ cleanup,
172
+ };
173
+ }
174
+
175
+ export function validateCandidate(candidate) {
176
+ assertExactKeys(candidate, ["schema_version", "kind", "entry_name", "run_id", "classification", "reason_codes", "reasons", "failure_stage", "attempted_cleanup", "evidence_digest", "evidence", "cleanup"], "candidate");
177
+ if (candidate.schema_version !== 1 || candidate.kind !== "factory-cleanup-candidate") throw new TypeError("candidate schema or kind is invalid");
178
+ const normalized = createCandidate(candidate);
179
+ if (canonicalJson(candidate) !== canonicalJson(normalized)) throw new TypeError("candidate is not in the closed canonical schema");
180
+ return normalized;
181
+ }
182
+
183
+ export function sortCandidates(candidates) {
184
+ if (!Array.isArray(candidates)) throw new TypeError("candidates must be an array");
185
+ return [...candidates].sort((left, right) => utf8Collator(left.entry_name, right.entry_name));
186
+ }
187
+
188
+ export function createAuthorizationEnvelope(repository, candidates) {
189
+ const normalizedRepository = createRepositoryIdentity(repository);
190
+ const normalizedCandidates = sortCandidates(candidates.map(validateCandidate)).map((candidate) => {
191
+ if (candidate.classification === "deleted") throw new TypeError("authorization candidates cannot be deleted");
192
+ return {
193
+ entry_name: candidate.entry_name,
194
+ run_id: candidate.run_id,
195
+ classification: candidate.classification,
196
+ reason_codes: [...candidate.reason_codes],
197
+ evidence_digest: candidate.evidence_digest,
198
+ };
199
+ });
200
+ return {
201
+ schema_version: 1,
202
+ kind: "factory-cleanup-sweep-authorization",
203
+ repository: normalizedRepository,
204
+ candidates: normalizedCandidates,
205
+ };
206
+ }
207
+
208
+ export function createCleanupSweepDigest(repository, candidates) {
209
+ const envelope = createAuthorizationEnvelope(repository, candidates);
210
+ return `${CLEANUP_SWEEP_DIGEST_PREFIX}.${repositoryDigest(envelope.repository)}.${sha256Canonical(envelope)}`;
211
+ }
212
+
213
+ export function parseCleanupSweepDigest(value) {
214
+ if (typeof value !== "string") throw new TypeError("cleanup sweep digest must be a string");
215
+ const match = DIGEST.exec(value);
216
+ if (!match) throw new TypeError("cleanup sweep digest is malformed");
217
+ return { schema_version: 1, repository_sha256: match[1], envelope_sha256: match[2], digest: value };
218
+ }
219
+
220
+ export function compareCleanupSweepDigest(providedDigest, repository, candidates) {
221
+ const provided = parseCleanupSweepDigest(providedDigest);
222
+ const expected = parseCleanupSweepDigest(createCleanupSweepDigest(repository, candidates));
223
+ if (provided.repository_sha256 !== expected.repository_sha256) return { matched: false, refusal_code: "DIGEST_FOREIGN", digest: expected.digest };
224
+ if (provided.envelope_sha256 !== expected.envelope_sha256) return { matched: false, refusal_code: "DIGEST_STALE", digest: expected.digest };
225
+ return { matched: true, refusal_code: null, digest: expected.digest };
226
+ }
227
+
228
+ export function countCandidates(candidates) {
229
+ const counts = { eligible: 0, protected: 0, skipped: 0, deleted: 0, failed: 0 };
230
+ for (const candidate of candidates) counts[enumValue(candidate.classification, CLEANUP_SWEEP_CLASSIFICATIONS, "candidate.classification")] += 1;
231
+ return counts;
232
+ }
233
+
234
+ export function countAttemptedCleanupFailures(candidates) {
235
+ return candidates.reduce((count, candidate) => count + (candidate.attempted_cleanup === true && candidate.classification === "failed" ? 1 : 0), 0);
236
+ }
237
+
238
+ export function createCleanupSweepReport(input) {
239
+ assertRecord(input, "report input");
240
+ const mode = enumValue(input.mode, CLEANUP_SWEEP_REPORT_MODES, "report.mode");
241
+ const status = enumValue(input.status, CLEANUP_SWEEP_REPORT_STATUSES, "report.status");
242
+ const repository = input.repository === null ? null : createRepositoryIdentity(input.repository);
243
+ const candidates = sortCandidates((input.candidates ?? []).map(validateCandidate));
244
+ const reportErrors = (input.report_errors ?? []).map((item) => createReportError(typeof item === "string" ? item : item?.code));
245
+ const authorization = normalizeAuthorization(input.authorization);
246
+ const confirmation = normalizeConfirmation(input.confirmation ?? { argv: null, shell_command: null });
247
+ const exitCode = input.exit_code;
248
+ if (exitCode !== 0 && exitCode !== 1) throw new TypeError("report.exit_code must be 0 or 1");
249
+ if (status === "failed" && reportErrors.length === 0) throw new TypeError("a failed report requires a report error");
250
+ if (status !== "failed" && reportErrors.length !== 0) throw new TypeError("only a failed report may contain report errors");
251
+ if (status !== "refused" && authorization.refusal_code !== null) throw new TypeError("only a refused report may have a refusal code");
252
+ if ((status === "refused" || status === "failed" || mode === "execute") && (confirmation.argv !== null || confirmation.shell_command !== null)) {
253
+ throw new TypeError("execute, refused, and failed reports must have null confirmation");
254
+ }
255
+ if (status === "failed" && (authorization.digest !== null || confirmation.argv !== null)) throw new TypeError("failed reports cannot expose a usable digest or confirmation");
256
+ const attemptedCleanupFailures = countAttemptedCleanupFailures(candidates);
257
+ assertReportState({ mode, status, repository, authorization, confirmation, candidates, attemptedCleanupFailures, exitCode });
258
+ return {
259
+ schema_version: 1,
260
+ kind: "factory-cleanup-sweep-report",
261
+ mode,
262
+ status,
263
+ repository,
264
+ authorization,
265
+ counts: countCandidates(candidates),
266
+ attempted_cleanup_failures: attemptedCleanupFailures,
267
+ report_errors: reportErrors,
268
+ confirmation,
269
+ candidates,
270
+ exit_code: exitCode,
271
+ };
272
+ }
273
+
274
+ export function createReportLevelFailure({ mode, code, repository = null, candidates = [], provided_digest = null }) {
275
+ return createCleanupSweepReport({
276
+ mode,
277
+ status: "failed",
278
+ repository,
279
+ authorization: { schema_version: 1, digest: null, provided_digest, matched: null, refusal_code: null },
280
+ report_errors: [code],
281
+ confirmation: { argv: null, shell_command: null },
282
+ candidates,
283
+ exit_code: 1,
284
+ });
285
+ }
286
+
287
+ export function createRefusedReport({ repository, candidates, provided_digest, refusal_code, digest = null }) {
288
+ const comparison = compareCleanupSweepDigest(provided_digest, repository, candidates);
289
+ if (digest !== null && digest !== comparison.digest) throw new TypeError("refused report digest must match recomputed authorization");
290
+ if (refusal_code !== undefined && refusal_code !== comparison.refusal_code) throw new TypeError("refused report code must be derived from the provided digest");
291
+ if (comparison.matched) throw new TypeError("a matching digest cannot create a refused report");
292
+ return createCleanupSweepReport({
293
+ mode: "execute",
294
+ status: "refused",
295
+ repository,
296
+ authorization: { schema_version: 1, digest: comparison.digest, provided_digest, matched: false, refusal_code: comparison.refusal_code },
297
+ candidates,
298
+ report_errors: [],
299
+ confirmation: { argv: null, shell_command: null },
300
+ exit_code: 1,
301
+ });
302
+ }
303
+
304
+ function normalizeEvidence(input) {
305
+ assertRecord(input, "candidate.evidence");
306
+ assertExactKeys(input, EVIDENCE_KEYS, "candidate.evidence");
307
+ const entry = exactRecord(input.entry, ["kind", "logical_path", "physical_path", "device", "inode"], "candidate.evidence.entry");
308
+ enumValue(entry.kind, ["directory", "file", "symlink", "other", "inaccessible"], "candidate.evidence.entry.kind");
309
+ assertNonEmptyString(entry.logical_path, "candidate.evidence.entry.logical_path");
310
+ nullableString(entry.physical_path, "candidate.evidence.entry.physical_path");
311
+ nullableString(entry.device, "candidate.evidence.entry.device");
312
+ nullableString(entry.inode, "candidate.evidence.entry.inode");
313
+ const claims = exactRecord(input.claims, ["branches", "worktrees"], "candidate.evidence.claims");
314
+ const run = exactRecord(input.run, ["state", "hash", "run_id", "status"], "candidate.evidence.run");
315
+ enumValue(run.state, ["missing", "valid", "invalid", "inaccessible"], "candidate.evidence.run.state");
316
+ hashOrNull(run.hash, "candidate.evidence.run.hash"); nullableString(run.run_id, "candidate.evidence.run.run_id"); nullableString(run.status, "candidate.evidence.run.status");
317
+ const factoryLock = exactRecord(input.factory_lock, ["state", "hash", "active_owner"], "candidate.evidence.factory_lock");
318
+ enumValue(factoryLock.state, ["missing", "valid-matching", "valid-mismatched", "invalid", "inaccessible"], "candidate.evidence.factory_lock.state");
319
+ hashOrNull(factoryLock.hash, "candidate.evidence.factory_lock.hash"); nullableBoolean(factoryLock.active_owner, "candidate.evidence.factory_lock.active_owner");
320
+ const heartbeat = exactRecord(input.heartbeat, ["state", "hash", "fresh"], "candidate.evidence.heartbeat");
321
+ enumValue(heartbeat.state, ["missing", "valid-fresh", "valid-stale", "invalid", "mismatched", "indeterminate"], "candidate.evidence.heartbeat.state");
322
+ hashOrNull(heartbeat.hash, "candidate.evidence.heartbeat.hash"); nullableBoolean(heartbeat.fresh, "candidate.evidence.heartbeat.fresh");
323
+ const process = exactRecord(input.process, ["state", "hash"], "candidate.evidence.process");
324
+ enumValue(process.state, ["missing", "live-matching", "absent", "mismatched", "invalid", "indeterminate"], "candidate.evidence.process.state"); hashOrNull(process.hash, "candidate.evidence.process.hash");
325
+ const launchClaim = exactRecord(input.launch_claim, ["state", "hash", "dir_device", "dir_inode", "file_device", "file_inode"], "candidate.evidence.launch_claim");
326
+ enumValue(launchClaim.state, ["missing", "live-matching", "dead", "mismatched", "invalid", "indeterminate"], "candidate.evidence.launch_claim.state");
327
+ hashOrNull(launchClaim.hash, "candidate.evidence.launch_claim.hash");
328
+ for (const key of ["dir_device", "dir_inode", "file_device", "file_inode"]) decimalStringOrNull(launchClaim[key], `candidate.evidence.launch_claim.${key}`);
329
+ const runLock = exactRecord(input.run_lock, ["observed_before_acquire", "held_by_sweep"], "candidate.evidence.run_lock");
330
+ enumValue(runLock.observed_before_acquire, ["missing", "present", "invalid"], "candidate.evidence.run_lock.observed_before_acquire");
331
+ if (typeof runLock.held_by_sweep !== "boolean") throw new TypeError("candidate.evidence.run_lock.held_by_sweep must be boolean");
332
+ const pr = exactRecord(input.pr, ["state", "url", "repository", "number", "base_ref", "base_sha"], "candidate.evidence.pr");
333
+ enumValue(pr.state, ["not-checked", "merged", "closed", "open", "missing-metadata", "mismatch", "not-found", "inaccessible", "invalid-response"], "candidate.evidence.pr.state");
334
+ for (const key of ["url", "repository", "base_ref", "base_sha"]) nullableString(pr[key], `candidate.evidence.pr.${key}`);
335
+ if (pr.number !== null && (!Number.isSafeInteger(pr.number) || pr.number <= 0)) throw new TypeError("candidate.evidence.pr.number must be a positive integer or null");
336
+ const worktreeRoot = exactRecord(input.worktree_root, ["state", "logical_path", "physical_path", "device", "inode"], "candidate.evidence.worktree_root");
337
+ enumValue(worktreeRoot.state, ["missing", "valid", "unsafe"], "candidate.evidence.worktree_root.state");
338
+ for (const key of ["logical_path", "physical_path", "device", "inode"]) nullableString(worktreeRoot[key], `candidate.evidence.worktree_root.${key}`);
339
+ const branches = normalizeBranches(input.branches);
340
+ const worktrees = normalizeWorktrees(input.worktrees);
341
+ return {
342
+ entry: { ...entry },
343
+ claims: { branches: stringArray(claims.branches, "candidate.evidence.claims.branches").sort(utf8Collator), worktrees: stringArray(claims.worktrees, "candidate.evidence.claims.worktrees").sort(utf8Collator) },
344
+ run: { ...run }, factory_lock: { ...factoryLock }, heartbeat: { ...heartbeat }, process: { ...process }, launch_claim: { ...launchClaim }, run_lock: { ...runLock }, pr: { ...pr }, worktree_root: { ...worktreeRoot }, branches, worktrees,
345
+ };
346
+ }
347
+
348
+ function normalizeBranches(value) {
349
+ if (!Array.isArray(value)) throw new TypeError("candidate.evidence.branches must be an array");
350
+ return value.map((item, index) => {
351
+ const path = `candidate.evidence.branches[${index}]`;
352
+ const branch = exactRecord(item, ["name", "expected_head", "state", "base_oid"], path);
353
+ assertNonEmptyString(branch.name, `${path}.name`); nullableString(branch.expected_head, `${path}.expected_head`); nullableString(branch.base_oid, `${path}.base_oid`);
354
+ enumValue(branch.state, ["verified-ancestor", "unsafe", "missing", "current", "checked-out-unrecorded", "unmerged", "unprovable"], `${path}.state`);
355
+ return { ...branch };
356
+ }).sort((left, right) => utf8Collator(left.name, right.name));
357
+ }
358
+
359
+ function normalizeWorktrees(value) {
360
+ if (!Array.isArray(value)) throw new TypeError("candidate.evidence.worktrees must be an array");
361
+ return value.map((item, index) => {
362
+ const path = `candidate.evidence.worktrees[${index}]`;
363
+ const worktree = exactRecord(item, ["recorded_path", "physical_path", "device", "inode", "branch", "head", "state"], path);
364
+ assertNonEmptyString(worktree.recorded_path, `${path}.recorded_path`); nullableString(worktree.physical_path, `${path}.physical_path`); decimalStringOrNull(worktree.device, `${path}.device`); decimalStringOrNull(worktree.inode, `${path}.inode`); nullableString(worktree.branch, `${path}.branch`); nullableString(worktree.head, `${path}.head`);
365
+ enumValue(worktree.state, ["verified", "outside-root", "missing", "symlink", "unregistered", "branch-mismatch", "head-mismatch", "unprovable"], `${path}.state`);
366
+ if (worktree.state === "verified" && (worktree.device === null || worktree.inode === null)) throw new TypeError(`${path} verified identity requires device and inode`);
367
+ return { ...worktree };
368
+ }).sort((left, right) => utf8Collator(left.physical_path ?? left.recorded_path, right.physical_path ?? right.recorded_path));
369
+ }
370
+
371
+ function normalizeCleanup(value) {
372
+ if (value === null) return null;
373
+ const cleanup = exactRecord(value, ["worktrees", "branches", "run_dir"], "candidate.cleanup");
374
+ if (!Array.isArray(cleanup.worktrees) || !Array.isArray(cleanup.branches)) throw new TypeError("candidate cleanup target results must be arrays");
375
+ const worktrees = cleanup.worktrees.map((item, index) => {
376
+ const result = exactRecord(item, ["recorded_path", "physical_path", "branch", "outcome", "reason_code"], `candidate.cleanup.worktrees[${index}]`);
377
+ assertNonEmptyString(result.recorded_path, "cleanup worktree recorded_path"); assertNonEmptyString(result.physical_path, "cleanup worktree physical_path"); nullableString(result.branch, "cleanup worktree branch");
378
+ enumValue(result.outcome, ["removed", "failed"], "cleanup worktree outcome"); validateOutcomeReason(result.outcome, result.reason_code, "cleanup worktree");
379
+ return { ...result };
380
+ }).sort((left, right) => utf8Collator(left.physical_path, right.physical_path));
381
+ const branches = cleanup.branches.map((item, index) => {
382
+ const result = exactRecord(item, ["name", "expected_head", "outcome", "reason_code"], `candidate.cleanup.branches[${index}]`);
383
+ assertNonEmptyString(result.name, "cleanup branch name"); assertNonEmptyString(result.expected_head, "cleanup branch expected_head");
384
+ enumValue(result.outcome, ["deleted", "failed", "not-attempted"], "cleanup branch outcome"); validateOutcomeReason(result.outcome, result.reason_code, "cleanup branch");
385
+ return { ...result };
386
+ }).sort((left, right) => utf8Collator(left.name, right.name));
387
+ const runDir = exactRecord(cleanup.run_dir, ["path", "outcome", "reason_code"], "candidate.cleanup.run_dir");
388
+ assertNonEmptyString(runDir.path, "cleanup run_dir path"); enumValue(runDir.outcome, ["removed", "failed", "retained"], "cleanup run_dir outcome"); validateOutcomeReason(runDir.outcome, runDir.reason_code, "cleanup run_dir");
389
+ const normalized = { worktrees, branches, run_dir: { ...runDir } };
390
+ assertCleanupSequenceCoherence(normalized);
391
+ return normalized;
392
+ }
393
+
394
+ function normalizeAuthorization(value) {
395
+ const authorization = exactRecord(value, ["schema_version", "digest", "provided_digest", "matched", "refusal_code"], "report.authorization");
396
+ if (authorization.schema_version !== 1) throw new TypeError("report.authorization.schema_version must be 1");
397
+ if (authorization.digest !== null) parseCleanupSweepDigest(authorization.digest);
398
+ nullableString(authorization.provided_digest, "report.authorization.provided_digest"); nullableBoolean(authorization.matched, "report.authorization.matched");
399
+ if (authorization.refusal_code !== null) enumValue(authorization.refusal_code, CLEANUP_SWEEP_REFUSAL_CODES, "report.authorization.refusal_code");
400
+ return { ...authorization };
401
+ }
402
+
403
+ function normalizeConfirmation(value) {
404
+ const confirmation = exactRecord(value, ["argv", "shell_command"], "report.confirmation");
405
+ if (confirmation.argv !== null) stringArray(confirmation.argv, "report.confirmation.argv");
406
+ nullableString(confirmation.shell_command, "report.confirmation.shell_command");
407
+ if ((confirmation.argv === null) !== (confirmation.shell_command === null)) throw new TypeError("confirmation argv and shell_command must both be null or both be present");
408
+ return { argv: confirmation.argv === null ? null : [...confirmation.argv], shell_command: confirmation.shell_command };
409
+ }
410
+
411
+ function dedupeReasons(value) {
412
+ const codes = stringArray(value, "candidate.reason_codes");
413
+ const unique = [];
414
+ for (const code of codes) if (!unique.includes(code)) { createReason(code); unique.push(code); }
415
+ return unique.sort((left, right) => REASON_ORDER.get(left) - REASON_ORDER.get(right));
416
+ }
417
+
418
+ function assertCandidateMutationState({ classification, failureStage, attemptedCleanup, cleanup }) {
419
+ if (classification === "failed" && failureStage === "inspection") {
420
+ if (attemptedCleanup || cleanup !== null) throw new TypeError("an inspection-failed candidate must be unattempted with null cleanup");
421
+ return;
422
+ }
423
+ if (classification === "failed" && failureStage === "cleanup") {
424
+ if (!attemptedCleanup || cleanup === null) throw new TypeError("a cleanup-failed candidate must be attempted with cleanup details");
425
+ if (cleanupWhollySuccessful(cleanup)) throw new TypeError("wholly successful cleanup details require deleted classification");
426
+ return;
427
+ }
428
+ if (classification === "failed") throw new TypeError("a failed candidate requires inspection or cleanup failure_stage");
429
+ if (failureStage !== null) throw new TypeError("only a failed candidate may have failure_stage");
430
+ if (classification === "deleted") {
431
+ if (!attemptedCleanup || cleanup === null) throw new TypeError("a deleted candidate must be attempted with cleanup details");
432
+ if (!cleanupWhollySuccessful(cleanup)) throw new TypeError("a deleted candidate requires wholly successful cleanup outcomes");
433
+ return;
434
+ }
435
+ if (attemptedCleanup || cleanup !== null) throw new TypeError("attempted cleanup is allowed only for deleted or cleanup-failed candidates");
436
+ }
437
+
438
+ function assertReportState({ mode, status, repository, authorization, confirmation, candidates, attemptedCleanupFailures, exitCode }) {
439
+ if (status === "previewed") {
440
+ if (mode !== "preview") throw new TypeError("a previewed report must use preview mode");
441
+ if (repository === null) throw new TypeError("a previewed report requires repository identity");
442
+ if (candidates.some((candidate) => candidate.attempted_cleanup || candidate.classification === "deleted")) throw new TypeError("a previewed report cannot contain attempted or deleted candidates");
443
+ const expectedDigest = createCleanupSweepDigest(repository, candidates);
444
+ if (authorization.digest !== expectedDigest || authorization.provided_digest !== null || authorization.matched !== null || authorization.refusal_code !== null) {
445
+ throw new TypeError("a previewed report requires its recomputed digest and null execution authorization fields");
446
+ }
447
+ if (confirmation.argv === null || confirmation.shell_command === null) throw new TypeError("a previewed report requires confirmation");
448
+ if (exitCode !== 0) throw new TypeError("a previewed report requires exit_code 0");
449
+ return;
450
+ }
451
+ if (status === "completed") {
452
+ if (mode !== "execute") throw new TypeError("a completed report must use execute mode");
453
+ if (repository === null) throw new TypeError("a completed report requires repository identity");
454
+ if (candidates.some((candidate) => candidate.classification === "eligible")) throw new TypeError("a completed report cannot retain eligible candidates");
455
+ if (authorization.digest === null || authorization.provided_digest === null || authorization.digest !== authorization.provided_digest || authorization.matched !== true || authorization.refusal_code !== null) {
456
+ throw new TypeError("a completed report requires equal matched authorization digests");
457
+ }
458
+ const parsed = parseCleanupSweepDigest(authorization.provided_digest);
459
+ if (parsed.repository_sha256 !== repositoryDigest(repository)) throw new TypeError("a completed report digest must be bound to its repository identity");
460
+ if (exitCode !== (attemptedCleanupFailures > 0 ? 1 : 0)) throw new TypeError("completed execution exit_code must reflect attempted cleanup failures");
461
+ return;
462
+ }
463
+ if (status === "refused") {
464
+ if (mode !== "execute") throw new TypeError("a refused report must use execute mode");
465
+ if (repository === null) throw new TypeError("a refused report requires repository identity");
466
+ if (candidates.some((candidate) => candidate.attempted_cleanup || candidate.classification === "deleted")) throw new TypeError("a refused report cannot contain attempted or deleted candidates");
467
+ if (authorization.digest === null || authorization.provided_digest === null || authorization.matched !== false || authorization.refusal_code === null) {
468
+ throw new TypeError("a refused report requires recomputed and provided unmatched digest authorization");
469
+ }
470
+ const comparison = compareCleanupSweepDigest(authorization.provided_digest, repository, candidates);
471
+ if (comparison.matched || authorization.digest !== comparison.digest || authorization.refusal_code !== comparison.refusal_code) {
472
+ throw new TypeError("a refused report authorization must derive from repository and candidate evidence");
473
+ }
474
+ if (exitCode !== 1) throw new TypeError("a refused report requires exit_code 1");
475
+ return;
476
+ }
477
+ if (exitCode !== 1) throw new TypeError("a failed report requires exit_code 1");
478
+ }
479
+
480
+ function cleanupWhollySuccessful(cleanup) {
481
+ return cleanup.worktrees.every((item) => item.outcome === "removed" && item.reason_code === null)
482
+ && cleanup.branches.every((item) => item.outcome === "deleted" && item.reason_code === null)
483
+ && cleanup.run_dir.outcome === "removed"
484
+ && cleanup.run_dir.reason_code === null;
485
+ }
486
+
487
+ function assertCleanupSequenceCoherence(cleanup) {
488
+ for (const worktree of cleanup.worktrees) {
489
+ if (worktree.outcome === "failed" && worktree.reason_code !== "FAILED_CLEANUP_WORKTREE") {
490
+ throw new TypeError("a failed worktree requires FAILED_CLEANUP_WORKTREE detail");
491
+ }
492
+ if (worktree.outcome === "failed" && worktree.branch !== null) {
493
+ const recordedBranch = cleanup.branches.find((branch) => branch.name === worktree.branch);
494
+ if (recordedBranch && recordedBranch.outcome !== "not-attempted") {
495
+ throw new TypeError("a branch with a failed recorded worktree must be not-attempted");
496
+ }
497
+ }
498
+ }
499
+ for (const branch of cleanup.branches) {
500
+ if (branch.outcome === "failed" && branch.reason_code !== "FAILED_CLEANUP_BRANCH") {
501
+ throw new TypeError("a failed branch requires FAILED_CLEANUP_BRANCH detail");
502
+ }
503
+ if (branch.outcome === "not-attempted") {
504
+ if (branch.reason_code !== "RETAINED_AFTER_PARTIAL_FAILURE") throw new TypeError("a not-attempted branch requires retained-after-partial-failure detail");
505
+ const failedRecordedWorktree = cleanup.worktrees.some((worktree) => worktree.outcome === "failed" && worktree.branch === branch.name);
506
+ if (!failedRecordedWorktree) throw new TypeError("a branch may be not-attempted only after its recorded worktree failed");
507
+ }
508
+ }
509
+ const targetFailed = cleanup.worktrees.some((item) => item.outcome === "failed")
510
+ || cleanup.branches.some((item) => item.outcome === "failed" || item.outcome === "not-attempted");
511
+ if (targetFailed) {
512
+ if (cleanup.run_dir.outcome !== "retained" || cleanup.run_dir.reason_code !== "RETAINED_AFTER_PARTIAL_FAILURE") {
513
+ throw new TypeError("a target failure requires the run directory to be retained after partial failure");
514
+ }
515
+ return;
516
+ }
517
+ if (cleanup.run_dir.outcome === "failed" && cleanup.run_dir.reason_code !== "FAILED_CLEANUP_RUN_DIR") {
518
+ throw new TypeError("a failed run directory removal requires FAILED_CLEANUP_RUN_DIR detail");
519
+ }
520
+ if (cleanup.run_dir.outcome === "retained" && cleanup.run_dir.reason_code !== "FAILED_CLEANUP_UNEXPECTED") {
521
+ throw new TypeError("a run directory retained without target failure requires FAILED_CLEANUP_UNEXPECTED detail");
522
+ }
523
+ }
524
+
525
+ function validateOutcomeReason(outcome, reasonCode, path) {
526
+ if (outcome === "removed" || outcome === "deleted") {
527
+ if (reasonCode !== null) throw new TypeError(`${path} successful outcome requires null reason_code`);
528
+ return;
529
+ }
530
+ if (reasonCode === null) throw new TypeError(`${path} unsuccessful outcome requires reason_code`);
531
+ createReason(reasonCode);
532
+ }
533
+ function hashOrNull(value, path) { if (value !== null && (typeof value !== "string" || !HASH.test(value))) throw new TypeError(`${path} must be a sha256 hash or null`); }
534
+ function decimalStringOrNull(value, path) { if (value !== null && (typeof value !== "string" || !/^\d+$/u.test(value))) throw new TypeError(`${path} must be a decimal string or null`); return value; }
535
+ function nullableString(value, path) { if (value !== null && typeof value !== "string") throw new TypeError(`${path} must be a string or null`); return value; }
536
+ function nullableBoolean(value, path) { if (value !== null && typeof value !== "boolean") throw new TypeError(`${path} must be a boolean or null`); return value; }
537
+ function requiredString(value, path) { assertNonEmptyString(value, path); return value; }
538
+ function assertNonEmptyString(value, path) { if (typeof value !== "string" || value.length === 0) throw new TypeError(`${path} must be a non-empty string`); }
539
+ function enumValue(value, allowed, path) { if (!allowed.includes(value)) throw new TypeError(`${path} must be one of ${allowed.join(", ")}`); return value; }
540
+ function stringArray(value, path) { if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new TypeError(`${path} must be an array of strings`); return [...value]; }
541
+ function assertRecord(value, path) { if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${path} must be an object`); }
542
+ function exactRecord(value, keys, path) { assertRecord(value, path); assertExactKeys(value, keys, path); return value; }
543
+ function assertExactKeys(value, keys, path) {
544
+ assertRecord(value, path);
545
+ const actual = Object.keys(value).sort(utf8Collator);
546
+ const expected = [...keys].sort(utf8Collator);
547
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) throw new TypeError(`${path} must contain exactly: ${keys.join(", ")}`);
548
+ }