@brainervirus/workit-core 0.10.0 → 1.0.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 (150) hide show
  1. package/README.md +5 -3
  2. package/package.json +8 -3
  3. package/scripts/analyze-release-scope.ts +4 -1
  4. package/scripts/doctor-check.ts +3 -1
  5. package/scripts/install-codex-plugin.sh +28 -0
  6. package/scripts/install-cursor-plugin.sh +1 -0
  7. package/scripts/install-opencode-plugin.sh +1 -0
  8. package/scripts/install-pi-package.sh +56 -0
  9. package/scripts/rewrite-workspace-deps.ts +22 -5
  10. package/scripts/sync-release-manifests.ts +8 -5
  11. package/scripts/sync-runtime.sh +7 -3
  12. package/scripts/validate-cursor-marketplace.ts +16 -62
  13. package/skills/workit-babysit/SKILL.md +33 -0
  14. package/skills/workit-behavioral-tdd/SKILL.md +53 -0
  15. package/skills/workit-blast-radius/SKILL.md +31 -0
  16. package/skills/workit-challenge/SKILL.md +62 -0
  17. package/skills/workit-debug/SKILL.md +61 -0
  18. package/skills/workit-deslop/SKILL.md +36 -0
  19. package/skills/workit-diagram/SKILL.md +32 -0
  20. package/skills/workit-green-run/SKILL.md +29 -0
  21. package/skills/workit-handoff/SKILL.md +43 -0
  22. package/skills/workit-implement/SKILL.md +46 -0
  23. package/skills/workit-mockup/SKILL.md +28 -0
  24. package/skills/workit-plan/SKILL.md +66 -0
  25. package/skills/workit-review/SKILL.md +60 -0
  26. package/skills/workit-steer/SKILL.md +32 -0
  27. package/src/core/authority.ts +918 -0
  28. package/src/core/boundary.ts +29 -0
  29. package/src/core/branch.ts +26 -255
  30. package/src/core/changelog.ts +95 -18
  31. package/src/core/commit-flavors.ts +65 -0
  32. package/src/core/config-conversion.ts +183 -0
  33. package/src/core/config.ts +42 -0
  34. package/src/core/cutover.ts +721 -0
  35. package/src/core/detect-hosts.ts +107 -0
  36. package/src/core/doctor.ts +428 -18
  37. package/src/core/external-action-effects.ts +1171 -0
  38. package/src/core/external-action.ts +541 -0
  39. package/src/core/init.ts +83 -32
  40. package/src/core/methods.ts +125 -0
  41. package/src/core/policy-resolver.ts +523 -0
  42. package/src/core/pr-create.ts +7 -1
  43. package/src/core/registration.ts +69 -4
  44. package/src/core/repo-context.ts +59 -1
  45. package/src/core/setup.ts +28 -0
  46. package/src/core/skill-manifests.ts +40 -42
  47. package/src/core/support-matrix.ts +8 -3
  48. package/src/core/sync-runtime.ts +15 -3
  49. package/src/core/task-context.ts +179 -0
  50. package/src/core/task-contract.ts +1136 -0
  51. package/src/core/task-engine.ts +2562 -0
  52. package/src/core/task-evaluation.ts +679 -0
  53. package/src/core/task-store.ts +1071 -0
  54. package/src/core/tracker-issues.ts +237 -0
  55. package/src/core/uninstall.ts +67 -3
  56. package/src/core/vcs-config.ts +53 -22
  57. package/src/core/workers.ts +166 -0
  58. package/src/core/workspaces.ts +3 -1
  59. package/src/core/youtrack-tools.ts +2 -0
  60. package/src/core/youtrack.ts +145 -10
  61. package/src/core.ts +126 -0
  62. package/templates/execution-contract.md +17 -50
  63. package/templates/plan-template.md +3 -3
  64. package/templates/spec-template.md +15 -3
  65. package/templates/workit-contract.md +12 -0
  66. package/commands/wk-changelog.md +0 -2
  67. package/commands/wk-commit.md +0 -2
  68. package/commands/wk-docs-refresh.md +0 -2
  69. package/commands/wk-handoff.md +0 -2
  70. package/commands/wk-implement.md +0 -2
  71. package/commands/wk-init.md +0 -2
  72. package/commands/wk-issue-update.md +0 -2
  73. package/commands/wk-meetings.md +0 -2
  74. package/commands/wk-pr.md +0 -2
  75. package/commands/wk-release-notes.md +0 -2
  76. package/commands/wk-status.md +0 -2
  77. package/commands/wk-verify.md +0 -2
  78. package/scripts/update-superpowers.sh +0 -82
  79. package/scripts/vendor-assets.ts +0 -37
  80. package/skills/wk-changelog/SKILL.md +0 -15
  81. package/skills/wk-commit/SKILL.md +0 -16
  82. package/skills/wk-docs-refresh/SKILL.md +0 -15
  83. package/skills/wk-handoff/SKILL.md +0 -20
  84. package/skills/wk-implement/SKILL.md +0 -47
  85. package/skills/wk-init/SKILL.md +0 -31
  86. package/skills/wk-issue-update/SKILL.md +0 -27
  87. package/skills/wk-issue-update/references/youtrack-update-style.md +0 -81
  88. package/skills/wk-meetings/SKILL.md +0 -17
  89. package/skills/wk-pr/SKILL.md +0 -27
  90. package/skills/wk-release-notes/SKILL.md +0 -15
  91. package/skills/wk-status/SKILL.md +0 -16
  92. package/skills/wk-verify/SKILL.md +0 -16
  93. package/src/core/detector.ts +0 -239
  94. package/src/core/flow-state.ts +0 -3048
  95. package/src/core/handoff-context.ts +0 -136
  96. package/src/core/handoff-tools.ts +0 -133
  97. package/src/core/menu.ts +0 -70
  98. package/src/core/plan-tasks.ts +0 -43
  99. package/src/core/reminder.ts +0 -124
  100. package/src/core/sdd.ts +0 -353
  101. package/src/state.ts +0 -22
  102. package/templates/superpowers-doc-contract.md +0 -75
  103. package/vendor/superpowers/skills/brainstorming/SKILL.md +0 -159
  104. package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +0 -213
  105. package/vendor/superpowers/skills/brainstorming/scripts/helper.js +0 -167
  106. package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +0 -723
  107. package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +0 -209
  108. package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +0 -120
  109. package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
  110. package/vendor/superpowers/skills/brainstorming/visual-companion.md +0 -291
  111. package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +0 -185
  112. package/vendor/superpowers/skills/executing-plans/SKILL.md +0 -70
  113. package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +0 -241
  114. package/vendor/superpowers/skills/receiving-code-review/SKILL.md +0 -213
  115. package/vendor/superpowers/skills/requesting-code-review/SKILL.md +0 -103
  116. package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +0 -172
  117. package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +0 -426
  118. package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +0 -139
  119. package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +0 -44
  120. package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +0 -22
  121. package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +0 -40
  122. package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +0 -188
  123. package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +0 -119
  124. package/vendor/superpowers/skills/systematic-debugging/SKILL.md +0 -296
  125. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
  126. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +0 -115
  127. package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +0 -122
  128. package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +0 -63
  129. package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +0 -169
  130. package/vendor/superpowers/skills/systematic-debugging/test-academic.md +0 -14
  131. package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +0 -58
  132. package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +0 -68
  133. package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +0 -69
  134. package/vendor/superpowers/skills/test-driven-development/SKILL.md +0 -371
  135. package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +0 -299
  136. package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +0 -202
  137. package/vendor/superpowers/skills/using-superpowers/SKILL.md +0 -62
  138. package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +0 -23
  139. package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +0 -39
  140. package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +0 -16
  141. package/vendor/superpowers/skills/verification-before-completion/SKILL.md +0 -139
  142. package/vendor/superpowers/skills/writing-plans/SKILL.md +0 -174
  143. package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
  144. package/vendor/superpowers/skills/writing-skills/SKILL.md +0 -689
  145. package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +0 -1150
  146. package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +0 -189
  147. package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +0 -172
  148. package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +0 -187
  149. package/vendor/superpowers/skills/writing-skills/render-graphs.js +0 -168
  150. package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +0 -384
@@ -0,0 +1,1071 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import { hostname } from "node:os";
4
+ import path from "node:path";
5
+ import * as z from "zod";
6
+ import {
7
+ acquireFileLockSync,
8
+ type FileLockSyncHandle,
9
+ type FileLockSyncAcquireOptions,
10
+ } from "@openclaw/fs-safe/file-lock";
11
+ import {
12
+ SCHEMA_VERSION,
13
+ canonicalJson,
14
+ failure,
15
+ intentSchema,
16
+ newId,
17
+ newRevision,
18
+ provenanceSchema,
19
+ refSchema,
20
+ sha256,
21
+ success,
22
+ taskRecordSchema,
23
+ workspaceRecordSchema,
24
+ type Id,
25
+ type Intent,
26
+ type Ref,
27
+ type Provenance,
28
+ type Result,
29
+ type Revision,
30
+ type TaskRecord,
31
+ type Utc,
32
+ type WorkspaceRecord,
33
+ } from "./task-contract";
34
+
35
+ export type MutationContext = { now: Utc; revision: Revision };
36
+ export type TaskMutation = (task: TaskRecord, context: MutationContext) => Result<TaskRecord>;
37
+ export type WorkspaceMutation = (
38
+ workspace: WorkspaceRecord,
39
+ context: MutationContext,
40
+ ) => Result<WorkspaceRecord>;
41
+ export type CoupledMutation = {
42
+ taskId: Id;
43
+ expectedTaskRevision?: Revision;
44
+ expectedRevision?: Revision;
45
+ expectedWorkspaceRevision: Revision;
46
+ now?: Utc;
47
+ task: TaskMutation;
48
+ workspace: WorkspaceMutation;
49
+ };
50
+ export type CoupledSnapshot = { task: TaskRecord; workspace: WorkspaceRecord };
51
+ export type CreateInput = {
52
+ intent: Intent;
53
+ provenance: Provenance;
54
+ expectedWorkspaceRevision: Revision | null;
55
+ now?: Utc;
56
+ };
57
+ export type ImportInput = {
58
+ task: TaskRecord;
59
+ expectedWorkspaceRevision: Revision | null;
60
+ workspaceId?: Id;
61
+ now?: Utc;
62
+ };
63
+ export type RecoveryInput = {
64
+ expectedBytes: string;
65
+ snapshotDigest: string;
66
+ reason: string;
67
+ authorityRefs: Ref[];
68
+ expectedWorkspaceRevision: Revision;
69
+ processEvidence: (
70
+ lock: MetadataLock | null,
71
+ writer: WorkspaceRecord["writer"],
72
+ ) => Result<ProcessEvidence>;
73
+ };
74
+ export type MetadataLock = {
75
+ pid: number;
76
+ processStart: string | null;
77
+ host: string;
78
+ nonce: string;
79
+ };
80
+ export type ProcessEvidence = {
81
+ state: "stopped" | "accounted_for";
82
+ pid: number;
83
+ processStart: string | null;
84
+ ownerDigest: string | null;
85
+ };
86
+ const processEvidenceSchema = z
87
+ .object({
88
+ state: z.enum(["stopped", "accounted_for"]),
89
+ pid: z.number().int().nonnegative(),
90
+ processStart: z.string().nullable(),
91
+ ownerDigest: z
92
+ .string()
93
+ .regex(/^[0-9a-f]{64}$/)
94
+ .nullable(),
95
+ })
96
+ .strict();
97
+ const metadataLockSchema = z
98
+ .object({
99
+ pid: z.number().int().nonnegative().safe(),
100
+ processStart: z.string().nullable(),
101
+ host: z.string().min(1),
102
+ nonce: z.string().min(1),
103
+ })
104
+ .strict();
105
+ export type RecoveryCandidate = {
106
+ target: "task" | "workspace";
107
+ path: string;
108
+ digest: string;
109
+ };
110
+
111
+ const now = (): Utc => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
112
+ const jsonBytes = (value: unknown): string => `${canonicalJson(value)}\n`;
113
+ const digestBytes = (value: string | Buffer): string =>
114
+ createHash("sha256").update(value).digest("hex");
115
+ const isObject = (value: unknown): value is Record<string, unknown> =>
116
+ typeof value === "object" && value !== null;
117
+ const validId = (value: string): boolean =>
118
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
119
+ const validDigest = (value: string): boolean => /^[0-9a-f]{64}$/.test(value);
120
+ const parseMetadataLock = (raw: string): MetadataLock => {
121
+ let value: unknown;
122
+ try {
123
+ value = JSON.parse(raw);
124
+ } catch {
125
+ throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
126
+ }
127
+ const parsed = metadataLockSchema.safeParse(value);
128
+ if (!parsed.success)
129
+ throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
130
+ return parsed.data;
131
+ };
132
+ const sameMetadataLock = (left: unknown, right: MetadataLock): boolean => {
133
+ const parsed = metadataLockSchema.safeParse(left);
134
+ return parsed.success && canonicalJson(parsed.data) === canonicalJson(right);
135
+ };
136
+ type LockSnapshot = { raw: string; data: MetadataLock };
137
+
138
+ export class TaskStore {
139
+ readonly root: string;
140
+
141
+ constructor(root: string) {
142
+ this.root = fs.existsSync(root) ? fs.realpathSync(root) : path.resolve(root);
143
+ }
144
+
145
+ readTask(taskId: Id): Result<TaskRecord> {
146
+ if (!validId(taskId)) return failure("invalid_input", "task ID is invalid", { taskId });
147
+ const result = this.readRecord<TaskRecord>(
148
+ path.join(this.tasksDir, `${taskId}.json`),
149
+ taskRecordSchema,
150
+ );
151
+ if (!result.exists) return failure("not_found", "task not found", { taskId });
152
+ if (result.result.ok && result.result.data.id !== taskId)
153
+ return failure("recovery_required", "task filename and record ID differ", { taskId });
154
+ if (result.result.ok) {
155
+ const workspace = this.readWorkspace();
156
+ if (!workspace.ok) return workspace as Result<never>;
157
+ if (!workspace.data || result.result.data.workspaceId !== workspace.data.id)
158
+ return failure("recovery_required", "task workspace binding is invalid", { taskId });
159
+ }
160
+ return result.result;
161
+ }
162
+
163
+ listTasks(): Result<TaskRecord[]> {
164
+ if (!fs.existsSync(this.tasksDir)) return success(null, null, []);
165
+ let names: string[];
166
+ try {
167
+ names = fs.readdirSync(this.tasksDir).filter((name) => name.endsWith(".json"));
168
+ } catch (error) {
169
+ return failure("storage_error", `unable to list tasks: ${String(error)}`, {
170
+ path: this.tasksDir,
171
+ });
172
+ }
173
+ const workspace = this.readWorkspace();
174
+ if (!workspace.ok) return workspace as Result<never>;
175
+ if (!workspace.data)
176
+ return failure("recovery_required", "task workspace binding is invalid", {
177
+ path: this.workspacePath,
178
+ });
179
+ const tasks: TaskRecord[] = [];
180
+ for (const name of names.sort()) {
181
+ const item = this.readRecord<TaskRecord>(path.join(this.tasksDir, name), taskRecordSchema);
182
+ if (!item.exists) continue;
183
+ if (!item.result.ok) return item.result;
184
+ if (!validId(name.slice(0, -5)) || item.result.data.id !== name.slice(0, -5))
185
+ return failure("recovery_required", "task filename and record ID differ", { path: name });
186
+ if (item.result.data.workspaceId !== workspace.data.id)
187
+ return failure("recovery_required", "task workspace binding is invalid", { path: name });
188
+ tasks.push(item.result.data);
189
+ }
190
+ return success(null, null, tasks);
191
+ }
192
+
193
+ readWorkspace(): Result<WorkspaceRecord | null> {
194
+ const item = this.readRecord<WorkspaceRecord>(this.workspacePath, workspaceRecordSchema);
195
+ if (!item.exists) return success(null, null, null);
196
+ if (item.result.ok && item.result.data.root !== this.root)
197
+ return failure("recovery_required", "workspace root binding is invalid", {
198
+ path: this.workspacePath,
199
+ });
200
+ return item.result;
201
+ }
202
+
203
+ create(
204
+ input: CreateInput | Intent,
205
+ provenance?: Provenance,
206
+ expectedWorkspaceRevision?: Revision | null,
207
+ ) {
208
+ const value: CreateInput =
209
+ "intent" in input
210
+ ? input
211
+ : {
212
+ intent: input,
213
+ provenance: provenance!,
214
+ expectedWorkspaceRevision: expectedWorkspaceRevision ?? null,
215
+ };
216
+ return this.withLock<TaskRecord>(() => {
217
+ const current = this.readWorkspace();
218
+ if (!current.ok) return current;
219
+ if (
220
+ current.data
221
+ ? value.expectedWorkspaceRevision !== current.data.revision
222
+ : value.expectedWorkspaceRevision !== null
223
+ ) {
224
+ return failure("revision_conflict", "workspace revision does not match", {
225
+ expectedWorkspaceRevision: value.expectedWorkspaceRevision,
226
+ actualWorkspaceRevision: current.data?.revision ?? null,
227
+ });
228
+ }
229
+ if (!value.provenance) return failure("invalid_input", "provenance is required");
230
+ if (
231
+ !intentSchema.safeParse(value.intent).success ||
232
+ !provenanceSchema.safeParse(value.provenance).success
233
+ )
234
+ return failure("invalid_input", "task intent or provenance is invalid");
235
+ const previousWorkspaceBytes = current.data ? this.snapshotBytes(this.workspacePath) : null;
236
+ if (current.data && !previousWorkspaceBytes)
237
+ return failure("storage_error", "workspace snapshot disappeared during creation");
238
+ const workspace: WorkspaceRecord = current.data
239
+ ? { ...current.data, revision: newRevision(), root: this.root }
240
+ : {
241
+ schemaVersion: SCHEMA_VERSION,
242
+ id: newId(),
243
+ revision: newRevision(),
244
+ root: this.root,
245
+ writer: null,
246
+ };
247
+ const timestamp = value.now ?? now();
248
+ const task: TaskRecord = {
249
+ schemaVersion: SCHEMA_VERSION,
250
+ id: newId(),
251
+ workspaceId: workspace.id,
252
+ revision: newRevision(),
253
+ createdAt: timestamp,
254
+ updatedAt: timestamp,
255
+ origin: null,
256
+ intent: {
257
+ id: newId(),
258
+ recordedAt: timestamp,
259
+ provenance: value.provenance,
260
+ data: value.intent,
261
+ },
262
+ constraints: [],
263
+ status: "active",
264
+ closure: null,
265
+ progress: { summary: "", nextAction: null, blockers: [] },
266
+ assessments: [],
267
+ policy: null,
268
+ policyChanges: [],
269
+ candidates: [],
270
+ evidence: [],
271
+ decisions: [],
272
+ findings: [],
273
+ workers: [],
274
+ };
275
+ const writtenWorkspace = this.replaceSnapshot(
276
+ this.workspacePath,
277
+ workspace,
278
+ previousWorkspaceBytes,
279
+ );
280
+ if (!writtenWorkspace.ok) return writtenWorkspace;
281
+ const writtenTask = this.replaceSnapshot(this.taskPath(task.id), task, null);
282
+ if (!writtenTask.ok) {
283
+ if (current.data && previousWorkspaceBytes) {
284
+ const restored = this.replaceSnapshot(
285
+ this.workspacePath,
286
+ current.data,
287
+ this.snapshotBytes(this.workspacePath),
288
+ );
289
+ if (!restored.ok)
290
+ return failure(
291
+ "external_outcome_unknown",
292
+ "task creation failed and workspace restoration is uncertain",
293
+ { operation: "create", outcome: "unknown" },
294
+ );
295
+ }
296
+ return failure(
297
+ "external_outcome_unknown",
298
+ "workspace created but task write is uncertain",
299
+ { operation: "create", outcome: "unknown" },
300
+ );
301
+ }
302
+ return success(task.revision, workspace.revision, task);
303
+ });
304
+ }
305
+
306
+ importTask(input: ImportInput): Result<TaskRecord> {
307
+ return this.withLock<TaskRecord>(() => {
308
+ const current = this.readWorkspace();
309
+ if (!current.ok) return current;
310
+ if (
311
+ current.data
312
+ ? input.expectedWorkspaceRevision !== current.data.revision
313
+ : input.expectedWorkspaceRevision !== null
314
+ )
315
+ return failure("revision_conflict", "workspace revision does not match", {
316
+ expectedWorkspaceRevision: input.expectedWorkspaceRevision,
317
+ actualWorkspaceRevision: current.data?.revision ?? null,
318
+ });
319
+ const previousWorkspaceBytes = current.data ? this.snapshotBytes(this.workspacePath) : null;
320
+ if (current.data && !previousWorkspaceBytes)
321
+ return failure("storage_error", "workspace snapshot disappeared during import");
322
+ const timestamp = input.now ?? now();
323
+ const workspace: WorkspaceRecord = current.data
324
+ ? { ...current.data, revision: newRevision(), root: this.root }
325
+ : {
326
+ schemaVersion: SCHEMA_VERSION,
327
+ id: input.workspaceId ?? newId(),
328
+ revision: newRevision(),
329
+ root: this.root,
330
+ writer: null,
331
+ };
332
+ const task = {
333
+ ...input.task,
334
+ workspaceId: workspace.id,
335
+ revision: newRevision(),
336
+ createdAt: timestamp,
337
+ updatedAt: timestamp,
338
+ };
339
+ const validTask = taskRecordSchema.safeParse(task);
340
+ if (!validTask.success)
341
+ return failure("invalid_input", "imported task does not satisfy its schema");
342
+ const writtenWorkspace = this.replaceSnapshot(
343
+ this.workspacePath,
344
+ workspace,
345
+ previousWorkspaceBytes,
346
+ );
347
+ if (!writtenWorkspace.ok) return writtenWorkspace;
348
+ const writtenTask = this.replaceSnapshot(this.taskPath(task.id), validTask.data, null);
349
+ if (!writtenTask.ok) {
350
+ if (current.data && previousWorkspaceBytes) {
351
+ const restored = this.replaceSnapshot(
352
+ this.workspacePath,
353
+ current.data,
354
+ this.snapshotBytes(this.workspacePath),
355
+ );
356
+ if (!restored.ok)
357
+ return failure(
358
+ "external_outcome_unknown",
359
+ "import failed and workspace restoration is uncertain",
360
+ { operation: "import", outcome: "unknown" },
361
+ );
362
+ }
363
+ return failure(
364
+ "external_outcome_unknown",
365
+ `workspace created but task import is uncertain: ${writtenTask.error}`,
366
+ {
367
+ operation: "import",
368
+ outcome: "unknown",
369
+ path: writtenTask.details.path,
370
+ },
371
+ );
372
+ }
373
+ return success(validTask.data.revision, workspace.revision, validTask.data);
374
+ });
375
+ }
376
+
377
+ mutateTask(
378
+ taskId: Id,
379
+ expected: Revision,
380
+ update: TaskMutation,
381
+ timestamp?: Utc,
382
+ ): Result<TaskRecord> {
383
+ return this.withLock(() => {
384
+ const current = this.readTask(taskId);
385
+ if (!current.ok) return current;
386
+ if (current.data.revision !== expected) return this.conflict(expected, current.data.revision);
387
+ const previousBytes = this.snapshotBytes(this.taskPath(taskId));
388
+ if (!previousBytes)
389
+ return failure("storage_error", "task snapshot disappeared during mutation");
390
+ const context = { now: timestamp ?? now(), revision: newRevision() };
391
+ let changed: Result<TaskRecord>;
392
+ try {
393
+ changed = update(current.data, Object.freeze({ ...context }));
394
+ } catch (error) {
395
+ return failure("storage_error", `task mutation failed: ${String(error)}`);
396
+ }
397
+ if (!changed.ok) return changed;
398
+ const record = {
399
+ ...changed.data,
400
+ id: taskId,
401
+ workspaceId: current.data.workspaceId,
402
+ createdAt: current.data.createdAt,
403
+ revision: context.revision,
404
+ updatedAt: context.now,
405
+ };
406
+ const valid = taskRecordSchema.safeParse(record);
407
+ if (!valid.success)
408
+ return failure("invalid_input", "task mutation produced an invalid record");
409
+ const written = this.replaceSnapshot(this.taskPath(taskId), valid.data, previousBytes);
410
+ return written.ok ? success(valid.data.revision, null, valid.data) : written;
411
+ });
412
+ }
413
+
414
+ mutateWorkspace(expected: Revision, update: WorkspaceMutation): Result<WorkspaceRecord> {
415
+ return this.withLock(() => {
416
+ const current = this.readWorkspace();
417
+ if (!current.ok) return current;
418
+ if (!current.data) return failure("not_found", "workspace not found");
419
+ if (current.data.revision !== expected) return this.conflict(expected, current.data.revision);
420
+ const previousBytes = this.snapshotBytes(this.workspacePath);
421
+ if (!previousBytes)
422
+ return failure("storage_error", "workspace snapshot disappeared during mutation");
423
+ const context = { now: now(), revision: newRevision() };
424
+ let changed: Result<WorkspaceRecord>;
425
+ try {
426
+ changed = update(current.data, Object.freeze({ ...context }));
427
+ } catch (error) {
428
+ return failure("storage_error", `workspace mutation failed: ${String(error)}`);
429
+ }
430
+ if (!changed.ok) return changed;
431
+ const record = {
432
+ ...changed.data,
433
+ id: current.data.id,
434
+ root: this.root,
435
+ revision: context.revision,
436
+ };
437
+ const valid = workspaceRecordSchema.safeParse(record);
438
+ if (!valid.success)
439
+ return failure("invalid_input", "workspace mutation produced an invalid record");
440
+ const written = this.replaceSnapshot(this.workspacePath, valid.data, previousBytes);
441
+ return written.ok ? success(valid.data.revision, valid.data.revision, valid.data) : written;
442
+ });
443
+ }
444
+
445
+ mutateTaskAndWorkspace(input: CoupledMutation): Result<CoupledSnapshot> {
446
+ return this.withLock(() => {
447
+ const task = this.readTask(input.taskId);
448
+ if (!task.ok) return task;
449
+ const workspace = this.readWorkspace();
450
+ if (!workspace.ok) return workspace;
451
+ if (!workspace.data) return failure("not_found", "workspace not found");
452
+ const expectedTaskRevision = input.expectedTaskRevision ?? input.expectedRevision!;
453
+ if (!expectedTaskRevision) return failure("invalid_input", "task revision is required");
454
+ if (task.data.revision !== expectedTaskRevision)
455
+ return this.conflict(expectedTaskRevision, task.data.revision);
456
+ if (workspace.data.revision !== input.expectedWorkspaceRevision)
457
+ return this.conflict(input.expectedWorkspaceRevision, workspace.data.revision);
458
+ const previousWorkspaceBytes = this.snapshotBytes(this.workspacePath);
459
+ const previousTaskBytes = this.snapshotBytes(this.taskPath(input.taskId));
460
+ if (!previousWorkspaceBytes || !previousTaskBytes)
461
+ return failure("storage_error", "snapshot disappeared during coupled mutation");
462
+ const workspaceContext = { now: input.now ?? now(), revision: newRevision() };
463
+ let changedWorkspace: Result<WorkspaceRecord>;
464
+ try {
465
+ changedWorkspace = input.workspace(workspace.data, Object.freeze({ ...workspaceContext }));
466
+ } catch (error) {
467
+ return failure("storage_error", `workspace mutation failed: ${String(error)}`);
468
+ }
469
+ if (!changedWorkspace.ok) return changedWorkspace;
470
+ const nextWorkspace = workspaceRecordSchema.safeParse({
471
+ ...changedWorkspace.data,
472
+ id: workspace.data.id,
473
+ root: this.root,
474
+ revision: workspaceContext.revision,
475
+ });
476
+ if (!nextWorkspace.success)
477
+ return failure("invalid_input", "workspace mutation produced an invalid record");
478
+ const reserved = this.replaceSnapshot(
479
+ this.workspacePath,
480
+ nextWorkspace.data,
481
+ previousWorkspaceBytes,
482
+ );
483
+ if (!reserved.ok) return reserved;
484
+ const taskContext = { now: input.now ?? now(), revision: newRevision() };
485
+ let changedTask: Result<TaskRecord>;
486
+ try {
487
+ changedTask = input.task(task.data, Object.freeze({ ...taskContext }));
488
+ } catch (error) {
489
+ this.markUncertain(nextWorkspace.data);
490
+ return failure(
491
+ "external_outcome_unknown",
492
+ `workspace reserved but task mutation threw: ${String(error)}`,
493
+ { operation: "coupled_mutation", outcome: "unknown" },
494
+ );
495
+ }
496
+ if (!changedTask.ok) {
497
+ this.markUncertain(nextWorkspace.data);
498
+ return failure(
499
+ "external_outcome_unknown",
500
+ "workspace reserved but task update is uncertain",
501
+ { operation: "coupled_mutation", outcome: "unknown" },
502
+ );
503
+ }
504
+ const nextTask = taskRecordSchema.safeParse({
505
+ ...changedTask.data,
506
+ id: input.taskId,
507
+ workspaceId: task.data.workspaceId,
508
+ createdAt: task.data.createdAt,
509
+ revision: taskContext.revision,
510
+ updatedAt: taskContext.now,
511
+ });
512
+ if (!nextTask.success) {
513
+ this.markUncertain(nextWorkspace.data);
514
+ return failure(
515
+ "external_outcome_unknown",
516
+ "workspace reserved but task update is uncertain",
517
+ { operation: "coupled_mutation", outcome: "unknown" },
518
+ );
519
+ }
520
+ const written = this.replaceSnapshot(
521
+ this.taskPath(input.taskId),
522
+ nextTask.data,
523
+ previousTaskBytes,
524
+ );
525
+ if (!written.ok) {
526
+ this.markUncertain(nextWorkspace.data);
527
+ return failure(
528
+ "external_outcome_unknown",
529
+ "workspace reserved but task replacement is uncertain",
530
+ { operation: "coupled_mutation", outcome: "unknown" },
531
+ );
532
+ }
533
+ return success(nextTask.data.revision, nextWorkspace.data.revision, {
534
+ task: nextTask.data,
535
+ workspace: nextWorkspace.data,
536
+ });
537
+ });
538
+ }
539
+
540
+ recoveryCandidates(): Result<RecoveryCandidate[]> {
541
+ if (!fs.existsSync(this.recoveryDir)) return success(null, null, []);
542
+ try {
543
+ const candidates: RecoveryCandidate[] = [];
544
+ for (const name of fs.readdirSync(this.recoveryDir)) {
545
+ const match = /^(task|workspace)\.([^.]+)\.([0-9a-f]{64})\.json$/.exec(name);
546
+ if (match)
547
+ candidates.push({
548
+ target: match[1] as "task" | "workspace",
549
+ path: path.join(this.recoveryDir, name),
550
+ digest: match[3],
551
+ });
552
+ }
553
+ return success(null, null, candidates);
554
+ } catch (error) {
555
+ return failure("storage_error", `unable to list recovery: ${String(error)}`, {
556
+ path: this.recoveryDir,
557
+ });
558
+ }
559
+ }
560
+
561
+ recoverTask(taskId: Id, input: RecoveryInput): Result<TaskRecord> {
562
+ return this.recover("task", taskId, input);
563
+ }
564
+
565
+ recoverWorkspace(input: RecoveryInput): Result<WorkspaceRecord> {
566
+ return this.recover("workspace", null, input);
567
+ }
568
+
569
+ private recover(
570
+ target: "task" | "workspace",
571
+ taskId: Id | null,
572
+ input: RecoveryInput,
573
+ ): Result<any> {
574
+ if (taskId !== null && !validId(taskId))
575
+ return failure("invalid_input", "task ID is invalid", { taskId });
576
+ if (
577
+ !input.reason ||
578
+ !Array.isArray(input.authorityRefs) ||
579
+ typeof input.processEvidence !== "function" ||
580
+ !validDigest(input.expectedBytes) ||
581
+ !validDigest(input.snapshotDigest)
582
+ )
583
+ return failure("invalid_input", "recovery authority is invalid");
584
+ if (input.authorityRefs.some((ref) => !refSchema.safeParse(ref).success))
585
+ return failure("invalid_input", "recovery references are invalid");
586
+ try {
587
+ const workspace = this.readWorkspace();
588
+ if (!workspace.ok && target === "task") return workspace;
589
+ const workspaceValue = workspace.ok ? workspace.data : null;
590
+ if (workspaceValue && workspaceValue.revision !== input.expectedWorkspaceRevision)
591
+ return this.conflict(input.expectedWorkspaceRevision, workspaceValue.revision);
592
+ const file = target === "workspace" ? this.workspacePath : this.taskPath(taskId!);
593
+ const currentBytes = this.snapshotBytes(file);
594
+ if (!currentBytes) return failure("not_found", "snapshot not found");
595
+ if (digestBytes(currentBytes) !== input.expectedBytes)
596
+ return failure("revision_conflict", "snapshot bytes do not match expected bytes");
597
+ let selectedBytes = currentBytes;
598
+ if (digestBytes(currentBytes) !== input.snapshotDigest) {
599
+ const candidate = this.findRecovery(target, taskId, input.snapshotDigest, workspaceValue);
600
+ if (!candidate)
601
+ return failure("recovery_required", "validated recovery snapshot not found");
602
+ selectedBytes = candidate;
603
+ }
604
+ const selectedRecord =
605
+ target === "workspace"
606
+ ? this.parseBytes<WorkspaceRecord>(selectedBytes, workspaceRecordSchema)
607
+ : this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema);
608
+ if (!selectedRecord.ok) return selectedRecord;
609
+ if (target === "workspace") {
610
+ const parsed = selectedRecord as Result<WorkspaceRecord>;
611
+ if (!parsed.ok || parsed.data.root !== this.root)
612
+ return failure("recovery_required", "workspace recovery binding is invalid");
613
+ } else {
614
+ const parsed = selectedRecord as Result<TaskRecord>;
615
+ if (
616
+ !parsed.ok ||
617
+ parsed.data.id !== taskId ||
618
+ !workspaceValue ||
619
+ parsed.data.workspaceId !== workspaceValue.id
620
+ )
621
+ return failure("recovery_required", "task recovery binding is invalid");
622
+ }
623
+ const lock = this.readLockSnapshot();
624
+ if (!lock.ok) return lock;
625
+ const writer =
626
+ target === "workspace"
627
+ ? (selectedRecord.data as WorkspaceRecord).writer
628
+ : (workspaceValue?.writer ?? null);
629
+ const evidence = this.processEvidence(input, lock.data?.data ?? null, writer);
630
+ if (!evidence.ok) return evidence;
631
+ if (
632
+ lock.data &&
633
+ (evidence.data.pid !== lock.data.data.pid ||
634
+ evidence.data.processStart !== lock.data.data.processStart)
635
+ )
636
+ return failure("recovery_required", "process evidence does not match metadata lock");
637
+ if (
638
+ writer &&
639
+ (evidence.data.state !== "accounted_for" ||
640
+ evidence.data.ownerDigest !== sha256(canonicalJson(writer)))
641
+ )
642
+ return failure("recovery_required", "workspace writer is not accounted for");
643
+ const recoveryGate = { reclaimed: false };
644
+ const result = this.withLock<any>(
645
+ (handle) => {
646
+ if (lock.data && !recoveryGate.reclaimed)
647
+ return failure("recovery_required", "metadata lock changed during recovery", {
648
+ path: this.lockPath,
649
+ });
650
+ if (!handle.verifyStillHeld())
651
+ return failure("recovery_required", "metadata lock was compromised", {
652
+ path: this.lockPath,
653
+ });
654
+ const reacquiredBytes = this.snapshotBytes(file);
655
+ if (!reacquiredBytes)
656
+ return failure("recovery_required", "snapshot disappeared during recovery");
657
+ if (digestBytes(reacquiredBytes) !== input.expectedBytes)
658
+ return failure("revision_conflict", "snapshot changed during recovery");
659
+ if (target === "task") {
660
+ const currentWorkspace = this.readWorkspace();
661
+ if (!currentWorkspace.ok || !currentWorkspace.data)
662
+ return failure("recovery_required", "workspace changed during recovery");
663
+ if (currentWorkspace.data.revision !== input.expectedWorkspaceRevision)
664
+ return this.conflict(input.expectedWorkspaceRevision, currentWorkspace.data.revision);
665
+ }
666
+ if (target === "workspace") {
667
+ const parsed = this.parseBytes<WorkspaceRecord>(selectedBytes, workspaceRecordSchema);
668
+ if (!parsed.ok) return parsed;
669
+ const value = { ...parsed.data, revision: newRevision(), writer: null };
670
+ const replaced = this.replaceSnapshot(file, value, reacquiredBytes);
671
+ return replaced.ok ? success(value.revision, value.revision, value) : replaced;
672
+ }
673
+ const parsed = this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema);
674
+ if (!parsed.ok) return parsed;
675
+ const value = {
676
+ ...parsed.data,
677
+ revision: newRevision(),
678
+ updatedAt: now(),
679
+ status: parsed.data.status === "active" ? "paused" : parsed.data.status,
680
+ } as TaskRecord;
681
+ const replaced = this.replaceSnapshot(file, value, reacquiredBytes);
682
+ return replaced.ok ? success(value.revision, null, value) : replaced;
683
+ },
684
+ this.recoveryLockOptions(lock.data, evidence.data, recoveryGate),
685
+ );
686
+ return result;
687
+ } catch (error) {
688
+ return failure("recovery_required", `recovery protocol failed: ${String(error)}`);
689
+ }
690
+ }
691
+
692
+ private processEvidence(
693
+ input: RecoveryInput,
694
+ lock: MetadataLock | null,
695
+ writer: WorkspaceRecord["writer"],
696
+ ): Result<ProcessEvidence> {
697
+ const lockCopy = lock ? this.deepFreeze(structuredClone(lock)) : null;
698
+ const writerCopy = writer ? this.deepFreeze(structuredClone(writer)) : null;
699
+ const lockDigest = lockCopy ? sha256(canonicalJson(lockCopy)) : null;
700
+ const writerDigest = writerCopy ? sha256(canonicalJson(writerCopy)) : null;
701
+ try {
702
+ const result = input.processEvidence(lockCopy, writerCopy);
703
+ if (lockCopy && sha256(canonicalJson(lockCopy)) !== lockDigest)
704
+ return failure("recovery_required", "process evidence mutated lock identity");
705
+ if (writerCopy && sha256(canonicalJson(writerCopy)) !== writerDigest)
706
+ return failure("recovery_required", "process evidence mutated writer identity");
707
+ if (!result.ok) return result;
708
+ const parsed = processEvidenceSchema.safeParse(result.data);
709
+ return parsed.success
710
+ ? success(null, null, parsed.data)
711
+ : failure("recovery_required", "process evidence is invalid");
712
+ } catch (error) {
713
+ return failure("recovery_required", `process evidence failed: ${String(error)}`);
714
+ }
715
+ }
716
+
717
+ private deepFreeze<T>(value: T): T {
718
+ if (value && typeof value === "object") {
719
+ for (const child of Object.values(value as Record<string, unknown>)) this.deepFreeze(child);
720
+ Object.freeze(value);
721
+ }
722
+ return value;
723
+ }
724
+
725
+ private readLockSnapshot(): Result<LockSnapshot | null> {
726
+ try {
727
+ const stat = fs.lstatSync(this.lockPath);
728
+ if (!stat.isFile() || stat.isSymbolicLink())
729
+ return failure("recovery_required", "metadata lock is not a regular file", {
730
+ path: this.lockPath,
731
+ });
732
+ const raw = fs.readFileSync(this.lockPath, "utf8");
733
+ return success(null, null, { raw, data: parseMetadataLock(raw) });
734
+ } catch (error: any) {
735
+ if (error?.code === "ENOENT") return success(null, null, null);
736
+ return failure("recovery_required", `metadata lock is invalid: ${String(error)}`, {
737
+ path: this.lockPath,
738
+ });
739
+ }
740
+ }
741
+
742
+ private metadataLockOptions(): FileLockSyncAcquireOptions<MetadataLock> {
743
+ return {
744
+ lockPath: this.lockPath,
745
+ staleMs: Number.MAX_SAFE_INTEGER,
746
+ timeoutMs: 0,
747
+ retry: { retries: 0 },
748
+ staleRecovery: "fail-closed",
749
+ shouldReclaim: () => false,
750
+ parsePayload: parseMetadataLock,
751
+ payload: () => ({
752
+ pid: process.pid,
753
+ processStart: this.processStart(process.pid),
754
+ host: hostname(),
755
+ nonce: randomUUID(),
756
+ }),
757
+ };
758
+ }
759
+
760
+ private recoveryLockOptions(
761
+ observed: LockSnapshot | null,
762
+ evidence: ProcessEvidence,
763
+ gate: { reclaimed: boolean },
764
+ ): FileLockSyncAcquireOptions<MetadataLock> {
765
+ const options = this.metadataLockOptions();
766
+ options.staleRecovery = "remove-if-unchanged";
767
+ options.shouldReclaim = ({ payload }) =>
768
+ Boolean(
769
+ observed &&
770
+ sameMetadataLock(payload, observed.data) &&
771
+ (evidence.state === "stopped" || evidence.state === "accounted_for"),
772
+ );
773
+ options.shouldRemoveStaleLock = ({ raw, payload }) => {
774
+ if (!observed || raw !== observed.raw || !sameMetadataLock(payload, observed.data))
775
+ return false;
776
+ gate.reclaimed = true;
777
+ return true;
778
+ };
779
+ return options;
780
+ }
781
+
782
+ private markUncertain(workspace: WorkspaceRecord) {
783
+ if (!workspace.writer || workspace.writer.state === "uncertain") return;
784
+ const value = {
785
+ ...workspace,
786
+ writer: { ...workspace.writer, state: "uncertain" as const },
787
+ revision: newRevision(),
788
+ };
789
+ this.replaceSnapshot(this.workspacePath, value, workspace);
790
+ }
791
+
792
+ private withLock<T>(
793
+ operation: (handle: FileLockSyncHandle) => Result<T>,
794
+ options: FileLockSyncAcquireOptions<MetadataLock> = this.metadataLockOptions(),
795
+ ): Result<T> {
796
+ try {
797
+ this.initializeMutationStorage();
798
+ } catch (error) {
799
+ return failure("storage_error", `unable to initialize store: ${String(error)}`, {
800
+ path: this.workitDir,
801
+ });
802
+ }
803
+ let handle: FileLockSyncHandle | undefined;
804
+ let result: Result<T> = failure(
805
+ "storage_error",
806
+ "metadata lock operation did not produce a result",
807
+ );
808
+ try {
809
+ handle = acquireFileLockSync(this.workspacePath, options);
810
+ } catch (error) {
811
+ result = this.lockFailure(error);
812
+ }
813
+ if (handle) {
814
+ try {
815
+ if (!handle.verifyStillHeld())
816
+ result = failure("recovery_required", "metadata lock was compromised", {
817
+ path: this.lockPath,
818
+ });
819
+ else {
820
+ try {
821
+ result = operation(handle);
822
+ } catch (error) {
823
+ result = failure("storage_error", `mutation failed: ${String(error)}`);
824
+ }
825
+ }
826
+ } catch (error) {
827
+ result = this.lockFailure(error);
828
+ }
829
+ }
830
+ if (handle) {
831
+ try {
832
+ handle.release();
833
+ } catch (error) {
834
+ const releaseFailure = this.lockFailure(error);
835
+ const releaseError = releaseFailure.ok
836
+ ? "metadata lock release failed"
837
+ : releaseFailure.error;
838
+ result = result.ok
839
+ ? releaseFailure
840
+ : failure("recovery_required", `${result.error}; ${releaseError}`, result.details);
841
+ }
842
+ }
843
+ return result;
844
+ }
845
+
846
+ private lockFailure(error: unknown): Result<never> {
847
+ const value = error as { code?: unknown; message?: unknown };
848
+ const code = typeof value?.code === "string" ? value.code : "";
849
+ const recovery =
850
+ code === "EEXIST" ||
851
+ code === "file_lock_timeout" ||
852
+ code === "file_lock_stale" ||
853
+ code === "metadata_lock_invalid" ||
854
+ code === "not-file" ||
855
+ /metadata lock|file lock|reclaim/i.test(String(value?.message ?? error));
856
+ return failure(
857
+ recovery ? "recovery_required" : "storage_error",
858
+ `metadata lock operation failed: ${String(error)}`,
859
+ { path: this.lockPath },
860
+ );
861
+ }
862
+
863
+ private initializeMutationStorage() {
864
+ fs.mkdirSync(this.tasksDir, { recursive: true });
865
+ fs.mkdirSync(this.recoveryDir, { recursive: true });
866
+ fs.writeFileSync(this.gitignorePath, "*\n");
867
+ }
868
+
869
+ private replaceSnapshot(file: string, value: unknown, previous: unknown): Result<any> {
870
+ let temporary: string | undefined;
871
+ try {
872
+ if (Buffer.isBuffer(previous)) this.saveRecovery(file, previous);
873
+ else if (previous !== null && typeof previous === "object")
874
+ this.saveRecovery(file, jsonBytes(previous));
875
+ else if (typeof previous === "string") this.saveRecovery(file, previous);
876
+ temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
877
+ const fd = fs.openSync(temporary, "wx", 0o600);
878
+ try {
879
+ fs.writeSync(fd, jsonBytes(value));
880
+ fs.fsyncSync(fd);
881
+ } finally {
882
+ fs.closeSync(fd);
883
+ }
884
+ fs.renameSync(temporary, file);
885
+ temporary = undefined;
886
+ this.fsyncDirectory(path.dirname(file));
887
+ return success(null, null, value);
888
+ } catch (error) {
889
+ return failure("storage_error", `snapshot replacement failed: ${String(error)}`, {
890
+ path: file,
891
+ });
892
+ } finally {
893
+ if (temporary)
894
+ try {
895
+ fs.unlinkSync(temporary);
896
+ } catch {}
897
+ }
898
+ }
899
+
900
+ private saveRecovery(file: string, bytes: string | Buffer) {
901
+ const target = path.basename(file) === "workspace.json" ? "workspace" : "task";
902
+ const id = target === "task" ? path.basename(file, ".json") : "workspace";
903
+ const destination = path.join(this.recoveryDir, `${target}.${id}.${digestBytes(bytes)}.json`);
904
+ if (fs.existsSync(destination)) {
905
+ if (digestBytes(fs.readFileSync(destination)) === digestBytes(bytes)) return;
906
+ throw new Error("recovery copy already exists with different bytes");
907
+ }
908
+ let temporary: string | undefined;
909
+ try {
910
+ temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
911
+ const fd = fs.openSync(temporary, "wx", 0o600);
912
+ try {
913
+ fs.writeSync(fd, bytes as any);
914
+ fs.fsyncSync(fd);
915
+ } finally {
916
+ fs.closeSync(fd);
917
+ }
918
+ fs.renameSync(temporary, destination);
919
+ temporary = undefined;
920
+ this.fsyncDirectory(this.recoveryDir);
921
+ } finally {
922
+ if (temporary)
923
+ try {
924
+ fs.unlinkSync(temporary);
925
+ } catch {}
926
+ }
927
+ }
928
+
929
+ private findRecovery(
930
+ target: "task" | "workspace",
931
+ taskId: Id | null,
932
+ digest: string,
933
+ workspace: WorkspaceRecord | null,
934
+ ): Buffer | null {
935
+ if (!fs.existsSync(this.recoveryDir)) return null;
936
+ const workspaceId = workspace?.id ?? this.trustedWorkspaceId();
937
+ if (target === "workspace" && !workspaceId) return null;
938
+ const candidates = this.recoveryCandidates();
939
+ if (!candidates.ok) return null;
940
+ for (const candidate of candidates.data) {
941
+ if (candidate.target !== target || candidate.digest !== digest) continue;
942
+ try {
943
+ if (!fs.lstatSync(candidate.path).isFile()) continue;
944
+ const bytes = fs.readFileSync(candidate.path);
945
+ if (digestBytes(bytes) !== digest) continue;
946
+ if (target === "workspace") {
947
+ const parsed = this.parseBytes<WorkspaceRecord>(bytes, workspaceRecordSchema);
948
+ if (parsed.ok && parsed.data.id === workspaceId && parsed.data.root === this.root)
949
+ return bytes;
950
+ } else {
951
+ const parsed = this.parseBytes<TaskRecord>(bytes, taskRecordSchema);
952
+ if (parsed.ok && parsed.data.id === taskId && parsed.data.workspaceId === workspace?.id)
953
+ return bytes;
954
+ }
955
+ } catch {}
956
+ }
957
+ return null;
958
+ }
959
+
960
+ private fsyncDirectory(directory: string) {
961
+ try {
962
+ const fd = fs.openSync(directory, "r");
963
+ try {
964
+ fs.fsyncSync(fd);
965
+ } finally {
966
+ fs.closeSync(fd);
967
+ }
968
+ } catch (error) {
969
+ // Windows rejects fsync on a directory handle; the rename is the durable step there.
970
+ if (process.platform !== "win32") throw error;
971
+ }
972
+ }
973
+
974
+ private trustedWorkspaceId(): Id | null {
975
+ if (!fs.existsSync(this.tasksDir)) return null;
976
+ const ids = new Set<Id>();
977
+ try {
978
+ for (const name of fs.readdirSync(this.tasksDir)) {
979
+ if (!name.endsWith(".json") || !validId(name.slice(0, -5))) return null;
980
+ const parsed = this.parseBytes<TaskRecord>(
981
+ fs.readFileSync(path.join(this.tasksDir, name)),
982
+ taskRecordSchema,
983
+ );
984
+ if (!parsed.ok || parsed.data.id !== name.slice(0, -5)) return null;
985
+ ids.add(parsed.data.workspaceId);
986
+ }
987
+ } catch {
988
+ return null;
989
+ }
990
+ return ids.size === 1 ? [...ids][0] : null;
991
+ }
992
+
993
+ private snapshotBytes(file: string): Buffer | null {
994
+ try {
995
+ return fs.readFileSync(file);
996
+ } catch {
997
+ return null;
998
+ }
999
+ }
1000
+
1001
+ private readRecord<T>(file: string, schema: { safeParse(value: unknown): any }) {
1002
+ try {
1003
+ const bytes = fs.readFileSync(file, "utf8");
1004
+ return { exists: true, result: this.parseBytes<T>(bytes, schema) };
1005
+ } catch (error: any) {
1006
+ if (error?.code === "ENOENT")
1007
+ return { exists: false, result: success(null, null, null as T) };
1008
+ return {
1009
+ exists: true,
1010
+ result: failure("recovery_required", `snapshot cannot be read: ${String(error)}`, {
1011
+ path: file,
1012
+ }),
1013
+ };
1014
+ }
1015
+ }
1016
+
1017
+ private parseBytes<T>(
1018
+ bytes: string | Buffer,
1019
+ schema: { safeParse(value: unknown): any },
1020
+ ): Result<T> {
1021
+ let value: unknown;
1022
+ try {
1023
+ value = JSON.parse(typeof bytes === "string" ? bytes : bytes.toString("utf8"));
1024
+ } catch {
1025
+ return failure("recovery_required", "snapshot JSON is corrupt");
1026
+ }
1027
+ if (isObject(value) && "schemaVersion" in value && value.schemaVersion !== SCHEMA_VERSION)
1028
+ return failure("unsupported_version", "unsupported snapshot schema version");
1029
+ const parsed = schema.safeParse(value);
1030
+ return parsed.success
1031
+ ? success(null, null, parsed.data)
1032
+ : failure("recovery_required", "snapshot does not satisfy its schema");
1033
+ }
1034
+
1035
+ private processStart(pid: number): string | null {
1036
+ try {
1037
+ return fs.readFileSync(`/proc/${pid}/stat`, "utf8").split(" ")[21] ?? null;
1038
+ } catch {
1039
+ return null;
1040
+ }
1041
+ }
1042
+
1043
+ private conflict(expected: Revision, actual: Revision): Result<never> {
1044
+ return failure("revision_conflict", "snapshot revision does not match", {
1045
+ expectedRevision: expected,
1046
+ actualRevision: actual,
1047
+ });
1048
+ }
1049
+
1050
+ private taskPath(taskId: Id) {
1051
+ return path.join(this.tasksDir, `${taskId}.json`);
1052
+ }
1053
+ private get workitDir() {
1054
+ return path.join(this.root, ".workit");
1055
+ }
1056
+ private get tasksDir() {
1057
+ return path.join(this.workitDir, "tasks");
1058
+ }
1059
+ private get recoveryDir() {
1060
+ return path.join(this.workitDir, "recovery");
1061
+ }
1062
+ private get workspacePath() {
1063
+ return path.join(this.workitDir, "workspace.json");
1064
+ }
1065
+ private get lockPath() {
1066
+ return path.join(this.workitDir, "metadata.lock");
1067
+ }
1068
+ private get gitignorePath() {
1069
+ return path.join(this.workitDir, ".gitignore");
1070
+ }
1071
+ }