@workit-dev/core 0.0.1 → 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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,2248 @@
1
+ import { realpathSync } from "node:fs";
2
+ import {
3
+ CHECK_OBSERVATION_PATH,
4
+ POLICY_VERSION,
5
+ canonicalJson,
6
+ checkObservationSchema,
7
+ failure,
8
+ decisionDigest,
9
+ exportBundleSchema,
10
+ decisionSchema,
11
+ evidenceSchema,
12
+ findingSchema,
13
+ intentSchema,
14
+ judgmentSchema,
15
+ legacyAssessmentSchema,
16
+ newId,
17
+ parseOperation,
18
+ resolveIdRef,
19
+ rewriteRecordRefs,
20
+ success,
21
+ type Candidate,
22
+ type Caller,
23
+ type CheckObservation,
24
+ type Decision,
25
+ type Entry,
26
+ type Evidence,
27
+ type ExportBundle,
28
+ type Finding,
29
+ type Judgment,
30
+ type Policy,
31
+ provenanceSchema,
32
+ type Ref,
33
+ type Result,
34
+ type Scope,
35
+ type TaskRecord,
36
+ type TaskBrief,
37
+ type TaskListItem,
38
+ type TaskSummary,
39
+ type TaskView,
40
+ type Utc,
41
+ type Provenance,
42
+ type Worker,
43
+ type OperationFamily,
44
+ type WorkspaceRecord,
45
+ taskRecordSchema,
46
+ scopeCovers as bindingCovers,
47
+ } from "./task-contract";
48
+ import { diffPolicy, resolvePolicy } from "./policy-resolver";
49
+ import { normalizeOperationInput } from "./operation-input";
50
+ import { latestJudgment } from "./policy/derive";
51
+ import {
52
+ givenReason,
53
+ judgeInputSummary,
54
+ liftedBlockers,
55
+ normalizeJudgment,
56
+ ownLiftedBlockers,
57
+ } from "./policy/judgment";
58
+ import { MAX_LINE_BYTES, appendObserved, observedRowBytes } from "../ledger";
59
+
60
+ /** Refs kept in a `policy.judged` row (the task store keeps all of them). */
61
+ const MAX_JUDGED_REFS = 5;
62
+ import { currentBranch, headSha } from "../git/rev";
63
+ import { resolveAutonomy, type VerificationMode } from "../autonomy";
64
+
65
+ import { TaskStore } from "./task-store";
66
+ import {
67
+ compactContext,
68
+ contextRootError,
69
+ environment,
70
+ helperEntry,
71
+ helperTaskGuard,
72
+ sameSession,
73
+ taskView,
74
+ type OperationContext,
75
+ } from "./task-view";
76
+ import { defaultLockTimeout } from "./store-lock";
77
+ import {
78
+ exportDigest,
79
+ reconcileResume as reconcileResumeContext,
80
+ type ResumeReconciliation,
81
+ } from "./task-context";
82
+ import {
83
+ isUncertainWorker,
84
+ workerBlocksTransition,
85
+ type NativeWorkerObservation,
86
+ type NativeWorkerVerification,
87
+ type NativeWorkerVerifier,
88
+ } from "./workers";
89
+ import {
90
+ boundTaskSummary,
91
+ captureCandidate,
92
+ checkPin,
93
+ treeFreshness,
94
+ type Freshness,
95
+ evaluateClosure,
96
+ evaluateEvidence,
97
+ evaluateRequirements,
98
+ findingVerificationPasses,
99
+ resolveBinding,
100
+ verifyDecisionContentAtRoot,
101
+ } from "./task-evaluation";
102
+
103
+ /** The user-config verification mode for `root`; `self` when it cannot be read. */
104
+ const workspaceVerification = (root: string): VerificationMode => {
105
+ try {
106
+ return resolveAutonomy(root).verification;
107
+ } catch {
108
+ return "self";
109
+ }
110
+ };
111
+
112
+ export type { OperationContext } from "./task-view";
113
+
114
+ export type ObserveCheckRequest = {
115
+ taskId: string;
116
+ /** Omit to retry over concurrent writers (S2b); a value is strict compare-and-swap. */
117
+ expectedRevision?: string;
118
+ observation: CheckObservation;
119
+ };
120
+
121
+ const provenance = (
122
+ context: OperationContext,
123
+ kind: "host_observed" | "agent_reported" = context.provenanceKind ?? "host_observed",
124
+ ) => ({
125
+ kind,
126
+ host: context.caller.host,
127
+ session: { kind: "host" as const, host: context.caller.host, handle: context.caller.actor },
128
+ workerId: context.workerId ?? null,
129
+ });
130
+
131
+ type WorkerBinding = {
132
+ owner: object;
133
+ store: TaskStore;
134
+ root: string;
135
+ caller: Caller;
136
+ };
137
+ type WorkerAuthority = {
138
+ taskId: string;
139
+ workspaceId: string;
140
+ workerId: string;
141
+ expectedRevision: string;
142
+ expectedWorkspaceRevision: string;
143
+ state: NativeWorkerObservation["state"];
144
+ session: NativeWorkerObservation["session"];
145
+ report: NativeWorkerObservation["report"] | null;
146
+ provenance: Provenance;
147
+ owner: object;
148
+ store: TaskStore;
149
+ root: string;
150
+ caller: Caller;
151
+ };
152
+ type VerifiedWorker = object;
153
+ const verifiedWorkers = new WeakMap<object, WorkerAuthority>();
154
+
155
+ const sameValue = (left: unknown, right: unknown): boolean => {
156
+ try {
157
+ return canonicalJson(left) === canonicalJson(right);
158
+ } catch {
159
+ return false;
160
+ }
161
+ };
162
+
163
+ const validNativeProvenance = (
164
+ value: unknown,
165
+ caller: Caller,
166
+ expected: Pick<NativeWorkerObservation, "workerId" | "session">,
167
+ ): value is Provenance => {
168
+ if (!provenanceSchema.safeParse(value).success) return false;
169
+ const observed = value as Provenance;
170
+ return (
171
+ observed.kind === "host_observed" &&
172
+ observed.host === caller.host &&
173
+ observed.workerId === expected.workerId &&
174
+ sameValue(observed.session, expected.session)
175
+ );
176
+ };
177
+
178
+ const verifyNativeWorker = (
179
+ verifier: NativeWorkerVerifier | undefined,
180
+ input: NativeWorkerVerification,
181
+ binding: WorkerBinding,
182
+ ): Result<VerifiedWorker> => {
183
+ if (!verifier) return failure("permission_denied", "native worker verifier is unavailable");
184
+ let result: Result<Provenance>;
185
+ try {
186
+ result = verifier.verifyWorker(input);
187
+ } catch (error) {
188
+ return failure("permission_denied", `native worker observation failed: ${String(error)}`);
189
+ }
190
+ const expected = input.expected;
191
+ if (!result.ok || !validNativeProvenance(result.data, input.caller, expected))
192
+ return failure("permission_denied", "native worker observation was not attested");
193
+ const token = {};
194
+ verifiedWorkers.set(token, {
195
+ taskId: expected.taskId,
196
+ workspaceId: expected.workspaceId,
197
+ workerId: expected.workerId,
198
+ expectedRevision: expected.expectedRevision,
199
+ expectedWorkspaceRevision: expected.expectedWorkspaceRevision,
200
+ state: expected.state,
201
+ session: expected.session,
202
+ report: expected.report ?? null,
203
+ provenance: result.data,
204
+ ...binding,
205
+ });
206
+ return success(null, null, token);
207
+ };
208
+
209
+ const takeWorker = (
210
+ token: VerifiedWorker,
211
+ binding: WorkerBinding,
212
+ expected: NativeWorkerObservation,
213
+ ): WorkerAuthority | null => {
214
+ const value = verifiedWorkers.get(token);
215
+ verifiedWorkers.delete(token);
216
+ if (!value) return null;
217
+ if (
218
+ value.owner !== binding.owner ||
219
+ value.store !== binding.store ||
220
+ value.root !== binding.root ||
221
+ value.root !== value.store.root ||
222
+ !sameValue(value.caller, binding.caller) ||
223
+ value.taskId !== expected.taskId ||
224
+ value.workerId !== expected.workerId ||
225
+ value.expectedRevision !== expected.expectedRevision ||
226
+ value.expectedWorkspaceRevision !== expected.expectedWorkspaceRevision ||
227
+ value.state !== expected.state ||
228
+ !sameValue(value.session, expected.session) ||
229
+ !sameValue(value.report, expected.report ?? null)
230
+ )
231
+ return null;
232
+ return value;
233
+ };
234
+
235
+ type ObserveWorkerLifecycleInput = NativeWorkerObservation & {
236
+ store: TaskStore;
237
+ authority: VerifiedWorker;
238
+ authorityOwner: object;
239
+ caller: Caller;
240
+ now?: Utc;
241
+ };
242
+
243
+ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<Worker>> => {
244
+ if (!input.store || !input.authority || !input.authorityOwner)
245
+ return failure("invalid_input", "native worker observation preconditions are required");
246
+ const authority = takeWorker(
247
+ input.authority,
248
+ {
249
+ owner: input.authorityOwner,
250
+ store: input.store,
251
+ root: input.store.root,
252
+ caller: input.caller,
253
+ },
254
+ input,
255
+ );
256
+ if (!authority) return failure("permission_denied", "native worker observation is not verified");
257
+
258
+ const task = input.store.readTask(input.taskId);
259
+ if (!task.ok) return task;
260
+ const workspace = input.store.readWorkspace();
261
+ if (!workspace.ok) return workspace;
262
+ if (!workspace.data) return failure("not_found", "workspace not found");
263
+ if (
264
+ task.data.workspaceId !== authority.workspaceId ||
265
+ workspace.data.id !== authority.workspaceId
266
+ )
267
+ return failure("recovery_required", "worker workspace binding is invalid");
268
+ const entry = task.data.workers.find((candidate) => candidate.id === input.workerId);
269
+ if (!entry) return failure("not_found", "worker not found");
270
+ if (task.data.status === "closed")
271
+ return failure("invalid_transition", "closed task cannot observe workers");
272
+ if (task.data.status === "paused" && (input.state === "running" || input.state === "cancelling"))
273
+ return failure("invalid_transition", "paused task cannot run a worker");
274
+ // Compare-and-swap first: a stale observation (another process launched
275
+ // and observed this worker meanwhile) must not pass as a no-op below.
276
+ if (input.expectedRevision !== task.data.revision)
277
+ return failure("revision_conflict", "worker observation is stale; re-read the task", {
278
+ expectedRevision: input.expectedRevision,
279
+ actualRevision: task.data.revision,
280
+ });
281
+ if (input.report && input.state !== "stopped")
282
+ return failure("invalid_input", "worker reports require a stopped worker");
283
+ if (
284
+ (input.state === "running" || input.state === "cancelling" || input.state === "stopped") &&
285
+ !input.session
286
+ )
287
+ return failure("invalid_input", "running worker observations require a session");
288
+ if (entry.data.session && !sameValue(entry.data.session, input.session))
289
+ return failure("permission_denied", "worker session does not match its assignment");
290
+ // `unknown` from `assigned` records a launch whose outcome was never
291
+ // observed (its launcher died); the lead must cancel before a relaunch.
292
+ if (
293
+ entry.data.state === "assigned" &&
294
+ input.state !== "running" &&
295
+ input.state !== "stopped" &&
296
+ input.state !== "unknown"
297
+ )
298
+ return failure("invalid_transition", "worker has not started");
299
+ if (input.state === "running" && entry.data.state === "stopped")
300
+ return failure("invalid_transition", "stopped worker cannot run again");
301
+ const terminalCancel = entry.data.state === "cancelling" && input.state === "running";
302
+ // The first observation replaces the assigning provenance with the child's,
303
+ // so the assigning coordinator session is kept on the worker: a restarted
304
+ // host still attributes the running child to its coordinator.
305
+ const coordinator =
306
+ entry.data.coordinator ??
307
+ (entry.data.state === "assigned" && entry.provenance.session?.kind === "host"
308
+ ? entry.provenance.session
309
+ : undefined);
310
+ const nextEntry = {
311
+ ...entry,
312
+ recordedAt: input.now ?? entry.recordedAt,
313
+ provenance: authority.provenance,
314
+ data: {
315
+ ...entry.data,
316
+ ...(coordinator ? { coordinator } : {}),
317
+ state: terminalCancel ? ("cancelling" as const) : input.state,
318
+ session: input.session,
319
+ report: entry.data.report ?? input.report ?? null,
320
+ },
321
+ } satisfies Entry<Worker>;
322
+ // A no-op observation (same state, same session)
323
+ // must not rewrite the entry: hosts observe on every session event, and a
324
+ // revision bump per event livelocks worker sessions — each call would
325
+ // invalidate the revision the previous call returned.
326
+ if (
327
+ entry.data.state === nextEntry.data.state &&
328
+ sameValue(entry.data.session, input.session) &&
329
+ sameValue(entry.data.report, nextEntry.data.report)
330
+ )
331
+ return success(task.data.revision, workspace.data.revision, entry);
332
+ const changed = input.store.mutateTaskAndWorkspace({
333
+ taskId: task.data.id,
334
+ expectedRevision: input.expectedRevision,
335
+ expectedWorkspaceRevision: input.expectedWorkspaceRevision,
336
+ now: input.now,
337
+ workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
338
+ task: (current, mutation) =>
339
+ success(mutation.revision, null, {
340
+ ...current,
341
+ workers: current.workers.map((candidate) =>
342
+ candidate.id === nextEntry.id ? { ...nextEntry, recordedAt: mutation.now } : candidate,
343
+ ),
344
+ }),
345
+ });
346
+ if (!changed.ok) return changed;
347
+ const updated = changed.data.task.workers.find((candidate) => candidate.id === input.workerId);
348
+ return updated
349
+ ? success(changed.data.task.revision, changed.data.workspace.revision, updated)
350
+ : failure("recovery_required", "worker disappeared during lifecycle observation");
351
+ };
352
+
353
+ const refsWithinScope = (refs: Ref[], scope: Scope): boolean =>
354
+ refs.every(
355
+ (ref) =>
356
+ ref.kind !== "file" ||
357
+ bindingCovers(scope, { description: "", paths: [ref.path], exclusions: [] }),
358
+ );
359
+
360
+ /** Actions that take a taskId; without one they apply to the implicit task. */
361
+ const TASK_SCOPED_ACTIONS: ReadonlySet<string> = new Set([
362
+ "task.inspect",
363
+ "task.revise",
364
+ "task.progress",
365
+ "task.pause",
366
+ "task.resume",
367
+ "task.close",
368
+ "policy.assess",
369
+ "policy.preview",
370
+ "policy.explain",
371
+ "evidence.record",
372
+ "finding.record",
373
+ "finding.resolve",
374
+ "decision.record",
375
+ "decision.revoke",
376
+ "worker.assign",
377
+ "worker.report",
378
+ "worker.cancel",
379
+ "state.export",
380
+ ]);
381
+ /** Worker actions that name their worker: they resolve to that worker's task. */
382
+ const ONLY_OWN_REPORT = "workers can submit only their own report";
383
+ const NO_HELPER_CONTROL = "helpers cannot cancel or control workers";
384
+ const WORKER_ADDRESSED_ACTIONS: ReadonlySet<string> = new Set(["worker.cancel", "worker.report"]);
385
+ /**
386
+ * The exact CLI command that runs worker `request` (cancel or report, with a
387
+ * valid workerId) on `taskId`, so a retry never depends on the branch.
388
+ */
389
+ const workerCommand = (taskId: string, request: unknown): string => {
390
+ const { action, workerId, reason, report } = request as Record<string, unknown>;
391
+ const rest = JSON.stringify({ reason, report });
392
+ const payload = rest === "{}" ? "" : ` --payload '${rest.replace(/'/gu, "'\\''")}'`;
393
+ return `workit worker ${String(action)} ${String(workerId)} --task ${taskId}${payload}`;
394
+ };
395
+ /** Recording actions that create the implicit task on first use. */
396
+ const CREATING_ACTIONS: ReadonlySet<string> = new Set([
397
+ "task.revise",
398
+ "task.progress",
399
+ "policy.assess",
400
+ "evidence.record",
401
+ "finding.record",
402
+ "decision.record",
403
+ "worker.assign",
404
+ ]);
405
+ /** Commit attempts for a call whose revisions the engine filled (see retryFilledRevisions). */
406
+ const REVISION_RETRY_ATTEMPTS = 8;
407
+ const retryPause = new Int32Array(new SharedArrayBuffer(4));
408
+ /** Which revisions the caller left for the engine to fill from its own read. */
409
+ type FilledRevisions = { task: boolean; workspace: boolean };
410
+ const filledRevisions = (request: unknown): FilledRevisions => {
411
+ const value = (typeof request === "object" && request !== null ? request : {}) as {
412
+ expectedRevision?: unknown;
413
+ expectedWorkspaceRevision?: unknown;
414
+ };
415
+ return {
416
+ task: value.expectedRevision === undefined,
417
+ workspace: value.expectedWorkspaceRevision === undefined,
418
+ };
419
+ };
420
+ /**
421
+ * A store compare-and-swap rejection (it carries the actual revision) on a
422
+ * revision the engine filled. A conflict on a caller-supplied revision, or a
423
+ * semantic conflict such as a changed import source, is never retried.
424
+ */
425
+ const filledConflict = (result: Result<unknown>, filled: FilledRevisions): boolean =>
426
+ !result.ok &&
427
+ result.code === "revision_conflict" &&
428
+ (("actualRevision" in result.details && filled.task) ||
429
+ ("actualWorkspaceRevision" in result.details && filled.workspace));
430
+ /**
431
+ * Re-run `run` while it loses a compare-and-swap race on an engine-filled
432
+ * revision. Each attempt commits at most once and a CAS rejection commits
433
+ * nothing, so a success is applied exactly once. Attempts stop at
434
+ * REVISION_RETRY_ATTEMPTS or once the lock budget has elapsed — so blocking
435
+ * stays within about twice that budget — and end as retryable busy.
436
+ */
437
+ const retryFilledRevisions = <T>(filled: FilledRevisions, run: () => Result<T>): Result<T> => {
438
+ if (!filled.task && !filled.workspace) return run();
439
+ let result = run();
440
+ // The deadline starts after the first attempt, so a slow first lock wait
441
+ // still leaves at least one retry.
442
+ const deadline = Date.now() + defaultLockTimeout();
443
+ for (let attempt = 1; filledConflict(result, filled); attempt += 1) {
444
+ if (attempt >= REVISION_RETRY_ATTEMPTS || Date.now() >= deadline)
445
+ return failure("busy", "records kept changing under concurrent writers; retry the call");
446
+ // Jittered backoff de-synchronizes writers that lost the same race.
447
+ Atomics.wait(retryPause, 0, 0, Math.floor(Math.random() * 4 * attempt) + 1);
448
+ result = run();
449
+ }
450
+ return result;
451
+ };
452
+
453
+ const trustedNow = (context: OperationContext): Utc =>
454
+ typeof context.now === "function" ? context.now() : context.now;
455
+
456
+ const mapRef = (ref: Ref, ids: Map<string, string>): Ref => {
457
+ if (ref.kind !== "record") return ref;
458
+ return { ...ref, id: ids.get(ref.id) ?? ref.id };
459
+ };
460
+
461
+ const portableRef = (ref: Ref | null): Ref | null =>
462
+ ref && (ref.kind === "file" || ref.kind === "record") ? ref : null;
463
+ const importedProvenance = (context: OperationContext): Provenance => ({
464
+ kind: "imported",
465
+ host: context.caller.host,
466
+ session: null,
467
+ workerId: null,
468
+ });
469
+
470
+ const portableTask = (task: TaskRecord): TaskRecord => {
471
+ // Schema-driven ref stripping: every Ref anywhere in the record is mapped
472
+ // by shape (arrays drop unportable refs, nullable fields null them), so a
473
+ // new ref-bearing field can never silently leak across checkouts.
474
+ const stripped = rewriteRecordRefs(task, taskRecordSchema, portableRef);
475
+ if (typeof stripped !== "object" || stripped === null || Array.isArray(stripped))
476
+ throw new TypeError("portable task rewrite produced a non-record");
477
+ const clone = structuredClone(stripped) as TaskRecord;
478
+ // Candidate metadata can contain environment-derived paths and digests. The destination
479
+ // must recapture its own candidate instead of receiving source checkout material.
480
+ clone.candidates = [];
481
+ return clone;
482
+ };
483
+
484
+ const importedTask = (
485
+ source: TaskRecord,
486
+ bundle: ExportBundle,
487
+ destinationWorkspaceId: string,
488
+ context: OperationContext,
489
+ timestamp: Utc,
490
+ ): TaskRecord => {
491
+ const ids = new Map<string, string>();
492
+ const allocate = (id: string) => {
493
+ const existing = ids.get(id);
494
+ if (existing) return existing;
495
+ const value = newId();
496
+ ids.set(id, value);
497
+ return value;
498
+ };
499
+ for (const entry of [
500
+ source.intent,
501
+ ...source.assessments,
502
+ ...(source.judgments ?? []),
503
+ ...source.evidence,
504
+ ...source.decisions,
505
+ ...source.findings,
506
+ ...source.workers,
507
+ ])
508
+ allocate(entry.id);
509
+ const fresh = (id: string) => allocate(id);
510
+ const imported = importedProvenance(context);
511
+ const remap = (ref: Ref): Ref | null => mapRef(ref, ids);
512
+ const intent = {
513
+ ...source.intent,
514
+ id: fresh(source.intent.id),
515
+ recordedAt: timestamp,
516
+ provenance: imported,
517
+ data: rewriteRecordRefs(source.intent.data, intentSchema, remap) as typeof source.intent.data,
518
+ };
519
+ const assessments = source.assessments.map((entry) => {
520
+ const data = entry.data;
521
+ return {
522
+ ...entry,
523
+ id: fresh(entry.id),
524
+ recordedAt: timestamp,
525
+ provenance: imported,
526
+ data: rewriteRecordRefs(data, legacyAssessmentSchema, remap) as typeof data,
527
+ };
528
+ });
529
+ const judgments = source.judgments?.map((entry) => ({
530
+ ...entry,
531
+ id: fresh(entry.id),
532
+ recordedAt: timestamp,
533
+ provenance: imported,
534
+ data: rewriteRecordRefs(entry.data, judgmentSchema, remap) as typeof entry.data,
535
+ }));
536
+ const evidence = source.evidence.map((entry) => ({
537
+ ...entry,
538
+ id: fresh(entry.id),
539
+ recordedAt: timestamp,
540
+ provenance: imported,
541
+ data: rewriteRecordRefs(entry.data, evidenceSchema, remap) as typeof entry.data,
542
+ }));
543
+ const decisions = source.decisions.map((entry) => ({
544
+ ...entry,
545
+ id: fresh(entry.id),
546
+ recordedAt: timestamp,
547
+ provenance: imported,
548
+ }));
549
+ const findings = source.findings.map((entry) => {
550
+ const data = rewriteRecordRefs(entry.data, findingSchema, remap) as typeof entry.data;
551
+ return {
552
+ ...entry,
553
+ id: fresh(entry.id),
554
+ recordedAt: timestamp,
555
+ provenance: imported,
556
+ data: {
557
+ ...data,
558
+ resolution: data.resolution
559
+ ? {
560
+ ...data.resolution,
561
+ evidenceIds: data.resolution.evidenceIds.map((id) => ids.get(id) ?? id),
562
+ decisionIds: data.resolution.decisionIds.map((id) => ids.get(id) ?? id),
563
+ }
564
+ : null,
565
+ },
566
+ };
567
+ });
568
+ const workers = source.workers.map((entry) => ({
569
+ ...entry,
570
+ id: fresh(entry.id),
571
+ recordedAt: timestamp,
572
+ provenance: imported,
573
+ data: {
574
+ ...entry.data,
575
+ state: "stopped" as const,
576
+ session: null,
577
+ assignment: {
578
+ ...entry.data.assignment,
579
+ decisionIds: entry.data.assignment.decisionIds.map((id) => ids.get(id) ?? id),
580
+ },
581
+ report: entry.data.report
582
+ ? {
583
+ ...entry.data.report,
584
+ evidenceIds: entry.data.report.evidenceIds.map((id) => ids.get(id) ?? id),
585
+ findingIds: entry.data.report.findingIds.map((id) => ids.get(id) ?? id),
586
+ }
587
+ : null,
588
+ },
589
+ }));
590
+ const task: TaskRecord = {
591
+ ...source,
592
+ id: newId(),
593
+ workspaceId: destinationWorkspaceId,
594
+ revision: newId(),
595
+ createdAt: timestamp,
596
+ updatedAt: timestamp,
597
+ origin: {
598
+ workspaceId: bundle.sourceWorkspaceId,
599
+ taskId: bundle.task.id,
600
+ exportDigest: bundle.digest,
601
+ },
602
+ intent,
603
+ constraints: context.constraints,
604
+ status: "paused",
605
+ closure: null,
606
+ assessments,
607
+ ...(judgments ? { judgments } : {}),
608
+ evidence,
609
+ decisions: decisions.map((entry) => {
610
+ const data = rewriteRecordRefs(entry.data, decisionSchema, remap) as typeof entry.data;
611
+ return {
612
+ ...entry,
613
+ data: {
614
+ ...data,
615
+ binding: {
616
+ ...data.binding,
617
+ taskId: "",
618
+ workspaceId: destinationWorkspaceId,
619
+ },
620
+ },
621
+ };
622
+ }),
623
+ findings,
624
+ workers,
625
+ candidates: [],
626
+ policy: null,
627
+ progress: {
628
+ ...source.progress,
629
+ blockers: source.progress.blockers.map((blocker) => ({
630
+ ...blocker,
631
+ refs: blocker.refs.map((ref) => mapRef(ref, ids)),
632
+ })),
633
+ },
634
+ };
635
+ task.decisions = task.decisions.map((entry) => ({
636
+ ...entry,
637
+ data: {
638
+ ...entry.data,
639
+ binding: { ...entry.data.binding, taskId: task.id },
640
+ digest: decisionDigest(entry.data),
641
+ },
642
+ }));
643
+ return task;
644
+ };
645
+
646
+ export class WorkitCore {
647
+ private readonly authorityOwner = {};
648
+
649
+ constructor(
650
+ private readonly store: TaskStore,
651
+ private readonly context: OperationContext,
652
+ ) {}
653
+
654
+ /** Optional host actions are coordinator-owned; supervised worker contexts cannot invoke them. */
655
+ public isDelegatedCaller(): boolean {
656
+ return this.context.workerId !== null && this.context.workerId !== undefined;
657
+ }
658
+
659
+ private contextRootError(): Result<null> {
660
+ return contextRootError(this.store, this.context);
661
+ }
662
+
663
+ /**
664
+ * Omitted revisions default to the records read for this call; explicit values
665
+ * stay CAS-enforced downstream. Call after loading task/workspace records.
666
+ */
667
+ private fillRevisions(
668
+ input: { expectedRevision?: string; expectedWorkspaceRevision?: string | null },
669
+ task?: { revision: string },
670
+ workspace?: { revision: string } | null,
671
+ ): void {
672
+ if (input.expectedRevision === undefined && task) input.expectedRevision = task.revision;
673
+ if (input.expectedWorkspaceRevision === undefined)
674
+ input.expectedWorkspaceRevision = workspace ? workspace.revision : null;
675
+ }
676
+
677
+ /**
678
+ * A revision the caller omitted is not a compare-and-swap request: when
679
+ * another writer commits between this call's read and its locked commit,
680
+ * re-run the whole operation — fresh read, policy, requirement and
681
+ * candidate checks, then commit — so a re-check that now fails returns that
682
+ * failure. Caller-supplied revisions stay strict CAS.
683
+ */
684
+ private retryOmittedRevisions<T>(request: unknown, run: () => Result<T>): Result<T> {
685
+ return retryFilledRevisions(filledRevisions(request), run);
686
+ }
687
+
688
+ /**
689
+ * A task-scoped request without a `taskId` applies to the implicit task of
690
+ * this checkout's branch (D3). Recording actions create that task on first
691
+ * use; reads and lifecycle actions only look it up. Helpers never create.
692
+ */
693
+ private implicitTask(family: OperationFamily, raw: unknown): Result<unknown> {
694
+ // Flat tool inputs become the canonical request first (S17).
695
+ const request = normalizeOperationInput(family, raw, this.context.caller.host);
696
+ if (typeof request !== "object" || request === null || Array.isArray(request))
697
+ return success(null, null, request);
698
+ const value = request as { action?: unknown; taskId?: unknown };
699
+ if (value.taskId !== undefined || typeof value.action !== "string")
700
+ return success(null, null, request);
701
+ const action = `${family}.${value.action}`;
702
+ if (!TASK_SCOPED_ACTIONS.has(action)) return success(null, null, request);
703
+ const root = this.contextRootError();
704
+ if (!root.ok) return root;
705
+ // A worker belongs to the task it was assigned on, whatever branch is
706
+ // checked out now (#264): worker.cancel/report follow the named worker,
707
+ // a helper's own calls follow its worker id.
708
+ const addressed = WORKER_ADDRESSED_ACTIONS.has(action);
709
+ // A missing or malformed id is invalid_input (from taskForWorker).
710
+ const workerId: unknown = addressed
711
+ ? (value as { workerId?: unknown }).workerId
712
+ : (this.context.workerId ?? null);
713
+ if (workerId !== null) {
714
+ const owner = this.store.taskForWorker(workerId);
715
+ if (!owner.ok) return owner;
716
+ if (owner.data) return success(null, null, { ...request, taskId: owner.data });
717
+ if (addressed)
718
+ return failure(
719
+ "not_found",
720
+ `worker ${workerId as string} is on no open task; find its task with \`workit task status --all\`, then run \`${workerCommand("<task-id>", request)}\``,
721
+ );
722
+ }
723
+ const create = CREATING_ACTIONS.has(action) && (this.context.workerId ?? null) === null;
724
+ const found = this.store.implicitTask({
725
+ provenance: provenance(this.context),
726
+ create,
727
+ now: trustedNow(this.context),
728
+ });
729
+ if (!found.ok) return found;
730
+ if (!found.data)
731
+ return failure(
732
+ "not_found",
733
+ 'no task for this branch yet; pass taskId, or record something first (`workit task start "<objective>"`, a note, a check) to create it',
734
+ );
735
+ return success(null, null, { ...request, taskId: found.data.task.id });
736
+ }
737
+
738
+ private helperEntry(
739
+ task: TaskRecord,
740
+ requireSession = false,
741
+ requireActive = false,
742
+ ): Result<Entry<Worker>> {
743
+ return helperEntry(task, this.context, requireSession, requireActive);
744
+ }
745
+
746
+ private helperTaskGuard(task: TaskRecord, allowRead = false): Result<null> {
747
+ return helperTaskGuard(task, this.context, allowRead);
748
+ }
749
+
750
+ private activeWorkerBlocker(task: TaskRecord) {
751
+ if (task.workers.some((entry) => workerBlocksTransition("close", entry.data.state)))
752
+ return failure("recovery_required", "worker state requires reconciliation");
753
+ return null;
754
+ }
755
+
756
+ state(request: unknown): Result<ExportBundle | TaskSummary | TaskRecord | WorkspaceRecord> {
757
+ const resolved = this.implicitTask("state", request);
758
+ if (!resolved.ok) return resolved;
759
+ const value = resolved.data;
760
+ return (value as { action?: unknown } | null)?.action === "import"
761
+ ? this.retryOmittedRevisions(value, () => this.stateOnce(value))
762
+ : this.stateOnce(value);
763
+ }
764
+
765
+ private stateOnce(
766
+ request: unknown,
767
+ ): Result<ExportBundle | TaskSummary | TaskRecord | WorkspaceRecord> {
768
+ const root = this.contextRootError();
769
+ if (!root.ok) return root;
770
+ if ((this.context.workerId ?? null) !== null)
771
+ return failure("permission_denied", "helpers cannot control task state");
772
+ const parsed = parseOperation("state", request);
773
+ if (!parsed.ok) return parsed;
774
+ const input = parsed.data;
775
+ if (input.action === "export") {
776
+ const task = this.store.readTask(input.taskId);
777
+ if (!task.ok) return task;
778
+ const workspace = this.store.readWorkspace();
779
+ if (!workspace.ok) return workspace;
780
+ if (!workspace.data) return failure("not_found", "workspace not found");
781
+ const portable = taskRecordSchema.safeParse(portableTask(task.data));
782
+ if (!portable.success)
783
+ return failure("storage_error", "portable task is invalid after sanitization");
784
+ const bundle = {
785
+ schemaVersion: 1 as const,
786
+ exportedAt: trustedNow(this.context),
787
+ sourceWorkspaceId: workspace.data.id,
788
+ task: portable.data,
789
+ };
790
+ const checked = exportBundleSchema.safeParse({
791
+ ...bundle,
792
+ digest: exportDigest(bundle),
793
+ });
794
+ if (!checked.success) return failure("storage_error", "portable export bundle is invalid");
795
+ return success(task.data.revision, workspace.data.revision, checked.data);
796
+ }
797
+ if (input.action === "import") {
798
+ const checked = exportBundleSchema.safeParse(input.bundle);
799
+ if (!checked.success) return failure("invalid_input", "export bundle is invalid");
800
+ const bundle = checked.data;
801
+ if (
802
+ bundle.sourceWorkspaceId !== bundle.task.workspaceId ||
803
+ exportDigest({
804
+ schemaVersion: bundle.schemaVersion,
805
+ exportedAt: bundle.exportedAt,
806
+ sourceWorkspaceId: bundle.sourceWorkspaceId,
807
+ task: bundle.task,
808
+ }) !== bundle.digest
809
+ )
810
+ return failure("invalid_input", "export bundle digest is invalid");
811
+ const workspace = this.store.readWorkspace();
812
+ if (!workspace.ok) return workspace;
813
+ const destinationWorkspaceId = workspace.data?.id ?? newId();
814
+ this.fillRevisions(input, undefined, workspace.data ?? null);
815
+ const timestamp = trustedNow(this.context);
816
+ const task = importedTask(
817
+ bundle.task,
818
+ bundle,
819
+ destinationWorkspaceId,
820
+ this.context,
821
+ timestamp,
822
+ );
823
+ const imported = this.store.importTask({
824
+ task,
825
+ expectedWorkspaceRevision: input.expectedWorkspaceRevision,
826
+ workspaceId: destinationWorkspaceId,
827
+ now: timestamp,
828
+ });
829
+ if (!imported.ok) return imported;
830
+ return this.summary(imported.data);
831
+ }
832
+ return failure("invalid_input", "unknown state action");
833
+ }
834
+
835
+ task(request: unknown): Result<TaskSummary | TaskBrief | TaskListItem[] | TaskView> {
836
+ const resolved = this.implicitTask("task", request);
837
+ if (!resolved.ok) return resolved;
838
+ const value = resolved.data;
839
+ return this.retryOmittedRevisions(value, () => this.taskOnce(value));
840
+ }
841
+
842
+ private taskOnce(request: unknown): Result<TaskSummary | TaskBrief | TaskListItem[] | TaskView> {
843
+ const root = this.contextRootError();
844
+ if (!root.ok) return root;
845
+ const parsed = parseOperation("task", request);
846
+ if (!parsed.ok) return parsed;
847
+ const input = parsed.data;
848
+ if (this.context.readOnlyChild) {
849
+ if (input.action !== "inspect")
850
+ return failure("permission_denied", "child sessions can only inspect the task summary");
851
+ const task = this.store.readTask(input.taskId);
852
+ if (!task.ok) return task;
853
+ const summary = this.summary(task.data);
854
+ return summary.ok ? { ...summary, data: boundTaskSummary(summary.data) } : summary;
855
+ }
856
+ if ((this.context.workerId ?? null) !== null) {
857
+ if (input.action !== "inspect" && input.action !== "list")
858
+ return failure("permission_denied", "helpers cannot control task lifecycle or scope");
859
+ }
860
+ switch (input.action) {
861
+ case "start": {
862
+ if (input.expectedWorkspaceRevision === undefined) {
863
+ const workspace = this.store.readWorkspace();
864
+ if (!workspace.ok) return workspace;
865
+ input.expectedWorkspaceRevision = workspace.data?.revision ?? null;
866
+ }
867
+ const created = this.store.create({
868
+ intent: input.intent,
869
+ provenance: provenance(this.context),
870
+ expectedWorkspaceRevision: input.expectedWorkspaceRevision,
871
+ now: trustedNow(this.context),
872
+ });
873
+ if (!created.ok) return created;
874
+ return this.summary(created.data);
875
+ }
876
+ case "list": {
877
+ const listed = this.store.listTasks();
878
+ if (!listed.ok) return listed;
879
+ const status = input.status ?? "open";
880
+ const limit = input.limit ?? 20;
881
+ const query = input.query?.toLowerCase().split(/\s+/);
882
+ const selected = listed.data
883
+ .filter((task) =>
884
+ status === "all"
885
+ ? true
886
+ : status === "closed"
887
+ ? task.status === "closed"
888
+ : task.status !== "closed",
889
+ )
890
+ .filter(
891
+ (task) =>
892
+ !query ||
893
+ query.every((term: string) =>
894
+ JSON.stringify({
895
+ id: task.id,
896
+ workspaceId: task.workspaceId,
897
+ origin: task.origin,
898
+ createdAt: task.createdAt,
899
+ updatedAt: task.updatedAt,
900
+ source: task.intent.provenance,
901
+ objective: task.intent.data.objective,
902
+ progress: task.progress,
903
+ decisions: task.decisions.map(({ data }) => data),
904
+ })
905
+ .toLowerCase()
906
+ .includes(term),
907
+ ),
908
+ )
909
+ .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))
910
+ .slice(0, limit);
911
+ if (selected.length === 0) return success(null, null, []);
912
+ const workspace = this.store.readWorkspace();
913
+ if (!workspace.ok) return workspace;
914
+ if (!workspace.data) return failure("not_found", "workspace not found");
915
+ const tasks = selected.map((task): TaskListItem => ({
916
+ id: task.id,
917
+ revision: task.revision,
918
+ workspaceRevision: workspace.data!.revision,
919
+ createdAt: task.createdAt,
920
+ updatedAt: task.updatedAt,
921
+ runtime: task.runtime ?? null,
922
+ objective: task.intent.data.objective,
923
+ status: task.status,
924
+ closure: task.closure,
925
+ progress: task.progress,
926
+ source: {
927
+ host: task.intent.provenance.host,
928
+ kind: task.intent.provenance.kind,
929
+ },
930
+ }));
931
+ return success(null, null, tasks);
932
+ }
933
+ case "inspect": {
934
+ const task = this.store.readTask(input.taskId);
935
+ if (!task.ok) return task;
936
+ const helper = this.helperTaskGuard(task.data, true);
937
+ if (!helper.ok) return helper;
938
+ if ((input.view ?? "summary") === "summary") return this.summary(task.data);
939
+ return this.view(task.data);
940
+ }
941
+ case "pause":
942
+ return this.transition(input, "paused");
943
+ case "resume":
944
+ return this.transition(input, "active");
945
+ case "progress":
946
+ return this.progress(input);
947
+ case "revise":
948
+ return this.revise(input);
949
+ case "close":
950
+ return this.close(input);
951
+ default:
952
+ return failure("invalid_transition", `task action ${input.action} is not implemented`);
953
+ }
954
+ }
955
+
956
+ policy(request: unknown): Result<Policy | null> {
957
+ const resolved = this.implicitTask("policy", request);
958
+ if (!resolved.ok) return resolved;
959
+ const value = resolved.data;
960
+ return this.retryOmittedRevisions(value, () => this.policyOnce(value));
961
+ }
962
+
963
+ private policyOnce(request: unknown): Result<Policy | null> {
964
+ const root = this.contextRootError();
965
+ if (!root.ok) return root;
966
+ const parsed = parseOperation("policy", request);
967
+ if (!parsed.ok) return parsed;
968
+ const input = parsed.data;
969
+ const task = this.store.readTask(input.taskId);
970
+ if (!task.ok) return task;
971
+ if (input.action === "explain") return success(task.data.revision, null, task.data.policy);
972
+ if ((this.context.workerId ?? null) !== null)
973
+ return failure("permission_denied", "helpers cannot change task requirements");
974
+ this.fillRevisions(input, task.data);
975
+ const previous = latestJudgment(task.data);
976
+ const judged = normalizeJudgment(input.judgment, previous);
977
+ if (!judged.ok) return judged;
978
+ const judgment = judged.data.judgment;
979
+ const resolved = this.resolve(task.data, judgment);
980
+ if (!resolved.ok) return resolved;
981
+ if (input.action === "preview") return success(null, null, resolved.data);
982
+ if (input.action !== "assess")
983
+ return failure("invalid_transition", "unsupported policy action");
984
+ // M6: a session lifting a before-write blocker it judged itself gives a
985
+ // reason; every assess is recorded in the ledger below either way.
986
+ const lifted = liftedBlockers(previous, judgment);
987
+ const why = givenReason(input.judgment);
988
+ const own = ownLiftedBlockers(task.data.judgments ?? [], lifted, this.context.caller.actor);
989
+ if (own.length && !why)
990
+ return failure(
991
+ "invalid_input",
992
+ `this session judged ${own.join(" and ")} true on this task; lifting it needs a reason: re-run with --why "<reason>" (recorded in the ledger)`,
993
+ );
994
+ // The audit row is sized before anything changes, so a blocker is never
995
+ // lifted with a reason too long to record.
996
+ const audit = this.judgedRow(
997
+ task.data.id,
998
+ input.judgment,
999
+ judgment,
1000
+ resolved.data,
1001
+ lifted,
1002
+ why,
1003
+ );
1004
+ const bytes = observedRowBytes(audit);
1005
+ if (bytes > MAX_LINE_BYTES)
1006
+ return failure(
1007
+ "invalid_input",
1008
+ `the judgment is too long to record in the ledger (${bytes} bytes; the limit is ${MAX_LINE_BYTES}): shorten --why and pass details by --ref <path>`,
1009
+ );
1010
+ const changed = this.store.mutateTask(
1011
+ task.data.id,
1012
+ input.expectedRevision,
1013
+ (current, mutation) => {
1014
+ if (current.status === "closed")
1015
+ return failure("invalid_transition", "closed task cannot be assessed");
1016
+ const nextChange = diffPolicy(
1017
+ current.policy,
1018
+ resolved.data,
1019
+ "policy reassessed",
1020
+ mutation.now,
1021
+ );
1022
+ const entry = {
1023
+ id: newId(),
1024
+ recordedAt: mutation.now,
1025
+ provenance: provenance(this.context),
1026
+ data: judgment,
1027
+ };
1028
+ const next = {
1029
+ ...current,
1030
+ constraints: this.context.constraints,
1031
+ judgments: [...(current.judgments ?? []), entry],
1032
+ // Older readers would drop judgments on rewrite: they fail closed instead.
1033
+ critical: [...new Set([...(current.critical ?? []), "judgments"])],
1034
+ policy: resolved.data,
1035
+ policyChanges: nextChange
1036
+ ? [...current.policyChanges, nextChange]
1037
+ : current.policyChanges,
1038
+ };
1039
+ return success(mutation.revision, null, next);
1040
+ },
1041
+ trustedNow(this.context),
1042
+ );
1043
+ if (!changed.ok) return changed;
1044
+ // Best effort: the assessment already stands in the task store.
1045
+ appendObserved(this.store.root, audit);
1046
+ return success(changed.data.revision, null, changed.data.policy);
1047
+ }
1048
+
1049
+ /**
1050
+ * The ledger row that makes a judge call auditable (M6): who judged, what
1051
+ * was sent (the reason once, in `why`), what it resolved to, the
1052
+ * requirements and the blockers it lifted. Refs are capped to keep the row
1053
+ * within one ledger line.
1054
+ */
1055
+ private judgedRow(
1056
+ taskId: string,
1057
+ input: unknown,
1058
+ judgment: Judgment,
1059
+ policy: Policy | null,
1060
+ lifted: readonly string[],
1061
+ why: string | null,
1062
+ ) {
1063
+ const root = this.store.root;
1064
+ const branch = currentBranch(root);
1065
+ const { note: _note, refs, ...calls } = judgment;
1066
+ return {
1067
+ type: "policy.judged",
1068
+ actor: {
1069
+ host: this.context.caller.host,
1070
+ session: this.context.caller.actor,
1071
+ agentId: this.context.workerId ?? null,
1072
+ },
1073
+ branch,
1074
+ head: branch ? headSha(root) : null,
1075
+ taskId,
1076
+ input: judgeInputSummary(input),
1077
+ judgment: { ...calls, refs: refs.slice(0, MAX_JUDGED_REFS), refCount: refs.length },
1078
+ requirements: (policy?.requirements ?? []).map((requirement) => requirement.ruleId),
1079
+ lifted,
1080
+ why,
1081
+ };
1082
+ }
1083
+
1084
+ evidence(request: unknown): Result<Entry<Evidence>> {
1085
+ const resolved = this.implicitTask("evidence", request);
1086
+ if (!resolved.ok) return resolved;
1087
+ const value = resolved.data;
1088
+ return this.retryOmittedRevisions(value, () => this.evidenceOnce(value));
1089
+ }
1090
+
1091
+ private evidenceOnce(request: unknown): Result<Entry<Evidence>> {
1092
+ const root = this.contextRootError();
1093
+ if (!root.ok) return root;
1094
+ const parsed = parseOperation("evidence", request);
1095
+ if (!parsed.ok) return parsed;
1096
+ const input = parsed.data;
1097
+ const task = this.store.readTask(input.taskId);
1098
+ if (!task.ok) return task;
1099
+ if (task.data.status === "closed")
1100
+ return failure("invalid_transition", "closed task cannot record evidence");
1101
+ this.fillRevisions(input, task.data);
1102
+ const helper =
1103
+ (this.context.workerId ?? null) === null ? null : this.helperEntry(task.data, true, true);
1104
+ if (helper && !helper.ok) return helper;
1105
+ const evidence = input.evidence as Evidence;
1106
+ if (
1107
+ (evidence.result === "missing" || evidence.result === "skipped") &&
1108
+ !evidence.summary.trim()
1109
+ )
1110
+ return failure("invalid_input", "missing or skipped evidence requires a reason");
1111
+ if (evidence.kind === "review") {
1112
+ if (!sameSession(evidence.reviewContext, this.context))
1113
+ return failure("invalid_input", "review context must match the trusted caller session");
1114
+ }
1115
+ const currentCandidate = captureCandidate(
1116
+ this.store.root,
1117
+ task.data.intent.data.scope,
1118
+ environment(),
1119
+ );
1120
+ if (!currentCandidate.ok) return currentCandidate;
1121
+ // Resolve the binding first (explicit IDs, else the worker pin, else the
1122
+ // current candidate), then validate and pin-check the resolved values —
1123
+ // never the caller's possibly-empty fields. The input itself is untouched.
1124
+ const pin = helper && helper.ok ? helper.data.data.assignment.candidateId : null;
1125
+ const bound = resolveBinding(evidence, pin, currentCandidate.data.id);
1126
+ if (helper?.ok) {
1127
+ const assignment = helper.data.data.assignment;
1128
+ if (
1129
+ evidence.requirementIds.some((id: string) => !assignment.requirementIds.includes(id)) ||
1130
+ !checkPin(assignment, bound.beforeCandidateId, bound.candidateId) ||
1131
+ !refsWithinScope(evidence.refs, assignment.scope) ||
1132
+ !refsWithinScope(evidence.refs, task.data.intent.data.scope)
1133
+ )
1134
+ return failure("permission_denied", "evidence is outside the worker assignment");
1135
+ }
1136
+ const knownCandidates = new Set(task.data.candidates.map((candidate) => candidate.id));
1137
+ for (const candidateId of [bound.beforeCandidateId, bound.candidateId]) {
1138
+ if (
1139
+ candidateId &&
1140
+ candidateId !== currentCandidate.data.id &&
1141
+ !knownCandidates.has(candidateId)
1142
+ )
1143
+ return failure("invalid_input", "evidence candidate is not a captured candidate");
1144
+ }
1145
+ const boundEvidence = { ...evidence, ...bound };
1146
+ const changed = this.store.mutateTask(
1147
+ task.data.id,
1148
+ input.expectedRevision,
1149
+ (current, mutation) => {
1150
+ const candidates = current.candidates.some((item) => item.id === currentCandidate.data.id)
1151
+ ? current.candidates
1152
+ : [...current.candidates, currentCandidate.data];
1153
+ const entry = {
1154
+ id: newId(),
1155
+ recordedAt: mutation.now,
1156
+ provenance: provenance(this.context, "agent_reported"),
1157
+ data: boundEvidence,
1158
+ };
1159
+ return success(mutation.revision, null, {
1160
+ ...current,
1161
+ candidates,
1162
+ evidence: [...current.evidence, entry],
1163
+ findings: current.findings.map((finding) => {
1164
+ if (finding.data.disposition === "open" || boundEvidence.result === "skipped")
1165
+ return finding;
1166
+ if (finding.data.disposition !== "fixed") return finding;
1167
+ // A fix stands while its verification still passes on the current
1168
+ // tree; it reopens only on lapse or direct contradiction. Judged
1169
+ // dispositions never auto-reopen.
1170
+ const withEntry = { ...current, candidates, evidence: [...current.evidence, entry] };
1171
+ const statuses = new Map(
1172
+ evaluateEvidence(
1173
+ withEntry,
1174
+ currentCandidate.data,
1175
+ treeFreshness(this.store.root),
1176
+ ).map((item) => [item.evidenceId, item.status]),
1177
+ );
1178
+ const verified = withEntry.evidence.some((item) =>
1179
+ findingVerificationPasses(finding.data.candidateId, {
1180
+ kind: item.data.kind,
1181
+ candidateId: item.data.candidateId,
1182
+ status: statuses.get(item.id) ?? "missing",
1183
+ }),
1184
+ );
1185
+ const contradicted =
1186
+ boundEvidence.result === "failed" &&
1187
+ finding.data.refs.some((left: Ref) =>
1188
+ boundEvidence.refs.some((right: Ref) => sameValue(left, right)),
1189
+ );
1190
+ return verified && !contradicted
1191
+ ? finding
1192
+ : { ...finding, data: { ...finding.data, disposition: "open", resolution: null } };
1193
+ }),
1194
+ });
1195
+ },
1196
+ trustedNow(this.context),
1197
+ );
1198
+ if (!changed.ok) return changed;
1199
+ return success(changed.data.revision, null, changed.data.evidence.at(-1)!);
1200
+ }
1201
+
1202
+ /**
1203
+ * Record a check the CLI itself ran and observed (`workit check`, design
1204
+ * §2.1 S9). Host-only, like observeDecision: the caller must be the workit
1205
+ * CLI (never a helper), and agents reach evidence only through
1206
+ * `evidence.record`, which stays agent_reported and cannot carry an
1207
+ * observation. The entry is `host_observed` by `workit_cli`, keyed to the
1208
+ * worktree tree in the observation, and the task lists the observation
1209
+ * path as critical so an older reader fails closed instead of dropping it.
1210
+ * Outside git (no tree key) it is bound to the current candidate instead.
1211
+ */
1212
+ observeCheck(request: ObserveCheckRequest): Result<Entry<Evidence>> {
1213
+ return this.retryOmittedRevisions(request, () => this.observeCheckOnce(request));
1214
+ }
1215
+
1216
+ private observeCheckOnce(request: ObserveCheckRequest): Result<Entry<Evidence>> {
1217
+ const root = this.contextRootError();
1218
+ if (!root.ok) return root;
1219
+ if (this.context.caller.host !== "workit_cli" || (this.context.workerId ?? null) !== null)
1220
+ return failure("permission_denied", "only the workit CLI records observed checks");
1221
+ const observation = checkObservationSchema.safeParse(request?.observation);
1222
+ if (!observation.success)
1223
+ return failure("invalid_input", "check observation is invalid", {
1224
+ fields: observation.error.issues.map((issue) => ({
1225
+ path: issue.path.join("."),
1226
+ reason: issue.message,
1227
+ })),
1228
+ });
1229
+ const task = this.store.readTask(request.taskId);
1230
+ if (!task.ok) return task;
1231
+ if (task.data.status === "closed")
1232
+ return failure("invalid_transition", "closed task cannot record evidence");
1233
+ const observed: CheckObservation = observation.data;
1234
+ let candidate: Candidate | null = null;
1235
+ if (observed.tree === null) {
1236
+ const captured = captureCandidate(
1237
+ this.store.root,
1238
+ task.data.intent.data.scope,
1239
+ environment(),
1240
+ );
1241
+ if (!captured.ok) return captured;
1242
+ candidate = captured.data;
1243
+ }
1244
+ const label = observed.name ?? observed.argv.join(" ");
1245
+ const data: Evidence = {
1246
+ kind: "check",
1247
+ claim: `workit check ${label}`.slice(0, 500),
1248
+ requirementIds: [],
1249
+ ...(candidate ? { beforeCandidateId: candidate.id, candidateId: candidate.id } : {}),
1250
+ result: observed.exitCode === 0 ? "passed" : "failed",
1251
+ summary: `${observed.configured ? "configured" : "ad-hoc"} check exited ${observed.exitCode}${observed.timedOut ? " (timed out)" : ""} in ${observed.durationMs} ms`,
1252
+ refs: [],
1253
+ exitCode: observed.exitCode,
1254
+ reviewContext: null,
1255
+ observation: observed,
1256
+ };
1257
+ const changed = this.store.mutateTask(
1258
+ task.data.id,
1259
+ request.expectedRevision ?? task.data.revision,
1260
+ (current, mutation) => {
1261
+ const candidates =
1262
+ candidate === null || current.candidates.some((item) => item.id === candidate.id)
1263
+ ? current.candidates
1264
+ : [...current.candidates, candidate];
1265
+ const critical = [...new Set([...(current.critical ?? []), CHECK_OBSERVATION_PATH])];
1266
+ return success(mutation.revision, null, {
1267
+ ...current,
1268
+ critical,
1269
+ candidates,
1270
+ evidence: [
1271
+ ...current.evidence,
1272
+ {
1273
+ id: newId(),
1274
+ recordedAt: mutation.now,
1275
+ provenance: provenance(this.context, "host_observed"),
1276
+ data,
1277
+ },
1278
+ ],
1279
+ });
1280
+ },
1281
+ trustedNow(this.context),
1282
+ );
1283
+ if (!changed.ok) return changed;
1284
+ return success(changed.data.revision, null, changed.data.evidence.at(-1)!);
1285
+ }
1286
+
1287
+ decision(request: unknown): Result<Entry<Decision>> {
1288
+ const resolved = this.implicitTask("decision", request);
1289
+ return resolved.ok ? this.recordDecision(resolved.data) : resolved;
1290
+ }
1291
+
1292
+ /**
1293
+ * Commit a task mutation whose update re-validates everything it depends on
1294
+ * under the lock: a CAS loss on an engine-filled revision re-reads the
1295
+ * record, repeats `recheck` against it and retries the commit.
1296
+ */
1297
+ private commitTask(
1298
+ taskId: string,
1299
+ expected: string,
1300
+ filled: boolean,
1301
+ recheck: (fresh: TaskRecord) => Result<unknown>,
1302
+ update: Parameters<TaskStore["mutateTask"]>[2],
1303
+ ): Result<TaskRecord> {
1304
+ let revision = expected;
1305
+ let first = true;
1306
+ return retryFilledRevisions({ task: filled, workspace: false }, () => {
1307
+ if (!first) {
1308
+ const fresh = this.store.readTask(taskId);
1309
+ if (!fresh.ok) return fresh;
1310
+ const checked = recheck(fresh.data);
1311
+ if (!checked.ok) return checked;
1312
+ revision = fresh.data.revision;
1313
+ }
1314
+ first = false;
1315
+ return this.store.mutateTask(taskId, revision, update, trustedNow(this.context));
1316
+ });
1317
+ }
1318
+
1319
+ /**
1320
+ * A decision is a durable, agent-asserted record (D2, D18). It satisfies
1321
+ * `decisions` requirements and accepted limitations; it never authorizes an
1322
+ * external effect, so no host attestation is minted or checked.
1323
+ */
1324
+ private recordDecision(request: unknown): Result<Entry<Decision>> {
1325
+ const root = this.contextRootError();
1326
+ if (!root.ok) return root;
1327
+ const parsed = parseOperation("decision", request);
1328
+ if (!parsed.ok) return parsed;
1329
+ const input = parsed.data;
1330
+ const stated = input.action === "record" && input.response === "stated";
1331
+ if (stated && !input.binding?.statedChoice)
1332
+ return failure("invalid_input", "stated choices require binding.statedChoice");
1333
+ const task = this.store.readTask(input.taskId);
1334
+ if (!task.ok) return task;
1335
+ if ((this.context.workerId ?? null) !== null)
1336
+ return failure("permission_denied", "helpers cannot record or revoke decisions");
1337
+ const filled = input.expectedRevision === undefined;
1338
+ this.fillRevisions(input, task.data);
1339
+ if (input.action === "record") {
1340
+ if (task.data.status === "closed")
1341
+ return failure("invalid_transition", "closed task cannot record a decision");
1342
+ const workspace = this.store.readWorkspace();
1343
+ if (!workspace.ok) return workspace;
1344
+ if (!workspace.data) return failure("not_found", "workspace not found");
1345
+ // Flat inputs omit the binding ids: a decision binds to where it is recorded.
1346
+ const binding = {
1347
+ ...input.binding,
1348
+ taskId: input.binding.taskId ?? task.data.id,
1349
+ workspaceId: input.binding.workspaceId ?? workspace.data.id,
1350
+ } as Decision["binding"];
1351
+ if (binding.taskId !== task.data.id || binding.workspaceId !== workspace.data.id)
1352
+ return failure("invalid_input", "decision task or workspace binding is invalid");
1353
+ const content = verifyDecisionContentAtRoot(this.store.root, binding);
1354
+ if (!content.ok) return content;
1355
+ const knownRequirements = new Set(
1356
+ task.data.policy?.requirements.map((item) => item.id) ?? [],
1357
+ );
1358
+ if (input.requirementIds.some((id: string) => !knownRequirements.has(id)))
1359
+ return failure("invalid_input", "decision references an unknown requirement");
1360
+ const base = {
1361
+ purpose: input.purpose,
1362
+ binding,
1363
+ response: input.response,
1364
+ requirementIds: input.requirementIds,
1365
+ revoked: null,
1366
+ } satisfies Omit<Decision, "digest">;
1367
+ const data: Decision = { ...base, digest: decisionDigest(base) };
1368
+ const changed = this.commitTask(
1369
+ task.data.id,
1370
+ input.expectedRevision,
1371
+ filled,
1372
+ (fresh) =>
1373
+ fresh.status === "closed"
1374
+ ? failure("invalid_transition", "closed task cannot record a decision")
1375
+ : success(null, null, null),
1376
+ (current, mutation) => {
1377
+ if (current.status === "closed")
1378
+ return failure("invalid_transition", "closed task cannot record a decision");
1379
+ const known = new Set(current.policy?.requirements.map((item) => item.id) ?? []);
1380
+ if (input.requirementIds.some((id: string) => !known.has(id)))
1381
+ return failure("invalid_input", "decision references an unknown requirement");
1382
+ const entry: Entry<Decision> = {
1383
+ id: newId(),
1384
+ recordedAt: mutation.now,
1385
+ provenance: provenance(this.context, "agent_reported"),
1386
+ data,
1387
+ };
1388
+ return success(mutation.revision, null, {
1389
+ ...current,
1390
+ decisions: [...current.decisions, entry],
1391
+ });
1392
+ },
1393
+ );
1394
+ if (!changed.ok) return changed;
1395
+ return success(changed.data.revision, null, changed.data.decisions.at(-1)!);
1396
+ }
1397
+ const decision = task.data.decisions.find((entry) => entry.id === input.decisionId);
1398
+ if (!decision) return failure("not_found", "decision not found");
1399
+ if (!input.reason.trim())
1400
+ return failure("invalid_input", "decision revocation requires a reason");
1401
+ if (decision.data.revoked) return failure("invalid_transition", "decision is already revoked");
1402
+ const changed = this.commitTask(
1403
+ task.data.id,
1404
+ input.expectedRevision,
1405
+ filled,
1406
+ () => success(null, null, null),
1407
+ (current, mutation) => {
1408
+ const entry = current.decisions.find((candidate) => candidate.id === input.decisionId);
1409
+ if (!entry) return failure("not_found", "decision not found");
1410
+ if (entry.data.revoked) return failure("invalid_transition", "decision is already revoked");
1411
+ const data = {
1412
+ ...entry.data,
1413
+ revoked: { at: mutation.now, reason: input.reason },
1414
+ };
1415
+ const updated = { ...entry, data };
1416
+ return success(mutation.revision, null, {
1417
+ ...current,
1418
+ decisions: current.decisions.map((candidate) =>
1419
+ candidate.id === input.decisionId ? updated : candidate,
1420
+ ),
1421
+ });
1422
+ },
1423
+ );
1424
+ if (!changed.ok) return changed;
1425
+ const entry = changed.data.decisions.find((candidate) => candidate.id === input.decisionId);
1426
+ return entry
1427
+ ? success(changed.data.revision, null, entry)
1428
+ : failure("recovery_required", "decision disappeared");
1429
+ }
1430
+
1431
+ finding(request: unknown): Result<Entry<Finding>> {
1432
+ const resolved = this.implicitTask("finding", request);
1433
+ if (!resolved.ok) return resolved;
1434
+ const value = resolved.data;
1435
+ return this.retryOmittedRevisions(value, () => this.findingOnce(value));
1436
+ }
1437
+
1438
+ private findingOnce(request: unknown): Result<Entry<Finding>> {
1439
+ const root = this.contextRootError();
1440
+ if (!root.ok) return root;
1441
+ const parsed = parseOperation("finding", request);
1442
+ if (!parsed.ok) return parsed;
1443
+ const input = parsed.data;
1444
+ const task = this.store.readTask(input.taskId);
1445
+ if (!task.ok) return task;
1446
+ if (task.data.status === "closed")
1447
+ return failure("invalid_transition", "closed task cannot mutate findings");
1448
+ this.fillRevisions(input, task.data);
1449
+ const helper =
1450
+ (this.context.workerId ?? null) === null ? null : this.helperEntry(task.data, true, true);
1451
+ if (helper && !helper.ok) return helper;
1452
+ if (input.action === "record") {
1453
+ if (helper?.ok) {
1454
+ const assignment = helper.data.data.assignment;
1455
+ if (
1456
+ !bindingCovers(assignment.scope, input.scope) ||
1457
+ !bindingCovers(task.data.intent.data.scope, input.scope) ||
1458
+ !checkPin(assignment, null, input.candidateId ?? null) ||
1459
+ !refsWithinScope(input.refs, assignment.scope) ||
1460
+ !refsWithinScope(input.refs, task.data.intent.data.scope)
1461
+ )
1462
+ return failure("permission_denied", "finding is outside the worker assignment");
1463
+ }
1464
+ if (
1465
+ input.candidateId &&
1466
+ !task.data.candidates.some((candidate) => candidate.id === input.candidateId)
1467
+ )
1468
+ return failure("invalid_input", "finding candidate is not a captured candidate");
1469
+ const changed = this.store.mutateTask(
1470
+ task.data.id,
1471
+ input.expectedRevision,
1472
+ (current, mutation) => {
1473
+ const entry: Entry<Finding> = {
1474
+ id: newId(),
1475
+ recordedAt: mutation.now,
1476
+ provenance: provenance(this.context, "agent_reported"),
1477
+ data: {
1478
+ claim: input.claim,
1479
+ consequence: input.consequence,
1480
+ scope: input.scope,
1481
+ candidateId: input.candidateId ?? null,
1482
+ refs: input.refs,
1483
+ disposition: "open",
1484
+ resolution: null,
1485
+ },
1486
+ };
1487
+ return success(mutation.revision, null, {
1488
+ ...current,
1489
+ findings: [...current.findings, entry],
1490
+ });
1491
+ },
1492
+ trustedNow(this.context),
1493
+ );
1494
+ if (!changed.ok) return changed;
1495
+ return success(changed.data.revision, null, changed.data.findings.at(-1)!);
1496
+ }
1497
+ if (helper?.ok) return failure("permission_denied", "helpers cannot resolve findings");
1498
+ const findingRef = resolveIdRef(
1499
+ "finding",
1500
+ input.findingId,
1501
+ task.data.findings.map((entry) => entry.id),
1502
+ );
1503
+ if (!findingRef.ok) return findingRef;
1504
+ input.findingId = findingRef.data;
1505
+ const finding = task.data.findings.find((entry) => entry.id === input.findingId);
1506
+ if (!finding) return failure("not_found", "finding not found");
1507
+ if (!input.reason.trim())
1508
+ return failure("invalid_input", "finding resolution requires a reason");
1509
+ const evidence = task.data.evidence.filter((entry) => input.evidenceIds.includes(entry.id));
1510
+ const decisions = task.data.decisions.filter((entry) => input.decisionIds.includes(entry.id));
1511
+ if (
1512
+ evidence.length !== input.evidenceIds.length ||
1513
+ decisions.length !== input.decisionIds.length
1514
+ )
1515
+ return failure("invalid_input", "finding resolution references an unknown record");
1516
+ if (input.disposition === "fixed") {
1517
+ const current = captureCandidate(this.store.root, task.data.intent.data.scope, environment());
1518
+ if (!current.ok) return current;
1519
+ const evaluations = evaluateEvidence(task.data, current.data, treeFreshness(this.store.root));
1520
+ const verified = evidence.some((entry) => {
1521
+ const evaluation = evaluations.find((item) => item.evidenceId === entry.id);
1522
+ return (
1523
+ entry.recordedAt >= finding.recordedAt &&
1524
+ findingVerificationPasses(finding.data.candidateId, {
1525
+ kind: entry.data.kind,
1526
+ candidateId: entry.data.candidateId,
1527
+ status: evaluation?.status ?? "missing",
1528
+ })
1529
+ );
1530
+ });
1531
+ if (!verified)
1532
+ return failure("permission_denied", "fixed findings require passing verification evidence");
1533
+ }
1534
+ if (input.disposition === "dismissed" && evidence.length === 0)
1535
+ return failure("permission_denied", "dismissal requires supporting evidence");
1536
+ if (input.disposition === "dismissed") {
1537
+ const relevant = evidence.every((entry) => {
1538
+ if (entry.data.result === "missing" || entry.data.result === "skipped") return false;
1539
+ const candidateMatches =
1540
+ finding.data.candidateId === null
1541
+ ? entry.data.candidateId === null
1542
+ : entry.data.candidateId === finding.data.candidateId ||
1543
+ entry.data.beforeCandidateId === finding.data.candidateId;
1544
+ const referenceMatches = finding.data.refs.some((left) =>
1545
+ entry.data.refs.some((right) => sameValue(left, right)),
1546
+ );
1547
+ return candidateMatches && (referenceMatches || entry.data.claim === finding.data.claim);
1548
+ });
1549
+ if (!relevant)
1550
+ return failure("permission_denied", "dismissal evidence is unrelated to the finding");
1551
+ }
1552
+ if (input.disposition === "deferred") {
1553
+ const workspace = this.store.readWorkspace();
1554
+ if (!workspace.ok) return workspace;
1555
+ if (!workspace.data) return failure("not_found", "workspace not found");
1556
+ const allowed = decisions.some(
1557
+ (entry) =>
1558
+ entry.provenance.kind !== "imported" &&
1559
+ entry.data.purpose === "limitation" &&
1560
+ entry.data.response === "approved" &&
1561
+ entry.data.revoked === null &&
1562
+ verifyDecisionContentAtRoot(this.store.root, entry.data.binding).ok &&
1563
+ entry.data.binding.taskId === task.data.id &&
1564
+ entry.data.binding.workspaceId === workspace.data!.id &&
1565
+ bindingCovers(entry.data.binding.scope, finding.data.scope) &&
1566
+ entry.data.requirementIds.some((id: string) =>
1567
+ task.data.policy?.requirements.some(
1568
+ (requirement) => requirement.id === id && requirement.acceptanceAllowed,
1569
+ ),
1570
+ ),
1571
+ );
1572
+ if (!allowed)
1573
+ return failure(
1574
+ "permission_denied",
1575
+ "deferred findings require an approved limitation decision that references an acceptanceAllowed requirement and covers the finding scope",
1576
+ );
1577
+ }
1578
+ const changed = this.store.mutateTask(
1579
+ task.data.id,
1580
+ input.expectedRevision,
1581
+ (current, mutation) => {
1582
+ const existing = current.findings.find((entry) => entry.id === input.findingId);
1583
+ if (!existing) return failure("not_found", "finding not found");
1584
+ const data =
1585
+ input.disposition === "open"
1586
+ ? { ...existing.data, disposition: "open" as const, resolution: null }
1587
+ : {
1588
+ ...existing.data,
1589
+ disposition: input.disposition,
1590
+ resolution: {
1591
+ reason: input.reason,
1592
+ evidenceIds: input.evidenceIds,
1593
+ decisionIds: input.decisionIds,
1594
+ },
1595
+ };
1596
+ const updated = { ...existing, data };
1597
+ return success(mutation.revision, null, {
1598
+ ...current,
1599
+ findings: current.findings.map((entry) =>
1600
+ entry.id === input.findingId ? updated : entry,
1601
+ ),
1602
+ });
1603
+ },
1604
+ trustedNow(this.context),
1605
+ );
1606
+ if (!changed.ok) return changed;
1607
+ const entry = changed.data.findings.find((candidate) => candidate.id === input.findingId);
1608
+ return entry
1609
+ ? success(changed.data.revision, null, entry)
1610
+ : failure("recovery_required", "finding disappeared");
1611
+ }
1612
+
1613
+ worker(request: unknown): Result<Entry<Worker>> {
1614
+ const resolved = this.implicitTask("worker", request);
1615
+ if (!resolved.ok) return resolved;
1616
+ const value = resolved.data;
1617
+ const result = this.retryOmittedRevisions(value, () => this.workerOnce(value));
1618
+ // A cancel/report whose task was resolved from the worker id names the
1619
+ // exact command for that task (every host and the CLI see the same text).
1620
+ const { action, taskId } = (value ?? {}) as { action?: unknown; taskId?: unknown };
1621
+ return result.ok ||
1622
+ (request as { taskId?: unknown } | null)?.taskId !== undefined ||
1623
+ typeof taskId !== "string" ||
1624
+ !WORKER_ADDRESSED_ACTIONS.has(`worker.${String(action)}`)
1625
+ ? result
1626
+ : { ...result, error: `${result.error}; run \`${workerCommand(taskId, value)}\`` };
1627
+ }
1628
+
1629
+ private workerOnce(request: unknown): Result<Entry<Worker>> {
1630
+ const root = this.contextRootError();
1631
+ if (!root.ok) return root;
1632
+ const parsed = parseOperation("worker", request);
1633
+ if (!parsed.ok) return parsed;
1634
+ const input = parsed.data;
1635
+ const task = this.store.readTask(input.taskId);
1636
+ if (!task.ok) return task;
1637
+ const workspace = this.store.readWorkspace();
1638
+ if (!workspace.ok) return workspace;
1639
+ if (!workspace.data) return failure("not_found", "workspace not found");
1640
+ this.fillRevisions(input, task.data, workspace.data);
1641
+ const helperId = this.context.workerId ?? null;
1642
+ if (input.action === "assign") {
1643
+ if (helperId !== null) return failure("permission_denied", "helpers cannot assign workers");
1644
+ if (task.data.status !== "active")
1645
+ return failure("invalid_transition", "paused or closed tasks cannot assign workers");
1646
+ if (!bindingCovers(task.data.intent.data.scope, input.assignment.scope))
1647
+ return failure("permission_denied", "worker assignment is outside task scope", {
1648
+ fields: [
1649
+ {
1650
+ path: "assignment.scope",
1651
+ reason: `paths ${JSON.stringify(input.assignment.scope.paths)} must be contained by lead paths ${JSON.stringify(task.data.intent.data.scope.paths)}`,
1652
+ },
1653
+ {
1654
+ path: "task.intent.scope",
1655
+ reason: `lead paths are ${JSON.stringify(task.data.intent.data.scope.paths)}`,
1656
+ },
1657
+ ],
1658
+ });
1659
+ if (
1660
+ input.assignment.decisionIds.some(
1661
+ (id: string) => !task.data.decisions.some((entry) => entry.id === id),
1662
+ )
1663
+ )
1664
+ return failure("invalid_input", "worker assignment references an unknown decision");
1665
+ if (
1666
+ input.assignment.requirementIds.some(
1667
+ (id: string) =>
1668
+ !task.data.policy?.requirements.some((requirement) => requirement.id === id),
1669
+ )
1670
+ )
1671
+ return failure("invalid_input", "worker assignment references an unknown requirement");
1672
+ const reviewRequirements = (task.data.policy?.requirements ?? []).filter(
1673
+ (requirement) => requirement.dimension === "review",
1674
+ );
1675
+ if (
1676
+ input.assignment.role === "reviewer" &&
1677
+ input.assignment.requirementIds.length === 0 &&
1678
+ reviewRequirements.length
1679
+ )
1680
+ return failure(
1681
+ "invalid_input",
1682
+ "reviewer assignment needs the review requirement ids it will evidence",
1683
+ { requirementIds: reviewRequirements.map((requirement) => requirement.id) },
1684
+ );
1685
+ if (
1686
+ input.assignment.candidateId &&
1687
+ !task.data.candidates.some((candidate) => candidate.id === input.assignment.candidateId)
1688
+ )
1689
+ return failure("invalid_input", "worker assignment references an unknown candidate");
1690
+ input.assignment.candidateId ??= null;
1691
+ const workerId = newId();
1692
+ const changed = this.store.mutateTaskAndWorkspace({
1693
+ taskId: task.data.id,
1694
+ expectedRevision: input.expectedRevision,
1695
+ expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1696
+ now: trustedNow(this.context),
1697
+ workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
1698
+ task: (current, mutation) => {
1699
+ const entry: Entry<Worker> = {
1700
+ id: workerId,
1701
+ recordedAt: mutation.now,
1702
+ provenance: provenance(this.context, "agent_reported"),
1703
+ data: { assignment: input.assignment, state: "assigned", session: null, report: null },
1704
+ };
1705
+ return success(mutation.revision, null, {
1706
+ ...current,
1707
+ workers: [...current.workers, entry],
1708
+ });
1709
+ },
1710
+ });
1711
+ if (!changed.ok) return changed;
1712
+ return success(
1713
+ changed.data.task.revision,
1714
+ changed.data.workspace.revision,
1715
+ changed.data.task.workers.at(-1)!,
1716
+ );
1717
+ }
1718
+
1719
+ // A helper may only address itself: resolving against its own id alone keeps
1720
+ // another worker's id out of any ambiguity error.
1721
+ const workerRef = resolveIdRef(
1722
+ "worker",
1723
+ input.workerId,
1724
+ helperId === null ? task.data.workers.map((candidate) => candidate.id) : [helperId],
1725
+ );
1726
+ if (!workerRef.ok)
1727
+ return helperId === null
1728
+ ? workerRef
1729
+ : failure(
1730
+ "permission_denied",
1731
+ input.action === "report" ? ONLY_OWN_REPORT : NO_HELPER_CONTROL,
1732
+ );
1733
+ input.workerId = workerRef.data;
1734
+ const entry = task.data.workers.find((candidate) => candidate.id === input.workerId);
1735
+ if (!entry) return failure("not_found", "worker not found");
1736
+ if (input.action === "report") {
1737
+ if (helperId === null || helperId !== input.workerId)
1738
+ return failure("permission_denied", ONLY_OWN_REPORT);
1739
+ const helper = this.helperEntry(task.data, true, true);
1740
+ if (!helper.ok) return helper;
1741
+ const ownFindings = task.data.findings
1742
+ .filter((candidate) => candidate.provenance.workerId === helperId)
1743
+ .map((candidate) => candidate.id);
1744
+ const reported: string[] = [];
1745
+ for (const findingRef of input.report.findingIds) {
1746
+ const found = resolveIdRef("finding", findingRef, ownFindings);
1747
+ if (found.ok) reported.push(found.data);
1748
+ else if (found.code === "not_found") reported.push(findingRef);
1749
+ else return found;
1750
+ }
1751
+ input.report.findingIds = reported;
1752
+ const evidence = task.data.evidence.filter((candidate) =>
1753
+ input.report.evidenceIds.includes(candidate.id),
1754
+ );
1755
+ const findings = task.data.findings.filter((candidate) =>
1756
+ input.report.findingIds.includes(candidate.id),
1757
+ );
1758
+ if (
1759
+ evidence.length !== input.report.evidenceIds.length ||
1760
+ findings.length !== input.report.findingIds.length
1761
+ )
1762
+ return failure("invalid_input", "worker report references an unknown record");
1763
+ if (
1764
+ evidence.some((candidate) => candidate.provenance.workerId !== helperId) ||
1765
+ findings.some((candidate) => candidate.provenance.workerId !== helperId) ||
1766
+ evidence.some(
1767
+ (candidate) =>
1768
+ !refsWithinScope(candidate.data.refs, helper.data.data.assignment.scope) ||
1769
+ !refsWithinScope(candidate.data.refs, task.data.intent.data.scope),
1770
+ ) ||
1771
+ findings.some(
1772
+ (candidate) =>
1773
+ !refsWithinScope(candidate.data.refs, helper.data.data.assignment.scope) ||
1774
+ !refsWithinScope(candidate.data.refs, task.data.intent.data.scope),
1775
+ )
1776
+ )
1777
+ return failure("permission_denied", "worker report is outside the worker assignment");
1778
+ const changed = this.store.mutateTaskAndWorkspace({
1779
+ taskId: task.data.id,
1780
+ expectedRevision: input.expectedRevision,
1781
+ expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1782
+ now: trustedNow(this.context),
1783
+ workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
1784
+ task: (current, mutation) =>
1785
+ success(mutation.revision, null, {
1786
+ ...current,
1787
+ workers: current.workers.map((candidate) =>
1788
+ candidate.id === input.workerId
1789
+ ? {
1790
+ ...candidate,
1791
+ recordedAt: mutation.now,
1792
+ data: { ...candidate.data, report: input.report },
1793
+ }
1794
+ : candidate,
1795
+ ),
1796
+ }),
1797
+ });
1798
+ if (!changed.ok) return changed;
1799
+ const updated = changed.data.task.workers.find(
1800
+ (candidate) => candidate.id === input.workerId,
1801
+ );
1802
+ return updated
1803
+ ? success(changed.data.task.revision, changed.data.workspace.revision, updated)
1804
+ : failure("recovery_required", "worker disappeared during report");
1805
+ }
1806
+
1807
+ if (helperId !== null) return failure("permission_denied", NO_HELPER_CONTROL);
1808
+ if (task.data.status === "closed")
1809
+ return failure("invalid_transition", "closed task cannot cancel workers");
1810
+ if (entry.data.state === "stopped") {
1811
+ // Idempotent cancel: repeating a settled stop succeeds with the
1812
+ // current entry instead of failing the retry.
1813
+ const stopped = task.data.workers.find((candidate) => candidate.id === input.workerId)!;
1814
+ return success(task.data.revision, workspace.data.revision, stopped);
1815
+ }
1816
+ // Lead-attested cancel is terminal for every live or unknown state: the lead
1817
+ // saw the run end (or chose to abandon it), so no host observation is awaited. A later live-child
1818
+ // sighting against a stopped worker is a new anomaly for the host to
1819
+ // flag, not a reason to strand the worker short of stopped.
1820
+ const changed = this.store.mutateTaskAndWorkspace({
1821
+ taskId: task.data.id,
1822
+ expectedRevision: input.expectedRevision,
1823
+ expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1824
+ now: trustedNow(this.context),
1825
+ workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
1826
+ task: (current, mutation) =>
1827
+ success(mutation.revision, null, {
1828
+ ...current,
1829
+ workers: current.workers.map((candidate) =>
1830
+ candidate.id === input.workerId
1831
+ ? {
1832
+ ...candidate,
1833
+ recordedAt: mutation.now,
1834
+ data: { ...candidate.data, state: "stopped" as const },
1835
+ }
1836
+ : candidate,
1837
+ ),
1838
+ }),
1839
+ });
1840
+ if (!changed.ok) return changed;
1841
+ return success(
1842
+ changed.data.task.revision,
1843
+ changed.data.workspace.revision,
1844
+ changed.data.task.workers.find((candidate) => candidate.id === input.workerId)!,
1845
+ );
1846
+ }
1847
+
1848
+ observeWorkerLifecycle(input: NativeWorkerObservation): Result<Entry<Worker>> {
1849
+ const root = this.contextRootError();
1850
+ if (!root.ok) return root;
1851
+ if (
1852
+ this.context.workerId !== undefined &&
1853
+ this.context.workerId !== null &&
1854
+ this.context.workerId !== input.workerId
1855
+ )
1856
+ return failure("permission_denied", "worker observation does not match the caller");
1857
+ const task = this.store.readTask(input.taskId);
1858
+ if (!task.ok) return task;
1859
+ const workspace = this.store.readWorkspace();
1860
+ if (!workspace.ok) return workspace;
1861
+ if (!workspace.data) return failure("not_found", "workspace not found");
1862
+ const authority = verifyNativeWorker(
1863
+ this.context.nativeWorker,
1864
+ {
1865
+ observation: input.observation,
1866
+ expected: { ...input, workspaceId: workspace.data.id },
1867
+ caller: this.context.caller,
1868
+ },
1869
+ {
1870
+ owner: this.authorityOwner,
1871
+ store: this.store,
1872
+ root: this.store.root,
1873
+ caller: this.context.caller,
1874
+ },
1875
+ );
1876
+ if (!authority.ok) return authority;
1877
+ return applyWorkerLifecycle({
1878
+ ...input,
1879
+ store: this.store,
1880
+ authority: authority.data,
1881
+ authorityOwner: this.authorityOwner,
1882
+ caller: this.context.caller,
1883
+ now: trustedNow(this.context),
1884
+ });
1885
+ }
1886
+
1887
+ private resolve(task: TaskRecord, judgment: Judgment): Result<Policy> {
1888
+ return resolvePolicy({
1889
+ intent: task.intent.data,
1890
+ judgment,
1891
+ constraints: this.context.constraints,
1892
+ verification: workspaceVerification(this.context.root),
1893
+ });
1894
+ }
1895
+
1896
+ private summary(task: TaskRecord): Result<TaskSummary> {
1897
+ const workspace = this.store.readWorkspace();
1898
+ if (!workspace.ok) return workspace;
1899
+ if (!workspace.data) return failure("not_found", "workspace not found");
1900
+ const historical = task.status === "closed" ? task.candidates.at(-1) : undefined;
1901
+ const current = historical
1902
+ ? success(null, null, historical)
1903
+ : captureCandidate(this.store.root, task.intent.data.scope, environment());
1904
+ if (!current.ok) return current;
1905
+ const evaluationWorkspace = workspace.data;
1906
+ const tree = treeFreshness(this.store.root);
1907
+ const requirements = evaluateRequirements(
1908
+ task,
1909
+ evaluationWorkspace,
1910
+ this.context.capabilities,
1911
+ current.data,
1912
+ this.store.root,
1913
+ this.context.caller,
1914
+ tree,
1915
+ );
1916
+ return success(task.revision, workspace.data.revision, {
1917
+ id: task.id,
1918
+ revision: task.revision,
1919
+ workspaceRevision: workspace.data.revision,
1920
+ createdAt: task.createdAt,
1921
+ updatedAt: task.updatedAt,
1922
+ runtime: task.runtime ?? null,
1923
+ objective: task.intent.data.objective,
1924
+ status: task.status,
1925
+ closure: task.closure,
1926
+ progress: task.progress,
1927
+ policy: task.policy,
1928
+ requirements,
1929
+ });
1930
+ }
1931
+
1932
+ /** Compact task context for per-turn host injection (task-view.ts). */
1933
+ compactContext(
1934
+ taskId: string | null,
1935
+ freshness?: Freshness,
1936
+ origin: { origin: string; taskId: string } | null = null,
1937
+ ): Result<string> {
1938
+ return compactContext(this.store, this.context, taskId, freshness, origin);
1939
+ }
1940
+
1941
+ private view(task: TaskRecord, capture = true, freshness?: Freshness): Result<TaskView> {
1942
+ return taskView(this.store, this.context, task, capture, freshness);
1943
+ }
1944
+
1945
+ reconcileResume(
1946
+ view: TaskView,
1947
+ observations: NativeWorkerObservation[] = [],
1948
+ ): Result<ResumeReconciliation> {
1949
+ const root = this.contextRootError();
1950
+ if (!root.ok) return root;
1951
+ if ((this.context.workerId ?? null) !== null)
1952
+ return failure("permission_denied", "helpers cannot reconcile task workers");
1953
+ if (view.task.workspaceId !== view.workspace.id)
1954
+ return failure("invalid_input", "task and workspace bindings are invalid");
1955
+ try {
1956
+ if (realpathSync(view.workspace.root) !== this.store.root)
1957
+ return failure("invalid_input", "task view root does not match the task store");
1958
+ } catch {
1959
+ return failure("invalid_input", "task view root cannot be resolved");
1960
+ }
1961
+ const updates: NativeWorkerObservation[] = [];
1962
+ const seen = new Set<string>();
1963
+ const binding: WorkerBinding = {
1964
+ owner: this.authorityOwner,
1965
+ store: this.store,
1966
+ root: this.store.root,
1967
+ caller: this.context.caller,
1968
+ };
1969
+ for (const observation of observations) {
1970
+ if (seen.has(observation.workerId))
1971
+ return failure("invalid_input", "worker observations must be unique");
1972
+ seen.add(observation.workerId);
1973
+ // Anchor on fresh reads, not the passed view: a view captured before
1974
+ // recent mutations must not stale-fail observations that match the
1975
+ // current state. Genuinely stale observations still conflict below.
1976
+ const freshTask = this.store.readTask(view.task.id);
1977
+ const freshWorkspace = this.store.readWorkspace();
1978
+ if (!freshTask.ok) return freshTask;
1979
+ if (!freshWorkspace.ok) return freshWorkspace;
1980
+ if (!freshWorkspace.data) return failure("not_found", "workspace not found");
1981
+ if (
1982
+ observation.taskId !== view.task.id ||
1983
+ observation.expectedRevision !== freshTask.data.revision ||
1984
+ observation.expectedWorkspaceRevision !== freshWorkspace.data.revision
1985
+ )
1986
+ return failure("revision_conflict", "worker observation is stale");
1987
+ if (!view.task.workers.some((entry) => entry.id === observation.workerId))
1988
+ return failure("permission_denied", "worker observation is not assigned");
1989
+ const verified = verifyNativeWorker(
1990
+ this.context.nativeWorker,
1991
+ {
1992
+ observation: observation.observation,
1993
+ expected: { ...observation, workspaceId: view.workspace.id },
1994
+ caller: this.context.caller,
1995
+ },
1996
+ binding,
1997
+ );
1998
+ if (!verified.ok) return verified;
1999
+ if (!takeWorker(verified.data, binding, observation))
2000
+ return failure("permission_denied", "worker observation authority was not retained");
2001
+ updates.push(observation);
2002
+ }
2003
+ const observedStates = new Map(updates.map((entry) => [entry.workerId, entry.state]));
2004
+ const effectiveView: TaskView = {
2005
+ ...view,
2006
+ task: {
2007
+ ...view.task,
2008
+ workers: view.task.workers.map((entry) => {
2009
+ const update = updates.find((item) => item.workerId === entry.id);
2010
+ return update
2011
+ ? {
2012
+ ...entry,
2013
+ data: { ...entry.data, state: update.state, session: update.session },
2014
+ }
2015
+ : entry;
2016
+ }),
2017
+ },
2018
+ };
2019
+ const base = reconcileResumeContext(effectiveView);
2020
+ if (!base.ok) return base;
2021
+ const needsReconciliation = view.task.workers.some((entry) =>
2022
+ isUncertainWorker(observedStates.get(entry.id) ?? entry.data.state),
2023
+ );
2024
+ const blockers = [...base.data.blockers];
2025
+ if (
2026
+ needsReconciliation &&
2027
+ !blockers.some((entry) => entry.reason === "worker state requires reconciliation")
2028
+ )
2029
+ blockers.push({
2030
+ reason: "worker state requires reconciliation",
2031
+ dependentAction: "resume",
2032
+ refs: [],
2033
+ });
2034
+ return success(null, null, { ...base.data, workerUpdates: updates, blockers });
2035
+ }
2036
+
2037
+ private transition(input: any, status: "active" | "paused"): Result<TaskSummary> {
2038
+ const task = this.store.readTask(input.taskId);
2039
+ if (!task.ok) return task;
2040
+ const helper = this.helperTaskGuard(task.data);
2041
+ if (!helper.ok) return helper;
2042
+ if (
2043
+ (status === "paused" && task.data.status !== "active") ||
2044
+ (status === "active" && task.data.status !== "paused")
2045
+ )
2046
+ return failure("invalid_transition", `cannot transition ${task.data.status} to ${status}`);
2047
+ const workspace = this.store.readWorkspace();
2048
+ if (!workspace.ok) return workspace;
2049
+ if (!workspace.data) return failure("not_found", "workspace not found");
2050
+ this.fillRevisions(input, task.data, workspace.data);
2051
+ let resumeCandidate: import("./task-contract").Candidate | null = null;
2052
+ if (status === "active" && task.data.origin) {
2053
+ if (!task.data.policy)
2054
+ return failure("needs_input", "imported task requires a current policy");
2055
+ if (task.data.policy.policyVersion !== POLICY_VERSION)
2056
+ return failure(
2057
+ "needs_input",
2058
+ "stored policy version is unsupported; reassessment is required",
2059
+ );
2060
+ const view = this.view(task.data);
2061
+ if (!view.ok) return view;
2062
+ const current = reconcileResumeContext(view.data);
2063
+ if (!current.ok) return current;
2064
+ const requirements = view.data.requirements;
2065
+ if (
2066
+ requirements.some(
2067
+ (requirement) =>
2068
+ requirement.status === "unsatisfied" || requirement.status === "unavailable",
2069
+ )
2070
+ )
2071
+ return failure("requirements_unsatisfied", "imported task requires current requirements", {
2072
+ requirementIds: requirements
2073
+ .filter(
2074
+ (requirement) =>
2075
+ requirement.status === "unsatisfied" || requirement.status === "unavailable",
2076
+ )
2077
+ .map((requirement) => requirement.requirementId),
2078
+ });
2079
+ if (current.data.reassessmentRequired)
2080
+ return failure("needs_input", "imported task requires destination policy reassessment");
2081
+ if (current.data.staleEvidenceIds.length)
2082
+ return failure("needs_input", "imported task requires fresh destination evidence");
2083
+ if (
2084
+ current.data.blockers.some(
2085
+ (blocker) =>
2086
+ blocker.dependentAction === "resume" ||
2087
+ blocker.reason === "worker state requires reconciliation",
2088
+ )
2089
+ )
2090
+ return failure("recovery_required", "imported task requires resume reconciliation");
2091
+ resumeCandidate = current.data.candidate;
2092
+ }
2093
+ const blocker = status === "paused" ? null : this.activeWorkerBlocker(task.data);
2094
+ if (blocker) return blocker;
2095
+ let pauseCandidate: import("./task-contract").Candidate | null = null;
2096
+ if (status === "paused") {
2097
+ // Freezing records the tree state the task resumes from.
2098
+ const captured = captureCandidate(
2099
+ this.store.root,
2100
+ task.data.intent.data.scope,
2101
+ environment(),
2102
+ );
2103
+ if (!captured.ok) return captured;
2104
+ pauseCandidate = captured.data;
2105
+ }
2106
+ const changed = this.store.mutateTaskAndWorkspace({
2107
+ taskId: task.data.id,
2108
+ expectedRevision: input.expectedRevision,
2109
+ expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2110
+ now: trustedNow(this.context),
2111
+ workspace: (unchanged, context) => success(context.revision, context.revision, unchanged),
2112
+ task: (current, context) =>
2113
+ success(context.revision, null, {
2114
+ ...current,
2115
+ status,
2116
+ candidates:
2117
+ (resumeCandidate ?? pauseCandidate) &&
2118
+ !current.candidates.some(
2119
+ (candidate) => candidate.id === (resumeCandidate ?? pauseCandidate)!.id,
2120
+ )
2121
+ ? [...current.candidates, (resumeCandidate ?? pauseCandidate)!]
2122
+ : current.candidates,
2123
+ progress: current.progress,
2124
+ pauseReason: status === "paused" ? (input.reason ?? null) : null,
2125
+ }),
2126
+ });
2127
+ if (!changed.ok) return changed;
2128
+ return this.summary(changed.data.task);
2129
+ }
2130
+
2131
+ private progress(input: any): Result<TaskSummary> {
2132
+ const task = this.store.readTask(input.taskId);
2133
+ if (!task.ok) return task;
2134
+ const helper = this.helperTaskGuard(task.data);
2135
+ if (!helper.ok) return helper;
2136
+ if (task.data.status === "closed")
2137
+ return failure("invalid_transition", "closed task cannot update progress");
2138
+ this.fillRevisions(input, task.data);
2139
+ const changed = this.store.mutateTask(
2140
+ task.data.id,
2141
+ input.expectedRevision,
2142
+ (current, context) =>
2143
+ success(context.revision, null, { ...current, progress: input.progress }),
2144
+ trustedNow(this.context),
2145
+ );
2146
+ if (!changed.ok) return changed;
2147
+ return this.summary(changed.data);
2148
+ }
2149
+
2150
+ private revise(input: any): Result<TaskSummary> {
2151
+ const task = this.store.readTask(input.taskId);
2152
+ if (!task.ok) return task;
2153
+ const helper = this.helperTaskGuard(task.data);
2154
+ if (!helper.ok) return helper;
2155
+ if (task.data.status === "closed")
2156
+ return failure("invalid_transition", "closed task cannot be revised");
2157
+ const workspace = this.store.readWorkspace();
2158
+ if (!workspace.ok) return workspace;
2159
+ if (!workspace.data) return failure("not_found", "workspace not found");
2160
+ const revisionBlocker = this.activeWorkerBlocker(task.data);
2161
+ if (revisionBlocker) return revisionBlocker;
2162
+ this.fillRevisions(input, task.data, workspace.data);
2163
+ const changed = this.store.mutateTaskAndWorkspace({
2164
+ taskId: task.data.id,
2165
+ expectedRevision: input.expectedRevision,
2166
+ expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2167
+ now: trustedNow(this.context),
2168
+ workspace: (unchanged, context) => success(context.revision, context.revision, unchanged),
2169
+ task: (current, context) =>
2170
+ success(context.revision, null, {
2171
+ ...current,
2172
+ intent: {
2173
+ id: newId(),
2174
+ recordedAt: context.now,
2175
+ provenance: provenance(this.context),
2176
+ data: input.intent,
2177
+ },
2178
+ policy: null,
2179
+ progress: { ...current.progress, summary: `Policy invalidated: ${input.reason}` },
2180
+ }),
2181
+ });
2182
+ if (!changed.ok) return changed;
2183
+ return this.summary(changed.data.task);
2184
+ }
2185
+
2186
+ private close(input: any): Result<TaskSummary> {
2187
+ const task = this.store.readTask(input.taskId);
2188
+ if (!task.ok) return task;
2189
+ const helper = this.helperTaskGuard(task.data);
2190
+ if (!helper.ok) return helper;
2191
+ if (task.data.status !== "active" && task.data.status !== "paused")
2192
+ return failure("invalid_transition", "closed task cannot be reopened or closed again");
2193
+ const workspace = this.store.readWorkspace();
2194
+ if (!workspace.ok) return workspace;
2195
+ if (!workspace.data) return failure("not_found", "workspace not found");
2196
+ const blocker = this.activeWorkerBlocker(task.data);
2197
+ if (blocker) return blocker;
2198
+ this.fillRevisions(input, task.data, workspace.data);
2199
+ const current = captureCandidate(this.store.root, task.data.intent.data.scope, environment());
2200
+ if (!current.ok) return current;
2201
+ const withCandidate = current.data;
2202
+ const taskForView =
2203
+ withCandidate.id === task.data.candidates.at(-1)?.id
2204
+ ? task.data
2205
+ : { ...task.data, candidates: [...task.data.candidates, withCandidate] };
2206
+ const tree = treeFreshness(this.store.root);
2207
+ const requirements = evaluateRequirements(
2208
+ taskForView,
2209
+ workspace.data,
2210
+ this.context.capabilities,
2211
+ withCandidate,
2212
+ this.store.root,
2213
+ this.context.caller,
2214
+ tree,
2215
+ );
2216
+ const view = {
2217
+ task: taskForView,
2218
+ workspace: workspace.data,
2219
+ capabilities: this.context.capabilities,
2220
+ requirements,
2221
+ evidence: evaluateEvidence(taskForView, withCandidate, tree),
2222
+ } satisfies TaskView;
2223
+ const closure = evaluateClosure(input.outcome, view);
2224
+ if (!closure.ok) return closure;
2225
+ const changed = this.store.mutateTaskAndWorkspace({
2226
+ taskId: task.data.id,
2227
+ expectedRevision: input.expectedRevision,
2228
+ expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2229
+ now: trustedNow(this.context),
2230
+ workspace: (value, context) => success(context.revision, context.revision, value),
2231
+ task: (value, context) =>
2232
+ success(context.revision, null, {
2233
+ ...value,
2234
+ status: "closed",
2235
+ candidates: taskForView.candidates,
2236
+ closure: {
2237
+ outcome: closure.data.outcome,
2238
+ at: context.now,
2239
+ summary: input.summary,
2240
+ decisionIds: closure.data.decisionIds,
2241
+ evidenceIds: closure.data.evidenceIds,
2242
+ },
2243
+ }),
2244
+ });
2245
+ if (!changed.ok) return changed;
2246
+ return this.summary(changed.data.task);
2247
+ }
2248
+ }