dsh-live-teams 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +176 -0
  2. package/NOTICE +11 -0
  3. package/README.md +85 -0
  4. package/cordis.patch.yml +25 -0
  5. package/lib/binding.d.ts +18 -0
  6. package/lib/binding.js +42 -0
  7. package/lib/changed-paths.d.ts +26 -0
  8. package/lib/changed-paths.js +69 -0
  9. package/lib/client.js +6753 -0
  10. package/lib/command-queue.d.ts +60 -0
  11. package/lib/command-queue.js +185 -0
  12. package/lib/compatibility.js +109 -0
  13. package/lib/context-provider.d.ts +110 -0
  14. package/lib/context-provider.js +249 -0
  15. package/lib/dispatch.d.ts +174 -0
  16. package/lib/dispatch.js +624 -0
  17. package/lib/errors.d.ts +36 -0
  18. package/lib/errors.js +103 -0
  19. package/lib/git-artifacts.d.ts +50 -0
  20. package/lib/git-artifacts.js +242 -0
  21. package/lib/index.d.ts +14 -0
  22. package/lib/index.js +14 -0
  23. package/lib/mailbox.d.ts +274 -0
  24. package/lib/mailbox.js +721 -0
  25. package/lib/member-tools.d.ts +57 -0
  26. package/lib/member-tools.js +1265 -0
  27. package/lib/migrations.d.ts +17 -0
  28. package/lib/migrations.js +47 -0
  29. package/lib/plugin.d.ts +106 -0
  30. package/lib/plugin.js +1003 -0
  31. package/lib/roles.d.ts +35 -0
  32. package/lib/roles.js +284 -0
  33. package/lib/routes.d.ts +586 -0
  34. package/lib/routes.js +2816 -0
  35. package/lib/scope.d.ts +62 -0
  36. package/lib/scope.js +133 -0
  37. package/lib/session-bridge.d.ts +76 -0
  38. package/lib/session-bridge.js +147 -0
  39. package/lib/session-title.js +35 -0
  40. package/lib/storage.d.ts +9 -0
  41. package/lib/storage.js +65 -0
  42. package/lib/task-store.d.ts +729 -0
  43. package/lib/task-store.js +2205 -0
  44. package/lib/team-store.d.ts +216 -0
  45. package/lib/team-store.js +765 -0
  46. package/lib/tree-snapshot.d.ts +28 -0
  47. package/lib/tree-snapshot.js +80 -0
  48. package/lib/types/client/TeamView.d.ts +26 -0
  49. package/lib/types/client/TeamView.dom.test.d.ts +1 -0
  50. package/lib/types/client/api.d.ts +522 -0
  51. package/lib/types/client/api.test.d.ts +1 -0
  52. package/lib/types/client/attention.d.ts +65 -0
  53. package/lib/types/client/attention.test.d.ts +1 -0
  54. package/lib/types/client/index.d.ts +31 -0
  55. package/lib/types/client/locales.d.ts +577 -0
  56. package/lib/types/client/member-name.d.ts +14 -0
  57. package/lib/types/client/member-name.test.d.ts +1 -0
  58. package/lib/types/client/roster.d.ts +26 -0
  59. package/lib/types/client/roster.test.d.ts +1 -0
  60. package/lib/types/client/styles.d.ts +3 -0
  61. package/package.json +104 -0
  62. package/roles/builder.md +40 -0
  63. package/roles/delegate.md +36 -0
  64. package/roles/lead.md +46 -0
  65. package/roles/oracle.md +36 -0
  66. package/roles/researcher.md +37 -0
  67. package/roles/reviewer.md +45 -0
  68. package/roles/scout.md +36 -0
  69. package/roles/verifier.md +36 -0
@@ -0,0 +1,2205 @@
1
+ import { ERROR_CODES, LiveTeamsError } from "./errors.js";
2
+ import { atomicWrite, atomicWriteJson, clone, fileExists, readJson, withProcessLock } from "./storage.js";
3
+ import { TEAM_ROOT } from "./team-store.js";
4
+ import { CODING_KINDS, literalPrefix, normalizeScope, normalizeScopePath, outsideScope, scopesIntersect } from "./scope.js";
5
+ import { diffSnapshots } from "./tree-snapshot.js";
6
+ import { MIGRATIONS, applyMigration, planMigration } from "./migrations.js";
7
+ import path from "node:path";
8
+ import { createHash, randomUUID } from "node:crypto";
9
+ import { promises, readFileSync } from "node:fs";
10
+ //#region src/task-store.ts
11
+ const TASK_KINDS = [
12
+ "work",
13
+ "research",
14
+ "requirements",
15
+ "implementation",
16
+ "verification",
17
+ "review",
18
+ "repair",
19
+ "integration"
20
+ ];
21
+ const TASK_STATUSES = [
22
+ "draft",
23
+ "ready",
24
+ "claimed",
25
+ "in_progress",
26
+ "waiting",
27
+ "needs_human",
28
+ "completed",
29
+ "failed",
30
+ "cancelled"
31
+ ];
32
+ const MAX_TITLE = 200;
33
+ const MAX_BODY = 2e4;
34
+ const MAX_TASKS = 100;
35
+ /** A task with more dependencies than this is a plan, not a task. */
36
+ const MAX_DEPENDENCIES = 8;
37
+ const FINDING_SEVERITIES = [
38
+ "low",
39
+ "medium",
40
+ "high",
41
+ "blocker"
42
+ ];
43
+ const REVIEW_VERDICTS = [
44
+ "pass",
45
+ "needs_revision",
46
+ "escalate"
47
+ ];
48
+ function objectRecord(value) {
49
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
50
+ }
51
+ function nonEmpty(value) {
52
+ return typeof value === "string" && value.trim().length > 0;
53
+ }
54
+ function chars(value) {
55
+ return Array.from(value).length;
56
+ }
57
+ function candidateId(value) {
58
+ return value.length > 0 && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\");
59
+ }
60
+ function fail(message) {
61
+ throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, message);
62
+ }
63
+ function memberById(team, id) {
64
+ return team.members.find((member) => member.memberId === id);
65
+ }
66
+ function isLead(team, actor) {
67
+ if (team.leadMemberId !== void 0) return team.leadMemberId === actor && memberById(team, actor) !== void 0;
68
+ const leads = team.members.filter((member) => member.role === "lead");
69
+ return leads.length === 1 && leads[0]?.memberId === actor;
70
+ }
71
+ /** The desktop route is the owning human's lead-level operation. */
72
+ function isLeadOrHuman(team, actor) {
73
+ return actor === "human:desktop" || actor === "local:unverified" || isLead(team, actor);
74
+ }
75
+ function leadMemberIdForMessage(team) {
76
+ return team.leadMemberId ?? team.members.find((member) => member.role === "lead")?.memberId ?? "the lead";
77
+ }
78
+ function validateRoute(route, team, assignee) {
79
+ if (objectRecord(route)?.type === "interactive") fail("the interactive route shape is not implemented: use {\"type\":\"review\",\"reviewer\":\"<member id>\"} or {\"type\":\"report\",\"to\":\"human\"}");
80
+ const value = objectRecord(route);
81
+ if (value === void 0 || typeof value.type !== "string") fail("route must be a report, review, interactive, or custom route");
82
+ if (value.type === "report") {
83
+ if (value.to !== "human" && (typeof value.to !== "string" || memberById(team, value.to) === void 0)) fail("report route must name a current member or human");
84
+ return {
85
+ type: "report",
86
+ to: value.to
87
+ };
88
+ }
89
+ if (value.type === "review") {
90
+ if (typeof value.reviewer !== "string" || memberById(team, value.reviewer) === void 0) fail("review route must name a current member");
91
+ if (assignee !== void 0 && value.reviewer === assignee) fail("reviewer cannot be the assignee; nobody reviews their own work");
92
+ return {
93
+ type: "review",
94
+ reviewer: value.reviewer
95
+ };
96
+ }
97
+ if (value.type === "interactive") {
98
+ if (value.then === void 0) fail("interactive route requires a follow-up route");
99
+ return {
100
+ type: "interactive",
101
+ then: validateRoute(value.then, team, assignee)
102
+ };
103
+ }
104
+ if (value.type === "custom") {
105
+ if (!nonEmpty(value.description)) fail("custom route description must be non-empty");
106
+ return {
107
+ type: "custom",
108
+ description: value.description.trim()
109
+ };
110
+ }
111
+ fail(`unsupported task route: ${value.type}`);
112
+ }
113
+ /**
114
+ * Validate the dependency list of a task contract.
115
+ *
116
+ * Checked against the tasks that exist *now*, because a dependency naming a task
117
+ * that does not exist is a typo that would block the work forever, and a cycle
118
+ * would block it just as permanently while looking legitimate.
119
+ */
120
+ function validateDependencies(value, selfId, tasks) {
121
+ if (value === void 0) return void 0;
122
+ if (!Array.isArray(value)) fail("dependsOn must be an array of task ids");
123
+ if (value.length === 0) return void 0;
124
+ if (value.length > MAX_DEPENDENCIES) fail(`a task may depend on at most ${MAX_DEPENDENCIES} tasks`);
125
+ const ids = [];
126
+ for (const entry of value) {
127
+ if (typeof entry !== "string" || !candidateId(entry)) fail("dependsOn must contain task ids");
128
+ if (entry === selfId) fail("a task cannot depend on itself");
129
+ if (ids.includes(entry)) fail(`dependsOn repeats ${entry}`);
130
+ if (!tasks.some((task) => task.id === entry)) fail(`dependsOn names an unknown task: ${entry}`);
131
+ ids.push(entry);
132
+ }
133
+ const byId = new Map(tasks.map((task) => [task.id, task]));
134
+ const seen = /* @__PURE__ */ new Set();
135
+ const pending = [...ids];
136
+ while (pending.length > 0) {
137
+ const id = pending.pop();
138
+ if (id === selfId) fail(`dependsOn would create a cycle through ${id}`);
139
+ if (seen.has(id)) continue;
140
+ seen.add(id);
141
+ for (const next of byId.get(id)?.dependsOn ?? []) pending.push(next);
142
+ }
143
+ return ids;
144
+ }
145
+ /**
146
+ * The dependencies that still stand between a task and its claim, in the order
147
+ * the contract lists them. A dependency in a terminal non-completed state is
148
+ * included on purpose: it blocks forever and the human must see that.
149
+ */
150
+ function blockedBy(task, tasks) {
151
+ const ids = task.dependsOn ?? [];
152
+ if (ids.length === 0) return [];
153
+ const byId = new Map(tasks.map((entry) => [entry.id, entry]));
154
+ return ids.filter((id) => byId.get(id)?.status !== "completed").map((id) => ({
155
+ id,
156
+ title: byId.get(id)?.title ?? id,
157
+ status: byId.get(id)?.status ?? "failed"
158
+ }));
159
+ }
160
+ function validateTaskRecord(value, teamId, archiveDirectory) {
161
+ const candidate = objectRecord(value);
162
+ const humanContact = candidate?.humanContact;
163
+ const archivedPath = candidate?.archivedPath;
164
+ const archiveRoot = archiveDirectory === void 0 ? void 0 : path.resolve(archiveDirectory);
165
+ const resolvedArchive = typeof archivedPath !== "string" || archiveRoot === void 0 ? void 0 : path.isAbsolute(archivedPath) ? path.resolve(archivedPath) : path.resolve(path.dirname(archiveRoot), archivedPath);
166
+ const archivePathValid = archivedPath === void 0 || typeof archivedPath === "string" && archivedPath.length > 0 && resolvedArchive !== void 0 && (resolvedArchive === archiveRoot || resolvedArchive.startsWith(`${archiveRoot}${path.sep}`));
167
+ if (candidate === void 0 || typeof candidate.id !== "string" || !candidateId(candidate.id) || candidate.teamId !== teamId || typeof candidate.title !== "string" || candidate.title.trim().length === 0 || !TASK_KINDS.includes(candidate.kind) || !TASK_STATUSES.includes(candidate.status) || !Array.isArray(candidate.acceptance) || !candidate.acceptance.every((entry) => typeof entry === "string") || candidate.status === "ready" && (candidate.assignee === void 0 || candidate.route === void 0 || candidate.acceptance.length === 0 || candidate.acceptance.some((entry) => entry.trim().length === 0)) || candidate.assignee !== void 0 && typeof candidate.assignee !== "string" || candidate.why !== void 0 && typeof candidate.why !== "string" || candidate.body !== void 0 && (typeof candidate.body !== "string" || chars(candidate.body) > MAX_BODY) || candidate.checks !== void 0 && (!Array.isArray(candidate.checks) || !candidate.checks.every((entry) => typeof entry === "string" && entry.trim().length > 0)) || candidate.dependsOn !== void 0 && (!Array.isArray(candidate.dependsOn) || candidate.dependsOn.length === 0 || candidate.dependsOn.length > MAX_DEPENDENCIES || !candidate.dependsOn.every((entry) => typeof entry === "string" && candidateId(entry)) || new Set(candidate.dependsOn).size !== candidate.dependsOn.length || candidate.dependsOn.includes(candidate.id)) || candidate.paths !== void 0 && normalizeScope(candidate.paths) === void 0 || candidate.contractRevision !== void 0 && (!Number.isInteger(candidate.contractRevision) || Number(candidate.contractRevision) < 1) || !archivePathValid || humanContact !== void 0 && humanContact !== "none" && humanContact !== "expected" && humanContact !== "required" || candidate.activeAttemptId !== void 0 && (typeof candidate.activeAttemptId !== "string" || !candidateId(candidate.activeAttemptId)) || !Number.isInteger(candidate.roundLimit) || Number(candidate.roundLimit) < 1 || Number(candidate.roundLimit) > 20 || typeof candidate.createdBy !== "string" || candidate.createdBy.length === 0 || !Number.isInteger(candidate.createdAt) || !Number.isInteger(candidate.updatedAt) || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 1) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid task record");
168
+ return candidate;
169
+ }
170
+ function parseTasksState(value, teamId, archiveDirectory) {
171
+ const candidate = objectRecord(value);
172
+ if (candidate !== void 0 && Number.isInteger(candidate.schemaVersion) && Number(candidate.schemaVersion) !== 1) {
173
+ const plan = planMigration(candidate.schemaVersion, 1, "task state");
174
+ if (plan.kind === "future") throw plan.error;
175
+ }
176
+ if (candidate === void 0 || !Number.isInteger(candidate.schemaVersion) || Number(candidate.schemaVersion) > 1 || candidate.teamId !== teamId || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 0 || !Array.isArray(candidate.tasks)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid task state");
177
+ const tasks = candidate.tasks.map((entry) => validateTaskRecord(entry, teamId, archiveDirectory));
178
+ return {
179
+ ...candidate,
180
+ revision: Number(candidate.revision),
181
+ tasks
182
+ };
183
+ }
184
+ function validateAttempt(value, teamId) {
185
+ const candidate = objectRecord(value);
186
+ if (candidate === void 0 || typeof candidate.id !== "string" || !candidateId(candidate.id) || typeof candidate.taskId !== "string" || !candidateId(candidate.taskId) || candidate.memberId === void 0 || typeof candidate.memberId !== "string" || candidate.memberId.length === 0 || typeof candidate.sessionId !== "string" || candidate.sessionId.length === 0 || !Number.isInteger(candidate.bindingGeneration) || Number(candidate.bindingGeneration) < 0 || !Number.isInteger(candidate.generation) || Number(candidate.generation) < 1 || candidate.status !== "active" && candidate.status !== "revoking" && candidate.status !== "revoked" && candidate.status !== "completed" && candidate.status !== "failed" || !Number.isInteger(candidate.startedAt) || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 1 || candidate.humanIntervenedAt !== void 0 && !Number.isInteger(candidate.humanIntervenedAt) || candidate.humanIntervenedContractRevision !== void 0 && !Number.isInteger(candidate.humanIntervenedContractRevision) || candidate.gitBase !== void 0 && (objectRecord(candidate.gitBase) === void 0 || typeof objectRecord(candidate.gitBase)?.ref !== "string" || typeof objectRecord(candidate.gitBase)?.baseTipSha !== "string") || candidate.stalled !== void 0 && (objectRecord(candidate.stalled) === void 0 || !Number.isInteger(objectRecord(candidate.stalled)?.at) || objectRecord(candidate.stalled)?.reason !== "unacknowledged" && objectRecord(candidate.stalled)?.reason !== "idle" || typeof objectRecord(candidate.stalled)?.detail !== "string")) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `invalid task attempt in ${teamId}`);
187
+ return candidate;
188
+ }
189
+ function parseAttemptsState(value, teamId) {
190
+ const candidate = objectRecord(value);
191
+ if (candidate === void 0 || !Number.isInteger(candidate.schemaVersion) || Number(candidate.schemaVersion) > 1 || candidate.teamId !== teamId || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 0 || !Array.isArray(candidate.attempts)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid attempts state");
192
+ return {
193
+ ...candidate,
194
+ revision: Number(candidate.revision),
195
+ attempts: candidate.attempts.map((entry) => validateAttempt(entry, teamId))
196
+ };
197
+ }
198
+ /**
199
+ * The fence of ADR 0005, applied where it was missing: a member acting on its own task must present
200
+ * the Session and binding generation it is bound to *now*. `submitTask` always checked the triple;
201
+ * `claimTask` and `acknowledgeTask` did not, so a rebind could leave an old Session's call landing on
202
+ * the new one's attempt (found by an external review).
203
+ */
204
+ function fenceMember(member, request, memberId) {
205
+ if (member === void 0) fail(`${memberId} is not a current team member`);
206
+ if (member.sessionId === void 0) return;
207
+ if (request.sessionId !== member.sessionId || request.bindingGeneration !== member.bindingGeneration) fail(`${memberId} is bound to Session ${member.sessionId} (generation ${member.bindingGeneration}); this call came from another binding. Re-read the task and act from the current Session`);
208
+ }
209
+ /** A readable, collision-free ref name for a pinned submission commit. */
210
+ function submissionGitRefSuffix(generation, headSha) {
211
+ return `g${generation}-${headSha.slice(0, 12)}`;
212
+ }
213
+ function validateCheck(value) {
214
+ const candidate = objectRecord(value);
215
+ const keys = candidate === void 0 ? String(value) : Object.keys(candidate).join(", ");
216
+ if (candidate === void 0 || typeof candidate.command !== "string" || candidate.command.trim().length === 0 || candidate.status !== "passed" && candidate.status !== "failed" || candidate.output !== void 0 && typeof candidate.output !== "string") fail(`check needs {"command":"…","status":"passed" | "failed"} and optionally {"output":"…"}; received keys: ${keys}`);
217
+ return {
218
+ command: candidate.command,
219
+ status: candidate.status,
220
+ ...candidate.output === void 0 ? {} : { output: candidate.output }
221
+ };
222
+ }
223
+ function validateSubmission(value, teamId) {
224
+ const candidate = objectRecord(value);
225
+ const artifact = objectRecord(candidate?.artifact);
226
+ if (candidate === void 0 || typeof candidate.id !== "string" || !candidateId(candidate.id) || typeof candidate.taskId !== "string" || !candidateId(candidate.taskId) || typeof candidate.attemptId !== "string" || !candidateId(candidate.attemptId) || !Number.isInteger(candidate.attemptGeneration) || Number(candidate.attemptGeneration) < 1 || artifact === void 0 || artifact.type !== "report" && artifact.type !== "files" || typeof candidate.summary !== "string" || !Array.isArray(candidate.checks) || !Number.isInteger(candidate.createdAt) || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 1) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `invalid submission in ${teamId}`);
227
+ if (artifact.type === "report" && (typeof artifact.bodyRef !== "string" || typeof artifact.bodyDigest !== "string")) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid report submission artifact");
228
+ if (artifact.type === "files" && (!Array.isArray(artifact.paths) || !artifact.paths.every((entry) => typeof entry === "string") || typeof artifact.manifestDigest !== "string")) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid files submission artifact");
229
+ if (candidate.git !== void 0) {
230
+ const git = objectRecord(candidate.git);
231
+ if (git === void 0 || typeof git.baseRef !== "string" || typeof git.baseTipSha !== "string" || typeof git.headSha !== "string" || !Array.isArray(git.commitPaths) || !git.commitPaths.every((entry) => typeof entry === "string") || git.mergeBaseSha !== void 0 && typeof git.mergeBaseSha !== "string") throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid Git submission artifact");
232
+ }
233
+ const scope = objectRecord(candidate.scope);
234
+ const normalizedScope = scope === void 0 ? void 0 : {
235
+ ...scope,
236
+ state: scope.state === "collision" ? "shared-tree" : scope.state,
237
+ ...scope.sharedTree === void 0 && Array.isArray(scope.collision) ? { sharedTree: scope.collision } : {}
238
+ };
239
+ if (normalizedScope !== void 0) delete normalizedScope.collision;
240
+ return {
241
+ ...candidate,
242
+ ...normalizedScope === void 0 ? {} : { scope: normalizedScope },
243
+ artifact: clone(artifact),
244
+ checks: candidate.checks.map(validateCheck)
245
+ };
246
+ }
247
+ function parseSubmissionsState(value, teamId) {
248
+ const candidate = objectRecord(value);
249
+ if (candidate === void 0 || !Number.isInteger(candidate.schemaVersion) || Number(candidate.schemaVersion) > 1 || candidate.teamId !== teamId || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 0 || !Array.isArray(candidate.submissions)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid submissions state");
250
+ return {
251
+ ...candidate,
252
+ revision: Number(candidate.revision),
253
+ submissions: candidate.submissions.map((entry) => validateSubmission(entry, teamId))
254
+ };
255
+ }
256
+ function validateFinding(value) {
257
+ const candidate = objectRecord(value);
258
+ const keys = candidate === void 0 ? String(value) : Object.keys(candidate).join(", ");
259
+ if (candidate === void 0 || !FINDING_SEVERITIES.includes(candidate.severity) || typeof candidate.problem !== "string" || candidate.problem.trim().length === 0 || typeof candidate.requiredFix !== "string" || candidate.requiredFix.trim().length === 0 || candidate.artifactLocation !== void 0 && typeof candidate.artifactLocation !== "string") fail(`finding needs {"severity":"low" | "medium" | "high" | "blocker","problem":"…","requiredFix":"…"}; received keys: ${keys}`);
260
+ return {
261
+ severity: candidate.severity,
262
+ problem: candidate.problem,
263
+ requiredFix: candidate.requiredFix,
264
+ ...candidate.artifactLocation === void 0 ? {} : { artifactLocation: candidate.artifactLocation }
265
+ };
266
+ }
267
+ function validateReview(value, teamId) {
268
+ const candidate = objectRecord(value);
269
+ if (candidate === void 0 || typeof candidate.id !== "string" || !candidateId(candidate.id) || typeof candidate.taskId !== "string" || !candidateId(candidate.taskId) || typeof candidate.submissionId !== "string" || !candidateId(candidate.submissionId) || typeof candidate.reviewerMemberId !== "string" || candidate.reviewerMemberId.length === 0 || !Number.isInteger(candidate.round) || Number(candidate.round) < 1 || !REVIEW_VERDICTS.includes(candidate.verdict) || !Array.isArray(candidate.findings) || !Array.isArray(candidate.evidence) || !Number.isInteger(candidate.createdAt) || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 1) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `invalid review in ${teamId}`);
270
+ return {
271
+ ...candidate,
272
+ findings: candidate.findings.map(validateFinding),
273
+ evidence: candidate.evidence.map(validateCheck)
274
+ };
275
+ }
276
+ function parseReviewsState(value, teamId) {
277
+ const candidate = objectRecord(value);
278
+ if (candidate === void 0 || !Number.isInteger(candidate.schemaVersion) || Number(candidate.schemaVersion) > 1 || candidate.teamId !== teamId || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 0 || !Array.isArray(candidate.rounds)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid reviews state");
279
+ return {
280
+ ...candidate,
281
+ revision: Number(candidate.revision),
282
+ rounds: candidate.rounds.map((entry) => validateReview(entry, teamId))
283
+ };
284
+ }
285
+ function routeText(route, team) {
286
+ if (route === void 0) return "—";
287
+ if (route.type === "report") {
288
+ if (route.to === "human") return "report → human";
289
+ return `report → ${memberById(team, route.to)?.displayName ?? route.to}`;
290
+ }
291
+ if (route.type === "review") return `review → ${memberById(team, route.reviewer)?.displayName ?? route.reviewer}`;
292
+ if (route.type === "custom") return `custom → ${route.description}`;
293
+ return `interactive → ${routeText(route.then, team)}`;
294
+ }
295
+ function assignmentRouteText(route, team) {
296
+ if (route === void 0) return "not set";
297
+ if (route.type === "report") return route.to === "human" ? "report to the human" : `report to ${memberById(team, route.to)?.displayName ?? route.to}`;
298
+ if (route.type === "review") return `review by ${memberById(team, route.reviewer)?.displayName ?? route.reviewer}, then the result goes to the lead`;
299
+ if (route.type === "custom") return `custom route: ${route.description}`;
300
+ return `interactive route, then ${assignmentRouteText(route.then, team)}`;
301
+ }
302
+ function deriveRouteContacts(task, team) {
303
+ if (task.assignee === void 0 || task.route === void 0) return [];
304
+ if (![
305
+ "ready",
306
+ "claimed",
307
+ "in_progress",
308
+ "waiting",
309
+ "needs_human"
310
+ ].includes(task.status)) return [];
311
+ if (task.route.type === "report") return task.route.to === "human" ? ["human"] : [task.route.to];
312
+ if (task.route.type === "review") return [task.assignee, task.route.reviewer];
313
+ if (task.route.type === "interactive") return deriveRouteContacts({
314
+ ...task,
315
+ route: task.route.then
316
+ }, team);
317
+ return [];
318
+ }
319
+ function routeAllowsContact(task, from, to, team) {
320
+ if (task.assignee === void 0 || task.route === void 0 || ![
321
+ "ready",
322
+ "claimed",
323
+ "in_progress",
324
+ "waiting",
325
+ "needs_human"
326
+ ].includes(task.status)) return false;
327
+ if (task.route.type === "report") return from === task.assignee && task.route.to === to;
328
+ if (task.route.type === "review") return from === task.assignee && to === task.route.reviewer || from === task.route.reviewer && to === task.assignee;
329
+ if (task.route.type === "interactive") return routeAllowsContact({
330
+ ...task,
331
+ route: task.route.then
332
+ }, from, to, team);
333
+ return false;
334
+ }
335
+ function taskDocumentPath(workspacePath, teamId, taskId) {
336
+ return path.join(path.resolve(workspacePath), ".dsh-live-teams", "teams", teamId, "tasks", `${taskId}.md`);
337
+ }
338
+ function renderDocument(task, team, fields = {}) {
339
+ const why = fields.why ?? task.why;
340
+ const body = fields.body ?? task.body;
341
+ const taskChecks = fields.checks ?? task.checks ?? [];
342
+ const assignee = task.assignee === void 0 ? "—" : `${memberById(team, task.assignee)?.displayName ?? task.assignee} (${task.assignee})`;
343
+ const checks = taskChecks.length === 0 ? "- —" : taskChecks.map((check) => `- \`${check}\``).join("\n");
344
+ const acceptance = task.acceptance.length === 0 ? "- —" : task.acceptance.map((criterion) => `- ${criterion}`).join("\n");
345
+ const tasks = fields.tasks ?? [];
346
+ const dependsOn = (task.dependsOn ?? []).length === 0 ? "" : `\n\n## Depends on\n${(task.dependsOn ?? []).map((id) => {
347
+ const dependency = tasks.find((entry) => entry.id === id);
348
+ return `- ${id}${dependency === void 0 ? "" : ` — ${dependency.title} (${dependency.status})`}`;
349
+ }).join("\n")}`;
350
+ const paths = (task.paths ?? []).length === 0 ? "" : `\n\n## Paths\nStay inside these; anything else is outside this task's scope.\n${(task.paths ?? []).map((entry) => `- \`${entry}\``).join("\n")}`;
351
+ return `# ${task.title}\n\n- **Task:** ${task.id}\n- **Kind:** ${task.kind}\n- **Assignee:** ${assignee}\n- **Route:** ${routeText(task.route, team)}\n- **Round limit:** ${task.roundLimit}\n\n## Why\n${why === void 0 || why.trim().length === 0 ? "—" : why}\n\n## What to do\n${body === void 0 || body.length === 0 ? "—" : body}${dependsOn}${paths}\n\n## Checks\n${checks}\n\n## Acceptance\n${acceptance}\n\n## Finish\nRun the checks. Report to your lead, or submit for review, according to the route.\nA task without a route is a defect: ask your lead rather than choosing one.\n`;
352
+ }
353
+ var TaskStore = class {
354
+ location;
355
+ workspacePath;
356
+ appendAudit;
357
+ deliverEscalation;
358
+ deliverClosing;
359
+ deliverDirection;
360
+ interruptSession;
361
+ awaitQuiescence;
362
+ quiescenceTimeoutMs;
363
+ changedPaths;
364
+ git;
365
+ migrations;
366
+ workspaceMode;
367
+ snapshotTree;
368
+ /** `null` means "no port": the answer is cached because a store lives with one workspace. */
369
+ resolvedMode;
370
+ constructor(options) {
371
+ this.deliverEscalation = options.deliverEscalation;
372
+ this.deliverClosing = options.deliverClosing;
373
+ this.deliverDirection = options.deliverDirection;
374
+ this.interruptSession = options.interruptSession;
375
+ this.awaitQuiescence = options.awaitQuiescence;
376
+ this.quiescenceTimeoutMs = options.quiescenceTimeoutMs ?? 5e3;
377
+ this.changedPaths = options.changedPaths;
378
+ this.git = options.git;
379
+ this.migrations = options.migrations ?? MIGRATIONS;
380
+ this.workspaceMode = options.workspaceMode;
381
+ this.snapshotTree = options.snapshotTree;
382
+ const workspacePath = path.resolve(options.workspacePath);
383
+ this.workspacePath = workspacePath;
384
+ const directory = path.join(workspacePath, ".dsh-live-teams", "teams", options.teamId);
385
+ this.location = {
386
+ directory,
387
+ file: path.join(directory, "tasks.json"),
388
+ attemptsFile: path.join(directory, "attempts.json"),
389
+ submissionsFile: path.join(directory, "submissions.json"),
390
+ reviewsFile: path.join(directory, "reviews.json"),
391
+ teamFile: path.join(directory, "team.json"),
392
+ tasksDirectory: path.join(directory, "tasks"),
393
+ archiveDirectory: path.join(directory, "archive"),
394
+ snapshotsDirectory: path.join(directory, "snapshots"),
395
+ blobsDirectory: path.join(directory, "artifacts", "blobs")
396
+ };
397
+ this.appendAudit = options.appendAudit ?? (async () => void 0);
398
+ }
399
+ get filePath() {
400
+ return this.location.file;
401
+ }
402
+ get directoryPath() {
403
+ return this.location.directory;
404
+ }
405
+ async open() {
406
+ return withProcessLock(this.location.teamFile, async () => this.readOrCreateLocked());
407
+ }
408
+ async listTasks(options = {}) {
409
+ const includeArchived = typeof options === "boolean" ? options : options.includeArchived === true;
410
+ return clone(await withProcessLock(this.location.teamFile, async () => {
411
+ const state = await this.readOrCreateLocked();
412
+ const reviews = await this.readReviewsIfPresentLocked();
413
+ const latestRound = /* @__PURE__ */ new Map();
414
+ for (const round of reviews.rounds) {
415
+ const prior = latestRound.get(round.taskId);
416
+ if (prior === void 0 || round.round > prior) latestRound.set(round.taskId, round.round);
417
+ }
418
+ const visible = includeArchived ? state.tasks : state.tasks.filter((task) => task.archivedPath === void 0);
419
+ return (await Promise.all(visible.map(async (task) => {
420
+ const round = latestRound.get(task.id);
421
+ const withRound = round === void 0 ? task : {
422
+ ...task,
423
+ round
424
+ };
425
+ const documentPath = task.archivedPath === void 0 ? this.documentPath(task.id) : this.archivedPathOf(task.archivedPath);
426
+ return await fileExists(documentPath) ? withRound : {
427
+ ...withRound,
428
+ documentMissing: true
429
+ };
430
+ }))).sort((left, right) => right.updatedAt - left.updatedAt || right.createdAt - left.createdAt || right.revision - left.revision || left.id.localeCompare(right.id)).slice(0, MAX_TASKS);
431
+ }));
432
+ }
433
+ async readTask(taskId) {
434
+ if (!candidateId(taskId)) fail("invalid task id");
435
+ return withProcessLock(this.location.teamFile, async () => {
436
+ const task = (await this.readOrCreateLocked()).tasks.find((entry) => entry.id === taskId);
437
+ if (task === void 0) fail(`task not found: ${taskId}`);
438
+ const driftOf = async (submission) => {
439
+ const artifact = submission.artifact;
440
+ const checks = artifact.type === "report" ? [{
441
+ path: artifact.bodyRef,
442
+ expected: artifact.bodyDigest
443
+ }] : (artifact.blobs ?? []).map((blob) => ({
444
+ path: blob.path,
445
+ expected: blob.digest
446
+ }));
447
+ const drift = [];
448
+ for (const entry of checks) {
449
+ const absolute = path.resolve(this.workspacePath, entry.path);
450
+ let bytes;
451
+ try {
452
+ bytes = await promises.readFile(absolute);
453
+ } catch {
454
+ bytes = void 0;
455
+ }
456
+ const actual = bytes === void 0 ? null : createHash("sha256").update(bytes).digest("hex");
457
+ if (actual !== entry.expected) drift.push({
458
+ path: entry.path,
459
+ expected: entry.expected,
460
+ actual
461
+ });
462
+ }
463
+ return drift;
464
+ };
465
+ const documentPath = task.archivedPath === void 0 ? this.documentPath(taskId) : this.archivedPathOf(task.archivedPath);
466
+ let document;
467
+ try {
468
+ document = await promises.readFile(documentPath, "utf8");
469
+ } catch (error) {
470
+ throw new LiveTeamsError(ERROR_CODES.CAPABILITY_UNAVAILABLE, `task ${taskId} document is missing at ${documentPath}; ask the lead to restore the task document`, { cause: error });
471
+ }
472
+ const submissions = await Promise.all((await this.readSubmissionsIfPresentLocked()).submissions.filter((entry) => entry.taskId === taskId).map(async (submission) => {
473
+ const drift = await driftOf(submission);
474
+ return drift.length === 0 ? submission : {
475
+ ...submission,
476
+ drift
477
+ };
478
+ }));
479
+ const rounds = (await this.readReviewsIfPresentLocked()).rounds.filter((entry) => entry.taskId === taskId);
480
+ const attempts = (await this.readAttemptsIfPresentLocked()).attempts.filter((entry) => entry.taskId === taskId);
481
+ return {
482
+ task: clone(task),
483
+ document,
484
+ submissions: clone(submissions),
485
+ rounds: clone(rounds),
486
+ attempts: clone(attempts)
487
+ };
488
+ });
489
+ }
490
+ async readAttempts() {
491
+ return withProcessLock(this.location.teamFile, async () => this.readAttemptsIfPresentLocked());
492
+ }
493
+ async readSubmissions() {
494
+ return withProcessLock(this.location.teamFile, async () => this.readSubmissionsIfPresentLocked());
495
+ }
496
+ async readReviews() {
497
+ return withProcessLock(this.location.teamFile, async () => this.readReviewsIfPresentLocked());
498
+ }
499
+ async activeTaskForMember(memberId) {
500
+ const active = (await withProcessLock(this.location.teamFile, async () => this.readOrCreateLocked())).tasks.filter((task) => task.assignee === memberId && [
501
+ "ready",
502
+ "claimed",
503
+ "in_progress",
504
+ "waiting",
505
+ "needs_human"
506
+ ].includes(task.status));
507
+ active.sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id));
508
+ return active[0] === void 0 ? void 0 : clone(active[0]);
509
+ }
510
+ activeTaskForMemberSync(memberId) {
511
+ try {
512
+ const active = parseTasksState(JSON.parse(readFileSync(this.location.file, "utf8")), this.teamId, this.location.archiveDirectory).tasks.filter((task) => task.assignee === memberId && [
513
+ "ready",
514
+ "claimed",
515
+ "in_progress",
516
+ "waiting",
517
+ "needs_human"
518
+ ].includes(task.status));
519
+ active.sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id));
520
+ return active[0] === void 0 ? void 0 : clone(active[0]);
521
+ } catch (error) {
522
+ if (error.code === "ENOENT") return void 0;
523
+ throw error;
524
+ }
525
+ }
526
+ /**
527
+ * The latest attempt of one task, read synchronously for context assembly: the member
528
+ * has to know that a human wrote into its Session mid-flight (B31).
529
+ */
530
+ latestAttemptForTaskSync(taskId) {
531
+ try {
532
+ const attempts = parseAttemptsState(JSON.parse(readFileSync(this.location.attemptsFile, "utf8")), this.teamId).attempts.filter((attempt) => attempt.taskId === taskId);
533
+ attempts.sort((left, right) => right.generation - left.generation);
534
+ return attempts[0] === void 0 ? void 0 : clone(attempts[0]);
535
+ } catch (error) {
536
+ if (error.code === "ENOENT") return void 0;
537
+ throw error;
538
+ }
539
+ }
540
+ /**
541
+ * Every task, read synchronously for context assembly. The authorization gate
542
+ * scans all tasks — a reviewer holds a path to a task it is not the assignee of —
543
+ * so the line a member reads about where it may send has to see the same set.
544
+ */
545
+ listTasksSync() {
546
+ try {
547
+ const state = parseTasksState(JSON.parse(readFileSync(this.location.file, "utf8")), this.teamId, this.location.archiveDirectory);
548
+ return clone(state.tasks.filter((task) => task.archivedPath === void 0));
549
+ } catch (error) {
550
+ if (error.code === "ENOENT") return [];
551
+ throw error;
552
+ }
553
+ }
554
+ /**
555
+ * Close attempts whose task has already ended (F5 of the first live run).
556
+ *
557
+ * Both acceptance paths are supposed to close the attempt, but "supposed to" is how
558
+ * the member stayed busy forever: a stale active attempt blocks every later task for
559
+ * that member and keeps the intervention observer watching a finished Session. This
560
+ * heals the class rather than the instance — a task in a terminal status cannot have
561
+ * work in flight.
562
+ */
563
+ async closeTerminalAttempts() {
564
+ return await withProcessLock(this.location.teamFile, async () => {
565
+ const attempts = await this.readAttemptsIfPresentLocked();
566
+ const tasks = await this.readTasksIfPresentLocked();
567
+ const terminal = new Map(tasks.tasks.filter((task) => task.status === "completed" || task.status === "cancelled" || task.status === "failed").map((task) => [task.id, task.status]));
568
+ const stale = attempts.attempts.filter((attempt) => attempt.status === "active" && terminal.has(attempt.taskId));
569
+ if (stale.length === 0) return { closed: [] };
570
+ const closed = stale.map((attempt) => ({
571
+ attemptId: attempt.id,
572
+ taskId: attempt.taskId,
573
+ memberId: attempt.memberId,
574
+ status: terminal.get(attempt.taskId) === "completed" ? "completed" : "failed"
575
+ }));
576
+ const byId = new Map(closed.map((entry) => [entry.attemptId, entry.status]));
577
+ const nextAttempts = {
578
+ ...attempts,
579
+ schemaVersion: 1,
580
+ revision: attempts.revision + 1,
581
+ attempts: attempts.attempts.map((attempt) => byId.has(attempt.id) ? {
582
+ ...attempt,
583
+ status: byId.get(attempt.id),
584
+ revision: attempt.revision + 1
585
+ } : attempt)
586
+ };
587
+ await atomicWriteJson(this.location.attemptsFile, nextAttempts);
588
+ for (const entry of closed) await this.appendAudit({
589
+ kind: "attempt/closed-stale",
590
+ taskId: entry.taskId,
591
+ attemptId: entry.attemptId,
592
+ memberId: entry.memberId,
593
+ status: entry.status,
594
+ actor: "reconciler",
595
+ at: Date.now(),
596
+ revision: nextAttempts.revision
597
+ });
598
+ return { closed };
599
+ });
600
+ }
601
+ /**
602
+ * Record that a human wrote into this member's Session mid-attempt (B31). Idempotent:
603
+ * the first observation wins, and the task revision at that moment is stored so that a
604
+ * later formal restatement of the contract resolves the mark without a second write.
605
+ */
606
+ async markHumanIntervention(attemptId, at) {
607
+ if (!candidateId(attemptId) || !Number.isInteger(at)) fail("invalid human intervention request");
608
+ return await withProcessLock(this.location.teamFile, async () => {
609
+ const attempts = await this.readAttemptsIfPresentLocked();
610
+ const index = attempts.attempts.findIndex((entry) => entry.id === attemptId);
611
+ if (index < 0) fail(`unknown attempt: ${attemptId}`);
612
+ const current = attempts.attempts[index];
613
+ if (current.humanIntervenedAt !== void 0) return {
614
+ attempt: clone(current),
615
+ changed: false
616
+ };
617
+ const contractRevision = await this.contractRevisionLocked(current.taskId);
618
+ const next = {
619
+ ...current,
620
+ humanIntervenedAt: at,
621
+ ...contractRevision === void 0 ? {} : { humanIntervenedContractRevision: contractRevision },
622
+ revision: current.revision + 1
623
+ };
624
+ const nextAttempts = {
625
+ ...attempts,
626
+ revision: attempts.revision + 1,
627
+ attempts: attempts.attempts.map((entry) => entry.id === attemptId ? next : entry)
628
+ };
629
+ await atomicWriteJson(this.location.attemptsFile, nextAttempts);
630
+ await this.appendAudit({
631
+ kind: "attempt/human-intervened",
632
+ taskId: current.taskId,
633
+ attemptId,
634
+ memberId: current.memberId,
635
+ at,
636
+ actor: "reconciler",
637
+ revision: nextAttempts.revision
638
+ });
639
+ return {
640
+ attempt: clone(next),
641
+ changed: true
642
+ };
643
+ });
644
+ }
645
+ async contractRevisionLocked(taskId) {
646
+ const task = (await this.readTasksIfPresentLocked()).tasks.find((entry) => entry.id === taskId);
647
+ return task === void 0 ? void 0 : task.contractRevision ?? 0;
648
+ }
649
+ async submitTask(request) {
650
+ if (!candidateId(request.taskId) || request.memberId.length === 0 || request.sessionId.length === 0 || !Number.isInteger(request.bindingGeneration)) fail("invalid submission request");
651
+ if (typeof request.summary !== "string" || request.summary.trim().length === 0) fail("submission summary must be non-empty");
652
+ return withProcessLock(this.location.teamFile, async () => {
653
+ const tasks = await this.readTasksIfPresentLocked();
654
+ const task = tasks.tasks.find((entry) => entry.id === request.taskId);
655
+ if (task === void 0) fail(`task not found: ${request.taskId}`);
656
+ if (task.assignee !== request.memberId) fail("only the task assignee may submit work");
657
+ if (task.status !== "claimed" && task.status !== "in_progress") fail(`task is not claimable for submission; current status is ${task.status}`);
658
+ const attempts = await this.readAttemptsIfPresentLocked();
659
+ const attempt = task.activeAttemptId === void 0 ? void 0 : attempts.attempts.find((entry) => entry.id === task.activeAttemptId && entry.status === "active");
660
+ if (attempt === void 0 || attempt.memberId !== request.memberId || attempt.sessionId !== request.sessionId || attempt.bindingGeneration !== request.bindingGeneration) fail("submission requires the active attempt binding");
661
+ const artifact = await this.freezeArtifact(request.artifact);
662
+ const submissions = await this.readSubmissionsIfPresentLocked();
663
+ const previous = submissions.submissions.filter((entry) => entry.taskId === task.id).sort((left, right) => left.createdAt - right.createdAt).at(-1);
664
+ const lastRound = (await this.readReviewsIfPresentLocked()).rounds.filter((entry) => entry.taskId === task.id).sort((left, right) => left.round - right.round).at(-1);
665
+ if (previous !== void 0 && lastRound?.verdict === "needs_revision") {
666
+ if (previous.artifact.type === "report" && artifact.type === "report" ? previous.artifact.bodyDigest === artifact.bodyDigest : previous.artifact.type === "files" && artifact.type === "files" ? previous.artifact.manifestDigest === artifact.manifestDigest : false) fail("this submission is identical to the one the reviewer sent back; change something and say what, or escalate to the lead instead of spending another round");
667
+ }
668
+ const now = Date.now();
669
+ const scope = await this.checkScope(task, tasks.tasks, attempts.attempts, artifact, attempt);
670
+ let git;
671
+ if (this.git !== void 0 && await this.mode() === "git" && CODING_KINDS.has(task.kind)) {
672
+ if (attempt.gitBase === void 0) fail("the attempt has no recorded Git base; the submission cannot be frozen for review");
673
+ const commitPaths = artifact.type === "files" ? artifact.paths : (scope?.changed ?? []).filter((entry) => !(scope?.unexpected ?? []).includes(entry));
674
+ if (commitPaths.length === 0) fail("nothing changed under the declaration and the submission names no files; there is nothing to review");
675
+ let base = {
676
+ ref: attempt.gitBase.ref,
677
+ sha: attempt.gitBase.baseTipSha
678
+ };
679
+ let rebasedFrom;
680
+ const tip = await this.git.tip(this.workspacePath, base.ref).catch(() => void 0);
681
+ if (tip !== void 0 && tip !== base.sha) {
682
+ const moved = await this.git.changedBetween(this.workspacePath, base.sha, tip).catch(() => void 0);
683
+ if (moved === void 0 || moved.some((entry) => {
684
+ const path = normalizeScopePath(entry) ?? entry;
685
+ return commitPaths.some((target) => scopesIntersect(literalPrefix(target), path));
686
+ })) fail(`the base moved since this attempt started (${base.ref} is at ${tip}, the attempt began at ${base.sha}) and the commits in between touch the same paths; ask the lead to re-plan this task rather than review work that cannot land`);
687
+ rebasedFrom = base.sha;
688
+ base = {
689
+ ref: base.ref,
690
+ sha: tip
691
+ };
692
+ }
693
+ const member = (await this.readTeamLocked()).members.find((entry) => entry.memberId === attempt.memberId);
694
+ const message = [
695
+ `${task.id}: ${task.title}`,
696
+ "",
697
+ request.summary.trim(),
698
+ "",
699
+ `Attempt: ${attempt.id} (generation ${attempt.generation})`,
700
+ `Member: ${attempt.memberId}`
701
+ ].join("\n");
702
+ const built = await this.git.commit(this.workspacePath, base, {
703
+ paths: commitPaths,
704
+ message,
705
+ author: {
706
+ name: member?.displayName ?? attempt.memberId,
707
+ email: `${attempt.memberId}@live-teams.local`
708
+ }
709
+ });
710
+ if (built === void 0) fail("the changed paths could not be frozen into a commit; nothing was recorded");
711
+ git = {
712
+ baseRef: base.ref,
713
+ baseTipSha: base.sha,
714
+ ...rebasedFrom === void 0 ? {} : { rebasedFrom },
715
+ headSha: built.headSha,
716
+ commitPaths: [...built.commitPaths],
717
+ ...built.mergeBaseSha === void 0 ? {} : { mergeBaseSha: built.mergeBaseSha }
718
+ };
719
+ if (!await this.git.pin(this.workspacePath, `refs/live-teams/${task.id}/${submissionGitRefSuffix(attempt.generation, git.headSha)}`, git.headSha).catch(() => false)) fail("the reviewed commit could not be pinned in the repository; nothing was recorded");
720
+ }
721
+ if (git !== void 0 && scope !== void 0) scope.committed = [...git.commitPaths];
722
+ const submission = {
723
+ id: `submission-${randomUUID()}`,
724
+ taskId: task.id,
725
+ attemptId: attempt.id,
726
+ attemptGeneration: attempt.generation,
727
+ artifact,
728
+ ...git === void 0 ? {} : { git },
729
+ summary: request.summary,
730
+ checks: request.checks.map(validateCheck),
731
+ ...scope === void 0 ? {} : { scope },
732
+ createdAt: now,
733
+ revision: submissions.revision + 1
734
+ };
735
+ const nextSubmissions = {
736
+ ...submissions,
737
+ schemaVersion: 1,
738
+ revision: submissions.revision + 1,
739
+ submissions: [...submissions.submissions, submission]
740
+ };
741
+ const nextTask = {
742
+ ...task,
743
+ latestSubmissionId: submission.id,
744
+ ...task.route?.type === "report" ? { status: "waiting" } : {},
745
+ updatedAt: now,
746
+ revision: task.revision + 1
747
+ };
748
+ const nextTasks = {
749
+ ...tasks,
750
+ revision: tasks.revision + 1,
751
+ tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
752
+ };
753
+ await atomicWriteJson(this.location.submissionsFile, nextSubmissions);
754
+ try {
755
+ await atomicWriteJson(this.location.file, nextTasks);
756
+ } catch (error) {
757
+ await atomicWriteJson(this.location.submissionsFile, submissions);
758
+ throw error;
759
+ }
760
+ await this.appendAudit({
761
+ kind: "submission/created",
762
+ taskId: task.id,
763
+ submissionId: submission.id,
764
+ attemptId: attempt.id,
765
+ actor: request.memberId,
766
+ at: now,
767
+ revision: nextSubmissions.revision
768
+ });
769
+ if (scope?.state === "violation") await this.appendAudit({
770
+ kind: "submission/scope-violation",
771
+ taskId: task.id,
772
+ submissionId: submission.id,
773
+ attemptId: attempt.id,
774
+ declared: scope.declared,
775
+ unexpected: scope.unexpected ?? [],
776
+ actor: request.memberId,
777
+ at: now,
778
+ revision: submissions.revision + 1
779
+ });
780
+ if (scope?.state === "shared-tree") await this.appendAudit({
781
+ kind: "submission/scope-shared-tree",
782
+ taskId: task.id,
783
+ submissionId: submission.id,
784
+ attemptId: attempt.id,
785
+ declared: scope.declared,
786
+ unexpected: scope.unexpected ?? [],
787
+ sharedTree: scope.sharedTree ?? [],
788
+ actor: request.memberId,
789
+ at: now,
790
+ revision: submissions.revision + 1
791
+ });
792
+ return { submission: clone(submission) };
793
+ });
794
+ }
795
+ async acceptTask(request) {
796
+ if (!candidateId(request.taskId) || request.actor.length === 0) fail("invalid accept request");
797
+ if (request.note !== void 0 && typeof request.note !== "string") fail("acceptance note must be a string");
798
+ let closing;
799
+ const result = await withProcessLock(this.location.teamFile, async () => {
800
+ const tasks = await this.readTasksIfPresentLocked();
801
+ const task = tasks.tasks.find((entry) => entry.id === request.taskId);
802
+ if (task === void 0) fail(`task not found: ${request.taskId}`);
803
+ const team = await this.readTeamLocked();
804
+ if (!isLeadOrHuman(team, request.actor)) fail(`only the lead may accept tasks; ask ${leadMemberIdForMessage(team)}`);
805
+ if (task.status === "completed") fail("task is already completed");
806
+ if (task.archivedPath !== void 0) fail("task is already archived");
807
+ if (task.status !== "waiting" && task.status !== "needs_human") fail(`task cannot be accepted in status ${task.status}`);
808
+ const attempts = await this.readAttemptsIfPresentLocked();
809
+ const activeAttempt = task.activeAttemptId === void 0 ? void 0 : attempts.attempts.find((entry) => entry.id === task.activeAttemptId && entry.status === "active");
810
+ if (activeAttempt === void 0) fail("task has no active attempt");
811
+ if (task.assignee === void 0 || activeAttempt.memberId !== task.assignee) fail("task has no assignee attempt");
812
+ const now = Date.now();
813
+ const accepted = (await this.readSubmissionsIfPresentLocked()).submissions.find((entry) => entry.id === task.latestSubmissionId);
814
+ const afterIntegration = accepted === void 0 ? task : await this.integrateSubmission(accepted, task);
815
+ const currentTask = afterIntegration.integrationPending === void 0 ? afterIntegration : {
816
+ ...afterIntegration,
817
+ integrationPending: void 0
818
+ };
819
+ const archivedPath = path.join("archive", `${now}-${task.id}.md`);
820
+ const archiveTarget = path.join(this.location.directory, archivedPath);
821
+ const sourcePath = this.documentPath(task.id);
822
+ const document = await promises.readFile(sourcePath);
823
+ if (await fileExists(archiveTarget)) fail("archive target already exists");
824
+ const { integrationPending: _finished, ...settled } = currentTask;
825
+ const nextTask = {
826
+ ...settled,
827
+ status: "completed",
828
+ ...task.latestSubmissionId === void 0 ? {} : { acceptedSubmissionId: task.latestSubmissionId },
829
+ archivedPath,
830
+ contractRevision: (task.contractRevision ?? 0) + 1,
831
+ updatedAt: now,
832
+ revision: currentTask.revision + 1
833
+ };
834
+ const nextAttempts = {
835
+ ...attempts,
836
+ schemaVersion: 1,
837
+ revision: attempts.revision + 1,
838
+ attempts: attempts.attempts.map((entry) => entry.id === activeAttempt.id ? {
839
+ ...entry,
840
+ status: "completed",
841
+ revision: entry.revision + 1
842
+ } : entry)
843
+ };
844
+ const nextTasks = {
845
+ ...tasks,
846
+ schemaVersion: 1,
847
+ revision: tasks.revision + 1,
848
+ tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
849
+ };
850
+ try {
851
+ await atomicWrite(archiveTarget, document);
852
+ await atomicWriteJson(this.location.attemptsFile, nextAttempts);
853
+ await atomicWriteJson(this.location.file, nextTasks);
854
+ await promises.unlink(sourcePath);
855
+ } catch (error) {
856
+ await atomicWriteJson(this.location.file, tasks).catch(() => void 0);
857
+ await atomicWriteJson(this.location.attemptsFile, attempts).catch(() => void 0);
858
+ await promises.unlink(archiveTarget).catch(() => void 0);
859
+ await atomicWrite(sourcePath, document).catch(() => void 0);
860
+ throw error;
861
+ }
862
+ await this.appendAudit({
863
+ kind: "task/accepted",
864
+ taskId: task.id,
865
+ actor: request.actor,
866
+ acceptedSubmissionId: nextTask.acceptedSubmissionId,
867
+ archivedPath,
868
+ ...request.note === void 0 ? {} : { note: request.note },
869
+ at: now,
870
+ revision: nextTasks.revision
871
+ });
872
+ await this.appendAudit({
873
+ kind: "task/archived",
874
+ taskId: task.id,
875
+ actor: request.actor,
876
+ archivedPath,
877
+ at: now,
878
+ revision: nextTasks.revision
879
+ });
880
+ closing = {
881
+ assignee: activeAttempt.memberId,
882
+ taskId: task.id,
883
+ ...request.note === void 0 ? {} : { note: request.note }
884
+ };
885
+ return {
886
+ task: clone(nextTask),
887
+ archivedPath
888
+ };
889
+ });
890
+ if (closing !== void 0) try {
891
+ await this.deliverClosing?.(closing);
892
+ } catch {}
893
+ return result;
894
+ }
895
+ async reviseTask(request) {
896
+ if (!candidateId(request.taskId) || request.actor.length === 0) fail("invalid revise request");
897
+ if (typeof request.instructions !== "string" || request.instructions.trim().length === 0) fail("revision instructions must be non-empty");
898
+ const instructions = request.instructions.trim();
899
+ let direction;
900
+ const result = await withProcessLock(this.location.teamFile, async () => {
901
+ const tasks = await this.readTasksIfPresentLocked();
902
+ const task = tasks.tasks.find((entry) => entry.id === request.taskId);
903
+ if (task === void 0) fail(`task not found: ${request.taskId}`);
904
+ const team = await this.readTeamLocked();
905
+ if (!isLeadOrHuman(team, request.actor)) fail(`only the lead may revise tasks; ask ${leadMemberIdForMessage(team)}`);
906
+ if (task.status !== "waiting" && task.status !== "needs_human") fail(`task cannot be revised in status ${task.status}`);
907
+ if (task.assignee === void 0) fail("task has no assignee");
908
+ const attempts = await this.readAttemptsIfPresentLocked();
909
+ const activeAttempt = task.activeAttemptId === void 0 ? void 0 : attempts.attempts.find((entry) => entry.id === task.activeAttemptId && entry.status === "active");
910
+ if (activeAttempt === void 0 || activeAttempt.memberId !== task.assignee) fail("task has no active attempt");
911
+ const now = Date.now();
912
+ const nextTask = {
913
+ ...task,
914
+ status: "in_progress",
915
+ contractRevision: (task.contractRevision ?? 0) + 1,
916
+ updatedAt: now,
917
+ revision: task.revision + 1
918
+ };
919
+ const nextTasks = {
920
+ ...tasks,
921
+ schemaVersion: 1,
922
+ revision: tasks.revision + 1,
923
+ tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
924
+ };
925
+ await atomicWriteJson(this.location.file, nextTasks);
926
+ await this.appendAudit({
927
+ kind: "task/revised",
928
+ taskId: task.id,
929
+ actor: request.actor,
930
+ instructions,
931
+ at: now,
932
+ revision: nextTasks.revision
933
+ });
934
+ direction = {
935
+ assignee: task.assignee,
936
+ taskId: task.id,
937
+ instructions
938
+ };
939
+ return { task: clone(nextTask) };
940
+ });
941
+ if (direction !== void 0) try {
942
+ await this.deliverDirection?.(direction);
943
+ } catch {}
944
+ return result;
945
+ }
946
+ async reviewTask(request) {
947
+ if (!candidateId(request.taskId) || !candidateId(request.submissionId) || request.reviewerMemberId.length === 0 || !REVIEW_VERDICTS.includes(request.verdict)) fail("invalid review request");
948
+ let escalation;
949
+ const result = await withProcessLock(this.location.teamFile, async () => {
950
+ const tasks = await this.readTasksIfPresentLocked();
951
+ const task = tasks.tasks.find((entry) => entry.id === request.taskId);
952
+ if (task === void 0) fail(`task not found: ${request.taskId}`);
953
+ if (task.status === "completed" || task.status === "waiting" || task.status === "cancelled" || task.status === "failed") fail(`task cannot be reviewed in status ${task.status}`);
954
+ if (task.route?.type !== "review" || task.route.reviewer !== request.reviewerMemberId) fail("only the task review-route member may review it");
955
+ if (task.assignee === request.reviewerMemberId) fail("the assignee cannot review their own work");
956
+ if (task.latestSubmissionId !== request.submissionId) fail("review must target the latest submission");
957
+ const attempts = await this.readAttemptsIfPresentLocked();
958
+ const submission = (await this.readSubmissionsIfPresentLocked()).submissions.find((entry) => entry.id === request.submissionId && entry.taskId === task.id);
959
+ if (submission === void 0) fail("submission not found for task");
960
+ const authoring = attempts.attempts.find((entry) => entry.id === submission.attemptId);
961
+ if (authoring === void 0 || authoring.generation !== submission.attemptGeneration) fail("submission was produced by a superseded attempt");
962
+ const reviews = await this.readReviewsIfPresentLocked();
963
+ if (reviews.rounds.some((entry) => entry.submissionId === submission.id)) fail("submission already has a terminal review");
964
+ const findings = (request.findings ?? []).map(validateFinding);
965
+ const evidence = (request.evidence ?? []).map(validateCheck);
966
+ if (request.verdict === "pass" && findings.some((finding) => finding.severity === "blocker" || finding.severity === "high")) fail("PASS is refused while blocker or high findings remain unresolved");
967
+ if (request.verdict === "pass" && evidence.length === 0) fail("PASS needs at least one recorded check: run the verification and record the command with its output, so the verdict can be reproduced later");
968
+ const priorRounds = reviews.rounds.filter((entry) => entry.taskId === task.id);
969
+ const roundNumber = priorRounds.length + 1;
970
+ if (request.verdict === "needs_revision" && priorRounds.filter((entry) => entry.verdict === "needs_revision").length >= task.roundLimit) fail(`round limit ${task.roundLimit} reached; escalate to the lead instead`);
971
+ const now = Date.now();
972
+ const round = {
973
+ id: `review-${randomUUID()}`,
974
+ taskId: task.id,
975
+ submissionId: submission.id,
976
+ reviewerMemberId: request.reviewerMemberId,
977
+ round: roundNumber,
978
+ verdict: request.verdict,
979
+ findings,
980
+ evidence,
981
+ createdAt: now,
982
+ revision: reviews.revision + 1
983
+ };
984
+ const nextReviews = {
985
+ ...reviews,
986
+ schemaVersion: 1,
987
+ revision: reviews.revision + 1,
988
+ rounds: [...reviews.rounds, round]
989
+ };
990
+ const reviewedTask = request.verdict === "pass" ? await this.integrateSubmission(submission, task) : task;
991
+ const { integrationPending: _pendingDrop, ...reviewedSettled } = reviewedTask;
992
+ let nextTask = {
993
+ ...reviewedSettled,
994
+ updatedAt: now,
995
+ revision: reviewedTask.revision + 1
996
+ };
997
+ if (request.verdict === "pass") nextTask = {
998
+ ...nextTask,
999
+ acceptedSubmissionId: submission.id,
1000
+ status: "completed",
1001
+ contractRevision: (task.contractRevision ?? 0) + 1
1002
+ };
1003
+ else if (request.verdict === "escalate") nextTask = {
1004
+ ...nextTask,
1005
+ status: "waiting"
1006
+ };
1007
+ let archivedPath;
1008
+ if (request.verdict === "pass" && task.archivedPath === void 0) {
1009
+ archivedPath = path.join("archive", `${now}-${task.id}.md`);
1010
+ const target = path.join(this.location.directory, archivedPath);
1011
+ if (await fileExists(target)) fail("archive target already exists");
1012
+ await atomicWrite(target, await promises.readFile(this.documentPath(task.id)));
1013
+ nextTask = {
1014
+ ...nextTask,
1015
+ archivedPath
1016
+ };
1017
+ }
1018
+ const closedAttempts = request.verdict === "pass" && task.activeAttemptId !== void 0 ? {
1019
+ ...attempts,
1020
+ schemaVersion: 1,
1021
+ revision: attempts.revision + 1,
1022
+ attempts: attempts.attempts.map((entry) => entry.id === task.activeAttemptId && entry.status === "active" ? {
1023
+ ...entry,
1024
+ status: "completed",
1025
+ revision: entry.revision + 1
1026
+ } : entry)
1027
+ } : void 0;
1028
+ await atomicWriteJson(this.location.reviewsFile, nextReviews);
1029
+ if (closedAttempts !== void 0) await atomicWriteJson(this.location.attemptsFile, closedAttempts);
1030
+ try {
1031
+ await atomicWriteJson(this.location.file, {
1032
+ ...tasks,
1033
+ revision: tasks.revision + 1,
1034
+ tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
1035
+ });
1036
+ } catch (error) {
1037
+ if (archivedPath !== void 0) await promises.unlink(archivedPath).catch(() => void 0);
1038
+ await atomicWriteJson(this.location.reviewsFile, reviews);
1039
+ throw error;
1040
+ }
1041
+ if (archivedPath !== void 0) {
1042
+ await promises.unlink(this.documentPath(task.id)).catch(() => void 0);
1043
+ await this.appendAudit({
1044
+ kind: "task/archived",
1045
+ taskId: task.id,
1046
+ actor: request.reviewerMemberId,
1047
+ archivedPath,
1048
+ at: now,
1049
+ revision: tasks.revision + 1
1050
+ });
1051
+ }
1052
+ await this.appendAudit({
1053
+ kind: "review/round",
1054
+ taskId: task.id,
1055
+ submissionId: submission.id,
1056
+ reviewerMemberId: request.reviewerMemberId,
1057
+ verdict: request.verdict,
1058
+ round: round.round,
1059
+ at: now,
1060
+ revision: nextReviews.revision
1061
+ });
1062
+ if (request.verdict === "pass") await this.appendAudit({
1063
+ kind: "task/accepted",
1064
+ taskId: task.id,
1065
+ submissionId: submission.id,
1066
+ actor: request.reviewerMemberId,
1067
+ at: now,
1068
+ revision: tasks.revision + 1
1069
+ });
1070
+ if (request.verdict === "escalate") {
1071
+ const leadMemberId = leadMemberIdForMessage(await this.readTeamLocked());
1072
+ const problem = findings.map((finding) => finding.problem).join("; ") || "reviewer escalated without a finding";
1073
+ const tried = findings.map((finding) => finding.requiredFix).join("; ") || "review evidence recorded";
1074
+ await this.appendAudit({
1075
+ kind: "review/escalated",
1076
+ taskId: task.id,
1077
+ submissionId: submission.id,
1078
+ reviewerMemberId: request.reviewerMemberId,
1079
+ leadMemberId,
1080
+ problem,
1081
+ tried,
1082
+ at: now,
1083
+ revision: nextReviews.revision
1084
+ });
1085
+ escalation = {
1086
+ leadMemberId,
1087
+ taskId: task.id,
1088
+ submissionId: submission.id,
1089
+ round,
1090
+ reviewerMemberId: request.reviewerMemberId,
1091
+ problem,
1092
+ tried
1093
+ };
1094
+ }
1095
+ return {
1096
+ round: clone(round),
1097
+ task: clone(nextTask)
1098
+ };
1099
+ });
1100
+ if (escalation !== void 0) await this.deliverEscalation?.(escalation);
1101
+ return result;
1102
+ }
1103
+ /**
1104
+ * What the declaration was worth at submission time. Only a writing task that
1105
+ * declared paths is checked: with nothing declared there is nothing to violate, and
1106
+ * an unobservable workspace is reported as `unchecked` rather than as clean (B30).
1107
+ */
1108
+ /**
1109
+ * Integrate a submission's commit into its base, refusing when the base moved.
1110
+ *
1111
+ * Nothing is checked out and nothing is merged: the working tree already holds the work, so
1112
+ * integration is one compare-and-swap ref update. `STALE_BASE` is a refusal — nothing is written,
1113
+ * and the decision has to be made again against the current base (ADR 0006).
1114
+ */
1115
+ async integrateSubmission(submission, task) {
1116
+ const git = submission.git;
1117
+ if (git === void 0 || this.git === void 0) return task;
1118
+ const current = await this.writeIntegrationPendingLocked(task, git) ?? task;
1119
+ const outcome = await this.git.integrate(this.workspacePath, git.baseRef, git.headSha, git.baseTipSha, git.commitPaths).catch(() => "stale");
1120
+ if (outcome !== "integrated") await this.clearIntegrationPendingLocked(current);
1121
+ if (outcome === "index-failed") throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `the base is at the reviewed commit ${String(git.headSha).slice(0, 12)} but the index could not be moved with it; retry the acceptance. If it persists, run: git reset -- ${git.commitPaths.join(" ")}`, { auditDetail: "index repair failed" });
1122
+ if (outcome === "stale") {
1123
+ const tip = await this.git.tip(this.workspacePath, git.baseRef).catch(() => void 0);
1124
+ throw new LiveTeamsError(ERROR_CODES.STALE_BASE, `the base moved since the review: ${git.baseRef} is at ${tip ?? "an unreadable tip"}, the reviewed base was ${git.baseTipSha}. Refresh the decision against the current base before accepting ${task.id}`, { auditDetail: "stale base" });
1125
+ }
1126
+ return current;
1127
+ }
1128
+ /**
1129
+ * Record the intent to integrate, and return the task as it now stands.
1130
+ *
1131
+ * `Locked` on purpose: the callers (both acceptance paths) already hold the team lock, and taking it
1132
+ * again deadlocks the process — which is exactly what the git suite reported, as a thirty-second
1133
+ * hang rather than a failure.
1134
+ */
1135
+ async writeIntegrationPendingLocked(task, git) {
1136
+ const state = await this.readOrCreateLocked();
1137
+ const current = state.tasks.find((entry) => entry.id === task.id);
1138
+ if (current === void 0 || current.integrationPending !== void 0) return current;
1139
+ const next = {
1140
+ ...current,
1141
+ integrationPending: {
1142
+ baseRef: git.baseRef,
1143
+ headSha: git.headSha,
1144
+ baseTipSha: git.baseTipSha,
1145
+ at: Date.now()
1146
+ },
1147
+ revision: current.revision + 1
1148
+ };
1149
+ await atomicWriteJson(this.location.file, {
1150
+ ...state,
1151
+ revision: state.revision + 1,
1152
+ tasks: state.tasks.map((entry) => entry.id === task.id ? next : entry)
1153
+ });
1154
+ return next;
1155
+ }
1156
+ /** Forget the intent: either the integration did not happen, or the caller is about to finish it. */
1157
+ async clearIntegrationPendingLocked(task) {
1158
+ if (task.integrationPending === void 0) return;
1159
+ const state = await this.readOrCreateLocked();
1160
+ const current = state.tasks.find((entry) => entry.id === task.id);
1161
+ if (current === void 0 || current.integrationPending === void 0) return;
1162
+ const { integrationPending: _dropped, ...withoutPending } = current;
1163
+ await atomicWriteJson(this.location.file, {
1164
+ ...state,
1165
+ revision: state.revision + 1,
1166
+ tasks: state.tasks.map((entry) => entry.id === task.id ? {
1167
+ ...withoutPending,
1168
+ revision: current.revision + 1
1169
+ } : entry)
1170
+ });
1171
+ }
1172
+ /**
1173
+ * Where an archived document lives. New records store the path relative to the team directory;
1174
+ * records written before that change hold an absolute path and are still honoured.
1175
+ */
1176
+ archivedPathOf(archivedPath) {
1177
+ return path.isAbsolute(archivedPath) ? archivedPath : path.resolve(this.location.directory, archivedPath);
1178
+ }
1179
+ /**
1180
+ * Record that an active attempt is not moving, once. Idempotent by design: a second call with the
1181
+ * same reason is a no-op, so the reconciler may call it on every pass without filling the journal.
1182
+ * Returns `true` when this call was the one that recorded it.
1183
+ */
1184
+ async markAttemptStalled(request) {
1185
+ return await withProcessLock(this.location.teamFile, async () => {
1186
+ const attempts = await this.readAttemptsIfPresentLocked();
1187
+ const attempt = attempts.attempts.find((entry) => entry.id === request.attemptId);
1188
+ if (attempt === void 0 || attempt.status !== "active") return false;
1189
+ if (attempt.stalled !== void 0) return false;
1190
+ const at = Date.now();
1191
+ const next = {
1192
+ ...attempts,
1193
+ schemaVersion: 1,
1194
+ revision: attempts.revision + 1,
1195
+ attempts: attempts.attempts.map((entry) => entry.id === request.attemptId ? {
1196
+ ...entry,
1197
+ stalled: {
1198
+ at,
1199
+ reason: request.reason,
1200
+ detail: request.detail
1201
+ },
1202
+ revision: entry.revision + 1
1203
+ } : entry)
1204
+ };
1205
+ await atomicWriteJson(this.location.attemptsFile, next);
1206
+ await this.appendAudit({
1207
+ kind: "attempt/stalled",
1208
+ taskId: attempt.taskId,
1209
+ attemptId: attempt.id,
1210
+ memberId: attempt.memberId,
1211
+ reason: request.reason,
1212
+ detail: request.detail,
1213
+ actor: request.actor,
1214
+ at,
1215
+ revision: next.revision
1216
+ });
1217
+ return true;
1218
+ });
1219
+ }
1220
+ /**
1221
+ * Keep a copy of submitted bytes, addressed by their digest. Returns the path relative to the team
1222
+ * directory, or `undefined` when the copy could not be written — a submission is never refused for
1223
+ * this, but it is then read back from the workspace and the drift check says so.
1224
+ */
1225
+ async storeBlob(digest, bytes) {
1226
+ const relative = path.join("artifacts", "blobs", digest);
1227
+ const target = path.join(this.location.directory, relative);
1228
+ try {
1229
+ if (!await fileExists(target)) {
1230
+ await promises.mkdir(this.location.blobsDirectory, { recursive: true });
1231
+ await promises.writeFile(target, bytes);
1232
+ }
1233
+ return relative;
1234
+ } catch {
1235
+ return;
1236
+ }
1237
+ }
1238
+ /** The frozen bytes of a submission artifact, or `undefined` when no copy was kept. */
1239
+ async readBlob(blobPath) {
1240
+ const resolved = path.resolve(this.location.directory, blobPath);
1241
+ const root = path.resolve(this.location.directory);
1242
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return void 0;
1243
+ try {
1244
+ return await promises.readFile(resolved);
1245
+ } catch {
1246
+ return;
1247
+ }
1248
+ }
1249
+ /**
1250
+ * Reconcile an integration that was interrupted: the marker is on the task, and the branch tells
1251
+ * the rest of the story. Reports once, and clears the marker only when nothing happened — a branch
1252
+ * that is already at the reviewed commit means the acceptance still has to be finished, and that is
1253
+ * a decision, not a cleanup.
1254
+ */
1255
+ async reconcilePendingIntegrations(request) {
1256
+ return await withProcessLock(this.location.teamFile, async () => {
1257
+ const state = await this.readOrCreateLocked();
1258
+ const pending = state.tasks.filter((task) => task.integrationPending !== void 0 && task.integrationPending.reportedAt === void 0);
1259
+ if (pending.length === 0) return [];
1260
+ const reported = [];
1261
+ const now = Date.now();
1262
+ let tasks = state.tasks;
1263
+ for (const task of pending) {
1264
+ const marker = task.integrationPending;
1265
+ const tip = await request.tipOf(marker.baseRef).catch(() => void 0);
1266
+ const outcome = tip === marker.headSha ? "moved" : tip === marker.baseTipSha ? "untouched" : "elsewhere";
1267
+ const detail = outcome === "moved" ? `the branch ${marker.baseRef} is already at the reviewed commit ${marker.headSha.slice(0, 12)}, but the acceptance never finished; retry it` : outcome === "untouched" ? `the branch ${marker.baseRef} never moved; the acceptance can simply be retried` : `the branch ${marker.baseRef} is at ${tip ?? "an unreadable tip"}, which is neither the reviewed base nor the reviewed commit; a human has to decide`;
1268
+ const next = outcome === "untouched" ? (() => {
1269
+ const { integrationPending: _cleared, ...rest } = task;
1270
+ return {
1271
+ ...rest,
1272
+ revision: task.revision + 1
1273
+ };
1274
+ })() : {
1275
+ ...task,
1276
+ integrationPending: {
1277
+ ...marker,
1278
+ reportedAt: now
1279
+ },
1280
+ revision: task.revision + 1
1281
+ };
1282
+ tasks = tasks.map((entry) => entry.id === task.id ? next : entry);
1283
+ await this.appendAudit({
1284
+ kind: "integration/pending",
1285
+ taskId: task.id,
1286
+ state: outcome,
1287
+ detail,
1288
+ actor: request.actor,
1289
+ at: now,
1290
+ revision: state.revision + 1
1291
+ });
1292
+ reported.push({
1293
+ taskId: task.id,
1294
+ state: outcome,
1295
+ detail
1296
+ });
1297
+ }
1298
+ await atomicWriteJson(this.location.file, {
1299
+ ...state,
1300
+ revision: state.revision + 1,
1301
+ tasks
1302
+ });
1303
+ return reported;
1304
+ });
1305
+ }
1306
+ /**
1307
+ * Bring every state file this store owns forward to the current schema version.
1308
+ *
1309
+ * Reading tolerates an older version, but nothing brought it forward: a field added later stayed
1310
+ * missing until something happened to write the file again, and no record said the state had been
1311
+ * read at an older version. A state from the future is refused with both versions named.
1312
+ *
1313
+ * The file is copied before it is rewritten, and the migration is recorded in the journal: a
1314
+ * migration is the one operation that changes history, and it should be possible to say when it
1315
+ * happened and to go back.
1316
+ */
1317
+ async migrate() {
1318
+ const migrations = this.migrations;
1319
+ const files = [];
1320
+ const targets = [
1321
+ {
1322
+ file: this.location.file,
1323
+ what: "task state",
1324
+ stamp: (state) => ({
1325
+ ...state,
1326
+ schemaVersion: 1
1327
+ })
1328
+ },
1329
+ {
1330
+ file: this.location.attemptsFile,
1331
+ what: "attempts state",
1332
+ stamp: (state) => ({
1333
+ ...state,
1334
+ schemaVersion: 1
1335
+ })
1336
+ },
1337
+ {
1338
+ file: this.location.submissionsFile,
1339
+ what: "submissions state",
1340
+ stamp: (state) => ({
1341
+ ...state,
1342
+ schemaVersion: 1
1343
+ })
1344
+ },
1345
+ {
1346
+ file: this.location.reviewsFile,
1347
+ what: "reviews state",
1348
+ stamp: (state) => ({
1349
+ ...state,
1350
+ schemaVersion: 1
1351
+ })
1352
+ }
1353
+ ];
1354
+ return await withProcessLock(this.location.teamFile, async () => {
1355
+ for (const target of targets) {
1356
+ let raw;
1357
+ try {
1358
+ raw = JSON.parse(await promises.readFile(target.file, "utf8"));
1359
+ } catch {
1360
+ continue;
1361
+ }
1362
+ const state = objectRecord(raw);
1363
+ if (state === void 0) continue;
1364
+ const plan = planMigration(state.schemaVersion, 1, target.what, migrations);
1365
+ if (plan.kind === "future") throw plan.error;
1366
+ if (plan.kind === "current") continue;
1367
+ const backup = `${target.file}.v${plan.from}.bak`;
1368
+ await promises.copyFile(target.file, backup);
1369
+ const migrated = target.stamp(objectRecord(applyMigration(state, plan, migrations)) ?? state);
1370
+ await atomicWriteJson(target.file, migrated);
1371
+ await this.appendAudit({
1372
+ kind: "state/migrated",
1373
+ file: path.basename(target.file),
1374
+ from: plan.from,
1375
+ to: plan.to,
1376
+ backup: path.basename(backup),
1377
+ actor: "reconciler",
1378
+ at: Date.now()
1379
+ });
1380
+ files.push({
1381
+ file: path.basename(target.file),
1382
+ from: plan.from,
1383
+ to: plan.to,
1384
+ backup: path.basename(backup)
1385
+ });
1386
+ }
1387
+ return { files };
1388
+ });
1389
+ }
1390
+ /** B32: resolved once per store; the answer cannot change while a store lives. */
1391
+ async mode() {
1392
+ if (this.resolvedMode === void 0) {
1393
+ if (this.workspaceMode === void 0) this.resolvedMode = this.changedPaths === void 0 ? null : "git";
1394
+ else this.resolvedMode = await this.workspaceMode(this.workspacePath).catch(() => void 0) ?? null;
1395
+ }
1396
+ return this.resolvedMode ?? void 0;
1397
+ }
1398
+ async snapshotPathFor(attemptId) {
1399
+ return path.join(this.location.snapshotsDirectory, `${attemptId}.json`);
1400
+ }
1401
+ /** Take the fingerprint a `files`-mode submission will be compared against. */
1402
+ async takeSnapshot(attemptId) {
1403
+ if (await this.mode() !== "files" || this.snapshotTree === void 0) return;
1404
+ const snapshot = await this.snapshotTree(this.workspacePath).catch(() => void 0);
1405
+ if (snapshot === void 0) return;
1406
+ await promises.mkdir(this.location.snapshotsDirectory, { recursive: true });
1407
+ await atomicWriteJson(await this.snapshotPathFor(attemptId), snapshot).catch(() => void 0);
1408
+ }
1409
+ /** Delete fingerprints whose attempt is no longer running: garbage, not evidence. */
1410
+ async pruneSnapshots() {
1411
+ let names;
1412
+ try {
1413
+ names = await promises.readdir(this.location.snapshotsDirectory);
1414
+ } catch {
1415
+ return 0;
1416
+ }
1417
+ if (names.length === 0) return 0;
1418
+ const attempts = await this.readAttemptsIfPresentLocked();
1419
+ const live = new Set(attempts.attempts.filter((attempt) => attempt.status === "active").map((attempt) => attempt.id));
1420
+ let removed = 0;
1421
+ for (const name of names) {
1422
+ if (!name.endsWith(".json")) continue;
1423
+ const attemptId = name.slice(0, -5);
1424
+ if (live.has(attemptId)) continue;
1425
+ await promises.unlink(path.join(this.location.snapshotsDirectory, name)).catch(() => void 0);
1426
+ removed += 1;
1427
+ }
1428
+ return removed;
1429
+ }
1430
+ async checkScope(task, tasks, attempts, artifact, attempt) {
1431
+ const declared = task.paths;
1432
+ if (declared === void 0 || declared.length === 0 || !CODING_KINDS.has(task.kind)) return void 0;
1433
+ const mode = await this.mode();
1434
+ if (mode === void 0) return {
1435
+ declared,
1436
+ state: "unchecked",
1437
+ reason: "unreadable"
1438
+ };
1439
+ let changed;
1440
+ if (mode === "git") {
1441
+ if (this.changedPaths === void 0) return {
1442
+ declared,
1443
+ state: "unchecked",
1444
+ reason: "unreadable",
1445
+ mode
1446
+ };
1447
+ try {
1448
+ changed = await this.changedPaths(this.workspacePath);
1449
+ } catch {
1450
+ changed = void 0;
1451
+ }
1452
+ if (changed === void 0) return {
1453
+ declared,
1454
+ state: "unchecked",
1455
+ reason: "unreadable",
1456
+ mode
1457
+ };
1458
+ } else {
1459
+ if (this.snapshotTree === void 0) return {
1460
+ declared,
1461
+ state: "unchecked",
1462
+ reason: "unreadable",
1463
+ mode
1464
+ };
1465
+ let before;
1466
+ try {
1467
+ before = JSON.parse(await promises.readFile(await this.snapshotPathFor(attempt.id), "utf8"));
1468
+ } catch {
1469
+ before = void 0;
1470
+ }
1471
+ if (before === void 0) return {
1472
+ declared,
1473
+ state: "unchecked",
1474
+ reason: "no-snapshot",
1475
+ mode
1476
+ };
1477
+ const after = await this.snapshotTree(this.workspacePath).catch(() => void 0);
1478
+ if (after === void 0) return {
1479
+ declared,
1480
+ state: "unchecked",
1481
+ reason: "too-many-files",
1482
+ mode
1483
+ };
1484
+ changed = diffSnapshots(before, after);
1485
+ }
1486
+ const ignored = [TEAM_ROOT, ...artifact.type === "report" ? [artifact.bodyRef] : []];
1487
+ const normalizedChanged = (changed ?? []).map((entry) => normalizeScopePath(entry)).filter((entry) => entry !== void 0).filter((entry) => !ignored.some((skip) => entry === skip || entry.startsWith(`${skip}/`)));
1488
+ const unexpected = outsideScope(normalizedChanged, declared);
1489
+ if (unexpected.length === 0) return {
1490
+ declared,
1491
+ changed: normalizedChanged,
1492
+ state: "clean",
1493
+ mode
1494
+ };
1495
+ const sharedTree = unexpected.filter((candidate) => attempts.some((other) => other.status === "active" && other.taskId !== task.id && (tasks.find((entry) => entry.id === other.taskId)?.paths ?? []).some((entry) => scopesIntersect(literalPrefix(entry), candidate))));
1496
+ return {
1497
+ declared,
1498
+ changed: normalizedChanged,
1499
+ unexpected,
1500
+ ...sharedTree.length === 0 ? {} : { sharedTree },
1501
+ state: sharedTree.length === 0 ? "violation" : "shared-tree",
1502
+ mode
1503
+ };
1504
+ }
1505
+ async freezeArtifact(artifact) {
1506
+ if (artifact.type === "report") {
1507
+ if (typeof artifact.bodyRef !== "string" || artifact.bodyRef.length === 0) fail("report bodyRef must be non-empty");
1508
+ const reportPath = path.resolve(this.workspacePath, artifact.bodyRef);
1509
+ if (reportPath !== this.workspacePath && !reportPath.startsWith(`${this.workspacePath}${path.sep}`)) fail("report bodyRef must stay inside the workspace");
1510
+ const body = await promises.readFile(reportPath);
1511
+ const bodyDigest = createHash("sha256").update(body).digest("hex");
1512
+ const blobPath = await this.storeBlob(bodyDigest, body);
1513
+ return {
1514
+ type: "report",
1515
+ bodyRef: artifact.bodyRef,
1516
+ bodyDigest,
1517
+ ...blobPath === void 0 ? {} : { blobPath }
1518
+ };
1519
+ }
1520
+ if (!Array.isArray(artifact.paths) || artifact.paths.length === 0 || !artifact.paths.every((entry) => typeof entry === "string" && entry.length > 0)) fail("files artifact paths must be non-empty");
1521
+ const normalized = artifact.paths.map((file) => {
1522
+ const absolute = path.resolve(this.workspacePath, file);
1523
+ if (absolute !== this.workspacePath && !absolute.startsWith(`${this.workspacePath}${path.sep}`)) fail("file artifact paths must stay inside the workspace");
1524
+ return {
1525
+ input: file,
1526
+ absolute,
1527
+ relative: path.relative(this.workspacePath, absolute)
1528
+ };
1529
+ });
1530
+ if (new Set(normalized.map((entry) => entry.relative)).size !== normalized.length) fail("file artifact paths must be unique");
1531
+ const entries = [];
1532
+ const blobs = [];
1533
+ for (const file of normalized) {
1534
+ let bytes;
1535
+ try {
1536
+ bytes = await promises.readFile(file.absolute);
1537
+ } catch {
1538
+ fail(`the file artifact names ${file.relative}, which is not on disk; a deletion is submitted as a report`);
1539
+ }
1540
+ const digest = createHash("sha256").update(bytes).digest("hex");
1541
+ entries.push({
1542
+ path: file.relative,
1543
+ digest
1544
+ });
1545
+ const blobPath = await this.storeBlob(digest, bytes);
1546
+ if (blobPath !== void 0) blobs.push({
1547
+ path: file.relative,
1548
+ digest,
1549
+ blobPath
1550
+ });
1551
+ }
1552
+ entries.sort((left, right) => left.path.localeCompare(right.path));
1553
+ return {
1554
+ type: "files",
1555
+ paths: entries.map((entry) => entry.path),
1556
+ manifestDigest: createHash("sha256").update(JSON.stringify(entries)).digest("hex"),
1557
+ ...blobs.length === entries.length ? { blobs } : {}
1558
+ };
1559
+ }
1560
+ /**
1561
+ * Edit the contract of one task (B27).
1562
+ *
1563
+ * Only while nobody is working on it: an attempt is bound to the contract its
1564
+ * assignee is working from, and a human decision owns every later transition.
1565
+ */
1566
+ async updateTask(request) {
1567
+ if (!candidateId(request.taskId) || request.actor.length === 0) fail("invalid update request");
1568
+ return withProcessLock(this.location.teamFile, async () => {
1569
+ const tasks = await this.readTasksIfPresentLocked();
1570
+ const task = tasks.tasks.find((entry) => entry.id === request.taskId);
1571
+ if (task === void 0) fail(`task not found: ${request.taskId}`);
1572
+ const team = await this.readTeamLocked();
1573
+ if (!isLeadOrHuman(team, request.actor)) fail(`only the lead may update tasks; ask ${leadMemberIdForMessage(team)}`);
1574
+ if (request.expectedRevision !== void 0 && request.expectedRevision !== tasks.revision) throw new LiveTeamsError(ERROR_CODES.REVISION_CONFLICT, `expected revision ${request.expectedRevision}, actual ${tasks.revision}`);
1575
+ if (task.status !== "draft" && task.status !== "ready") fail(`task cannot be updated in status ${task.status}; ${task.status === "waiting" || task.status === "needs_human" ? "a human revision owns that transition" : "only work nobody has started can be edited"}`);
1576
+ if ([
1577
+ "title",
1578
+ "kind",
1579
+ "assignee",
1580
+ "route",
1581
+ "acceptance",
1582
+ "checks",
1583
+ "why",
1584
+ "body",
1585
+ "roundLimit",
1586
+ "humanContact",
1587
+ "dependsOn",
1588
+ "paths"
1589
+ ].filter((field) => request[field] !== void 0).length === 0) fail("nothing to change");
1590
+ const merged = {
1591
+ title: request.title ?? task.title,
1592
+ kind: request.kind ?? task.kind,
1593
+ assignee: request.assignee === void 0 ? task.assignee : request.assignee ?? void 0,
1594
+ route: request.route === void 0 ? task.route : request.route ?? void 0,
1595
+ humanContact: request.humanContact ?? task.humanContact ?? "none",
1596
+ acceptance: request.acceptance ?? task.acceptance,
1597
+ checks: request.checks ?? task.checks,
1598
+ why: request.why ?? task.why,
1599
+ body: request.body ?? task.body,
1600
+ roundLimit: request.roundLimit ?? task.roundLimit,
1601
+ dependsOn: request.dependsOn === void 0 ? task.dependsOn : request.dependsOn ?? void 0,
1602
+ paths: request.paths === void 0 ? task.paths : request.paths ?? void 0
1603
+ };
1604
+ const normalized = this.validateContract(merged, team, tasks.tasks, task.id);
1605
+ const comparable = (field, value) => JSON.stringify(field === "checks" ? value ?? [] : value ?? null);
1606
+ const changed = [
1607
+ "title",
1608
+ "kind",
1609
+ "assignee",
1610
+ "route",
1611
+ "humanContact",
1612
+ "acceptance",
1613
+ "checks",
1614
+ "why",
1615
+ "body",
1616
+ "roundLimit",
1617
+ "dependsOn",
1618
+ "paths"
1619
+ ].filter((field) => comparable(field, task[field]) !== comparable(field, normalized[field]));
1620
+ if (changed.length === 0) fail("nothing to change; the contract already matches");
1621
+ const now = Date.now();
1622
+ const nextTask = {
1623
+ ...task,
1624
+ title: normalized.title,
1625
+ kind: normalized.kind,
1626
+ status: normalized.status,
1627
+ acceptance: normalized.acceptance,
1628
+ roundLimit: normalized.roundLimit,
1629
+ humanContact: normalized.humanContact,
1630
+ contractRevision: (task.contractRevision ?? 0) + 1,
1631
+ updatedAt: now,
1632
+ revision: task.revision + 1
1633
+ };
1634
+ const optional = [
1635
+ ["assignee", normalized.assignee],
1636
+ ["route", normalized.route],
1637
+ ["dependsOn", normalized.dependsOn],
1638
+ ["why", normalized.why],
1639
+ ["body", normalized.body],
1640
+ ["checks", normalized.checks === void 0 || normalized.checks.length === 0 ? void 0 : normalized.checks],
1641
+ ["paths", normalized.paths]
1642
+ ];
1643
+ for (const [field, value] of optional) if (value === void 0) delete nextTask[field];
1644
+ else Object.assign(nextTask, { [field]: value });
1645
+ const nextTasks = {
1646
+ ...tasks,
1647
+ schemaVersion: 1,
1648
+ revision: tasks.revision + 1,
1649
+ tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
1650
+ };
1651
+ const document = renderDocument(nextTask, team, { tasks: tasks.tasks });
1652
+ await atomicWrite(this.documentPath(task.id), document);
1653
+ try {
1654
+ await atomicWriteJson(this.location.file, nextTasks);
1655
+ } catch (error) {
1656
+ await atomicWrite(this.documentPath(task.id), renderDocument(task, team, { tasks: tasks.tasks })).catch(() => void 0);
1657
+ throw error;
1658
+ }
1659
+ await this.appendAudit({
1660
+ kind: "task/updated",
1661
+ taskId: task.id,
1662
+ actor: request.actor,
1663
+ fields: changed,
1664
+ at: now,
1665
+ revision: nextTasks.revision
1666
+ });
1667
+ return {
1668
+ task: clone(nextTask),
1669
+ document,
1670
+ changed
1671
+ };
1672
+ });
1673
+ }
1674
+ async claimTask(request) {
1675
+ if (!candidateId(request.taskId) || request.memberId.length === 0 || request.sessionId.length === 0 || !Number.isInteger(request.bindingGeneration)) fail("invalid claim request");
1676
+ return withProcessLock(this.location.teamFile, async () => {
1677
+ const tasks = await this.readTasksIfPresentLocked();
1678
+ const task = tasks.tasks.find((entry) => entry.id === request.taskId);
1679
+ if (task === void 0) fail(`task not found: ${request.taskId}`);
1680
+ const attempts = await this.readAttemptsIfPresentLocked();
1681
+ const existing = task.activeAttemptId === void 0 ? void 0 : attempts.attempts.find((entry) => entry.id === task.activeAttemptId);
1682
+ if (existing?.status === "active" && existing.memberId === request.memberId) return {
1683
+ task: clone(task),
1684
+ attempt: clone(existing)
1685
+ };
1686
+ if (task.assignee !== request.memberId) fail("only the task assignee may claim it");
1687
+ fenceMember((await this.readTeamLocked()).members.find((entry) => entry.memberId === request.memberId), request, request.memberId);
1688
+ if (task.status !== "ready") fail(`task is not ready; current status is ${task.status}`);
1689
+ const blockers = blockedBy(task, tasks.tasks);
1690
+ if (blockers.length > 0) {
1691
+ const first = blockers[0];
1692
+ fail(`task is blocked by ${first.id} "${first.title}" (${first.status})${blockers.length > 1 ? ` and ${blockers.length - 1} more` : ""}`);
1693
+ }
1694
+ if (existing?.status === "active") fail("task already has an active attempt owned by another member");
1695
+ if (attempts.attempts.some((entry) => entry.memberId === request.memberId && entry.status === "active")) fail("member already has an active attempt");
1696
+ const now = Date.now();
1697
+ const priorGenerations = attempts.attempts.filter((entry) => entry.taskId === task.id).map((entry) => entry.generation);
1698
+ const generation = (priorGenerations.length === 0 ? 0 : Math.max(...priorGenerations)) + 1;
1699
+ const attempt = {
1700
+ id: `attempt-${randomUUID()}`,
1701
+ taskId: task.id,
1702
+ memberId: request.memberId,
1703
+ sessionId: request.sessionId,
1704
+ bindingGeneration: request.bindingGeneration,
1705
+ generation,
1706
+ status: "active",
1707
+ startedAt: now,
1708
+ revision: attempts.revision + 1
1709
+ };
1710
+ const nextTask = {
1711
+ ...task,
1712
+ status: "claimed",
1713
+ activeAttemptId: attempt.id,
1714
+ updatedAt: now,
1715
+ revision: task.revision + 1
1716
+ };
1717
+ const nextTasks = {
1718
+ ...tasks,
1719
+ revision: tasks.revision + 1,
1720
+ tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
1721
+ };
1722
+ const nextAttempts = {
1723
+ ...attempts,
1724
+ revision: attempts.revision + 1,
1725
+ attempts: [...attempts.attempts, attempt]
1726
+ };
1727
+ await atomicWriteJson(this.location.file, nextTasks);
1728
+ try {
1729
+ await atomicWriteJson(this.location.attemptsFile, nextAttempts);
1730
+ } catch (error) {
1731
+ await atomicWriteJson(this.location.file, tasks);
1732
+ throw error;
1733
+ }
1734
+ await this.takeSnapshot(attempt.id);
1735
+ if (this.git !== void 0 && await this.mode() === "git") {
1736
+ const base = await this.git.base(this.workspacePath).catch(() => void 0);
1737
+ if (base !== void 0) {
1738
+ const current = await this.readAttemptsIfPresentLocked();
1739
+ const withBase = {
1740
+ ...current,
1741
+ schemaVersion: 1,
1742
+ revision: current.revision + 1,
1743
+ attempts: current.attempts.map((entry) => entry.id === attempt.id ? {
1744
+ ...entry,
1745
+ gitBase: {
1746
+ ref: base.ref,
1747
+ baseTipSha: base.sha
1748
+ },
1749
+ revision: entry.revision + 1
1750
+ } : entry)
1751
+ };
1752
+ await atomicWriteJson(this.location.attemptsFile, withBase);
1753
+ }
1754
+ }
1755
+ await this.appendAudit({
1756
+ kind: "task/claimed",
1757
+ taskId: task.id,
1758
+ memberId: request.memberId,
1759
+ attemptId: attempt.id,
1760
+ actor: request.memberId,
1761
+ at: now,
1762
+ revision: nextTasks.revision
1763
+ });
1764
+ await this.appendAudit({
1765
+ kind: "attempt/started",
1766
+ taskId: task.id,
1767
+ memberId: request.memberId,
1768
+ attemptId: attempt.id,
1769
+ actor: request.memberId,
1770
+ at: now,
1771
+ revision: nextAttempts.revision
1772
+ });
1773
+ return {
1774
+ task: clone(nextTask),
1775
+ attempt: clone(attempt)
1776
+ };
1777
+ });
1778
+ }
1779
+ /**
1780
+ * Take work away from its current attempt (B28, ADR 0005).
1781
+ *
1782
+ * The order matters and is the contract: the capability is invalidated and made
1783
+ * durable *before* the session is asked to stop, so a session that keeps running
1784
+ * cannot write anything; and `revoked` is only ever reported once quiescence has
1785
+ * actually been observed. When it has not, the attempt stays `revoking` and the
1786
+ * task goes to the human rather than claiming a stop that did not happen.
1787
+ */
1788
+ async revokeTask(request) {
1789
+ if (!candidateId(request.taskId) || request.actor.length === 0) fail("invalid revoke request");
1790
+ if (typeof request.reason !== "string" || request.reason.trim().length === 0) fail("revocation reason must be non-empty");
1791
+ const reason = request.reason.trim();
1792
+ const { task, attempt } = await withProcessLock(this.location.teamFile, async () => {
1793
+ const tasks = await this.readTasksIfPresentLocked();
1794
+ const task = tasks.tasks.find((entry) => entry.id === request.taskId);
1795
+ if (task === void 0) fail(`task not found: ${request.taskId}`);
1796
+ const team = await this.readTeamLocked();
1797
+ if (!isLeadOrHuman(team, request.actor)) fail(`only the lead may revoke tasks; ask ${leadMemberIdForMessage(team)}`);
1798
+ if (request.expectedRevision !== void 0 && request.expectedRevision !== tasks.revision) throw new LiveTeamsError(ERROR_CODES.REVISION_CONFLICT, `expected revision ${request.expectedRevision}, actual ${tasks.revision}`);
1799
+ if (task.status !== "claimed" && task.status !== "in_progress") fail(`task cannot be revoked in status ${task.status}`);
1800
+ const attempts = await this.readAttemptsIfPresentLocked();
1801
+ const bound = task.activeAttemptId === void 0 ? void 0 : attempts.attempts.find((entry) => entry.id === task.activeAttemptId);
1802
+ const attempt = bound?.status === "active" || bound?.status === "revoking" ? bound : void 0;
1803
+ if (attempt === void 0) fail("task has no active attempt to revoke");
1804
+ if (request.nextAssignee !== void 0) {
1805
+ if (memberById(team, request.nextAssignee) === void 0) fail(`assignee is not a current team member: ${request.nextAssignee}`);
1806
+ if (task.route !== void 0) validateRoute(task.route, team, request.nextAssignee);
1807
+ }
1808
+ const now = Date.now();
1809
+ const revoking = {
1810
+ ...attempt,
1811
+ status: "revoking",
1812
+ revision: attempt.revision + 1
1813
+ };
1814
+ const nextAttempts = {
1815
+ ...attempts,
1816
+ schemaVersion: 1,
1817
+ revision: attempts.revision + 1,
1818
+ attempts: attempts.attempts.map((entry) => entry.id === attempt.id ? revoking : entry)
1819
+ };
1820
+ await atomicWriteJson(this.location.attemptsFile, nextAttempts);
1821
+ if (attempt.status === "active") await this.appendAudit({
1822
+ kind: "attempt/revoking",
1823
+ taskId: task.id,
1824
+ attemptId: attempt.id,
1825
+ memberId: attempt.memberId,
1826
+ reason,
1827
+ actor: request.actor,
1828
+ at: now,
1829
+ revision: nextAttempts.revision
1830
+ });
1831
+ return {
1832
+ task,
1833
+ attempt: revoking
1834
+ };
1835
+ });
1836
+ if (this.interruptSession === void 0 || this.awaitQuiescence === void 0) return this.settleRevocation(task, attempt, false, request, reason);
1837
+ try {
1838
+ await this.interruptSession(attempt.sessionId, reason);
1839
+ } catch {}
1840
+ const quiesced = await this.awaitQuiescence(attempt.sessionId, this.quiescenceTimeoutMs);
1841
+ return this.settleRevocation(task, attempt, quiesced, request, reason);
1842
+ }
1843
+ /** Mark the settled outcome: revoked and re-owned, or escalated to the human. */
1844
+ async settleRevocation(task, attempt, quiesced, request, reason) {
1845
+ return withProcessLock(this.location.teamFile, async () => {
1846
+ const tasks = await this.readTasksIfPresentLocked();
1847
+ const current = tasks.tasks.find((entry) => entry.id === task.id);
1848
+ if (current === void 0) fail(`task not found: ${task.id}`);
1849
+ const attempts = await this.readAttemptsIfPresentLocked();
1850
+ const stored = attempts.attempts.find((entry) => entry.id === attempt.id);
1851
+ if (stored === void 0) fail(`attempt not found: ${attempt.id}`);
1852
+ const team = await this.readTeamLocked();
1853
+ const now = Date.now();
1854
+ if (!quiesced) {
1855
+ const escalated = {
1856
+ ...current,
1857
+ status: "needs_human",
1858
+ updatedAt: now,
1859
+ revision: current.revision + 1
1860
+ };
1861
+ const nextTasks = {
1862
+ ...tasks,
1863
+ schemaVersion: 1,
1864
+ revision: tasks.revision + 1,
1865
+ tasks: tasks.tasks.map((entry) => entry.id === current.id ? escalated : entry)
1866
+ };
1867
+ await atomicWriteJson(this.location.file, nextTasks);
1868
+ await this.appendAudit({
1869
+ kind: "attempt/revoke-timeout",
1870
+ taskId: current.id,
1871
+ attemptId: attempt.id,
1872
+ memberId: attempt.memberId,
1873
+ reason,
1874
+ actor: request.actor,
1875
+ at: now,
1876
+ revision: nextTasks.revision
1877
+ });
1878
+ return {
1879
+ task: clone(escalated),
1880
+ attempt: clone(stored),
1881
+ quiesced: false,
1882
+ reassigned: false
1883
+ };
1884
+ }
1885
+ const revoked = {
1886
+ ...stored,
1887
+ status: "revoked",
1888
+ revision: stored.revision + 1
1889
+ };
1890
+ const nextAttempts = {
1891
+ ...attempts,
1892
+ schemaVersion: 1,
1893
+ revision: attempts.revision + 1,
1894
+ attempts: attempts.attempts.map((entry) => entry.id === stored.id ? revoked : entry)
1895
+ };
1896
+ const candidate = request.nextAssignee ?? current.assignee;
1897
+ const nextAssignee = candidate !== void 0 && memberById(team, candidate) !== void 0 ? candidate : void 0;
1898
+ const reassigned = nextAssignee !== void 0 && nextAssignee !== current.assignee;
1899
+ const ready = {
1900
+ ...current,
1901
+ status: nextAssignee === void 0 || current.route === void 0 || current.acceptance.length === 0 ? "draft" : "ready",
1902
+ updatedAt: now,
1903
+ revision: current.revision + 1
1904
+ };
1905
+ if (nextAssignee === void 0) delete ready.assignee;
1906
+ else ready.assignee = nextAssignee;
1907
+ delete ready.activeAttemptId;
1908
+ const nextTasks = {
1909
+ ...tasks,
1910
+ schemaVersion: 1,
1911
+ revision: tasks.revision + 1,
1912
+ tasks: tasks.tasks.map((entry) => entry.id === current.id ? ready : entry)
1913
+ };
1914
+ await atomicWriteJson(this.location.attemptsFile, nextAttempts);
1915
+ await atomicWriteJson(this.location.file, nextTasks);
1916
+ await this.appendAudit({
1917
+ kind: "attempt/revoked",
1918
+ taskId: current.id,
1919
+ attemptId: stored.id,
1920
+ memberId: stored.memberId,
1921
+ reason,
1922
+ quiesced,
1923
+ actor: request.actor,
1924
+ at: now,
1925
+ revision: nextAttempts.revision
1926
+ });
1927
+ if (reassigned) await this.appendAudit({
1928
+ kind: "task/reassigned",
1929
+ taskId: current.id,
1930
+ from: current.assignee,
1931
+ to: nextAssignee,
1932
+ actor: request.actor,
1933
+ at: now,
1934
+ revision: nextTasks.revision
1935
+ });
1936
+ return {
1937
+ task: clone(ready),
1938
+ attempt: clone(revoked),
1939
+ quiesced: true,
1940
+ reassigned
1941
+ };
1942
+ });
1943
+ }
1944
+ async acknowledgeTask(request) {
1945
+ if (!candidateId(request.taskId) || request.memberId.length === 0) fail("invalid acknowledge request");
1946
+ return withProcessLock(this.location.teamFile, async () => {
1947
+ const tasks = await this.readTasksIfPresentLocked();
1948
+ const task = tasks.tasks.find((entry) => entry.id === request.taskId);
1949
+ if (task === void 0) fail(`task not found: ${request.taskId}`);
1950
+ if (task.assignee !== request.memberId) fail("only the task assignee may acknowledge it");
1951
+ fenceMember((await this.readTeamLocked()).members.find((entry) => entry.memberId === request.memberId), request, request.memberId);
1952
+ if (task.status === "in_progress") return { task: clone(task) };
1953
+ if (task.status !== "claimed") fail(`task is not claimed; current status is ${task.status}`);
1954
+ const attempts = await this.readAttemptsIfPresentLocked();
1955
+ const attempt = task.activeAttemptId === void 0 ? void 0 : attempts.attempts.find((entry) => entry.id === task.activeAttemptId && entry.status === "active");
1956
+ if (attempt === void 0 || attempt.memberId !== request.memberId) fail("task has no active attempt for this member");
1957
+ const now = Date.now();
1958
+ const nextTask = {
1959
+ ...task,
1960
+ status: "in_progress",
1961
+ updatedAt: now,
1962
+ revision: task.revision + 1
1963
+ };
1964
+ const nextTasks = {
1965
+ ...tasks,
1966
+ revision: tasks.revision + 1,
1967
+ tasks: tasks.tasks.map((entry) => entry.id === task.id ? nextTask : entry)
1968
+ };
1969
+ await atomicWriteJson(this.location.file, nextTasks);
1970
+ await this.appendAudit({
1971
+ kind: "task/acknowledged",
1972
+ taskId: task.id,
1973
+ memberId: request.memberId,
1974
+ attemptId: attempt.id,
1975
+ actor: request.memberId,
1976
+ at: now,
1977
+ revision: nextTasks.revision
1978
+ });
1979
+ return { task: clone(nextTask) };
1980
+ });
1981
+ }
1982
+ async createTask(request) {
1983
+ return withProcessLock(this.location.teamFile, async () => {
1984
+ const team = await this.readTeamLocked();
1985
+ if (!isLeadOrHuman(team, request.actor)) fail(`only the lead may create tasks; ask ${leadMemberIdForMessage(team)}`);
1986
+ const current = await this.readTasksIfPresentLocked();
1987
+ if (request.expectedRevision !== void 0 && request.expectedRevision !== current.revision) throw new LiveTeamsError(ERROR_CODES.REVISION_CONFLICT, `expected revision ${request.expectedRevision}, actual ${current.revision}`);
1988
+ const normalized = this.validateCreate(request, team, current.tasks);
1989
+ const now = Date.now();
1990
+ const id = `task-${randomUUID()}`;
1991
+ const task = {
1992
+ id,
1993
+ teamId: this.teamId,
1994
+ title: normalized.title,
1995
+ kind: normalized.kind,
1996
+ status: normalized.status,
1997
+ ...normalized.assignee === void 0 ? {} : { assignee: normalized.assignee },
1998
+ ...normalized.route === void 0 ? {} : { route: normalized.route },
1999
+ humanContact: normalized.humanContact,
2000
+ ...normalized.dependsOn === void 0 ? {} : { dependsOn: normalized.dependsOn },
2001
+ ...normalized.why === void 0 ? {} : { why: normalized.why },
2002
+ ...normalized.body === void 0 ? {} : { body: normalized.body },
2003
+ ...normalized.checks.length === 0 ? {} : { checks: normalized.checks },
2004
+ ...normalized.paths === void 0 ? {} : { paths: normalized.paths },
2005
+ acceptance: normalized.acceptance,
2006
+ roundLimit: normalized.roundLimit,
2007
+ contractRevision: 1,
2008
+ createdBy: request.actor,
2009
+ createdAt: now,
2010
+ updatedAt: now,
2011
+ revision: current.revision + 1
2012
+ };
2013
+ const documentPath = this.documentPath(id);
2014
+ const document = renderDocument(task, team, { tasks: current.tasks });
2015
+ await atomicWrite(documentPath, document);
2016
+ const next = {
2017
+ ...current,
2018
+ schemaVersion: 1,
2019
+ revision: current.revision + 1,
2020
+ tasks: [...current.tasks, task]
2021
+ };
2022
+ try {
2023
+ await atomicWriteJson(this.location.file, next);
2024
+ } catch (error) {
2025
+ await promises.unlink(documentPath).catch(() => void 0);
2026
+ throw error;
2027
+ }
2028
+ const auditAt = Date.now();
2029
+ await this.appendAudit({
2030
+ kind: "task/created",
2031
+ id,
2032
+ taskId: id,
2033
+ title: task.title,
2034
+ assignee: task.assignee,
2035
+ routeType: task.route?.type,
2036
+ actor: request.actor,
2037
+ at: auditAt,
2038
+ revision: next.revision
2039
+ });
2040
+ await this.appendAudit({
2041
+ kind: "task/document",
2042
+ id,
2043
+ taskId: id,
2044
+ path: documentPath,
2045
+ actor: request.actor,
2046
+ at: Date.now()
2047
+ });
2048
+ return {
2049
+ task: clone(task),
2050
+ documentPath
2051
+ };
2052
+ });
2053
+ }
2054
+ get teamId() {
2055
+ return path.basename(this.location.directory);
2056
+ }
2057
+ documentPath(taskId) {
2058
+ return path.join(this.location.tasksDirectory, `${taskId}.md`);
2059
+ }
2060
+ async readOrCreateLocked() {
2061
+ await promises.mkdir(this.location.directory, { recursive: true });
2062
+ return this.readTasksIfPresentLocked();
2063
+ }
2064
+ async readTasksIfPresentLocked() {
2065
+ if (!await fileExists(this.location.file)) return {
2066
+ schemaVersion: 1,
2067
+ revision: 0,
2068
+ teamId: this.teamId,
2069
+ tasks: []
2070
+ };
2071
+ try {
2072
+ return parseTasksState(await readJson(this.location.file), this.teamId, this.location.archiveDirectory);
2073
+ } catch (error) {
2074
+ if (error instanceof LiveTeamsError) throw error;
2075
+ throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `cannot read ${this.location.file}`, { cause: error });
2076
+ }
2077
+ }
2078
+ async readAttemptsIfPresentLocked() {
2079
+ if (!await fileExists(this.location.attemptsFile)) return {
2080
+ schemaVersion: 1,
2081
+ revision: 0,
2082
+ teamId: this.teamId,
2083
+ attempts: []
2084
+ };
2085
+ try {
2086
+ return parseAttemptsState(await readJson(this.location.attemptsFile), this.teamId);
2087
+ } catch (error) {
2088
+ if (error instanceof LiveTeamsError) throw error;
2089
+ throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `cannot read ${this.location.attemptsFile}`, { cause: error });
2090
+ }
2091
+ }
2092
+ async readSubmissionsIfPresentLocked() {
2093
+ if (!await fileExists(this.location.submissionsFile)) return {
2094
+ schemaVersion: 1,
2095
+ revision: 0,
2096
+ teamId: this.teamId,
2097
+ submissions: []
2098
+ };
2099
+ try {
2100
+ return parseSubmissionsState(await readJson(this.location.submissionsFile), this.teamId);
2101
+ } catch (error) {
2102
+ if (error instanceof LiveTeamsError) throw error;
2103
+ throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `cannot read ${this.location.submissionsFile}`, { cause: error });
2104
+ }
2105
+ }
2106
+ async readReviewsIfPresentLocked() {
2107
+ if (!await fileExists(this.location.reviewsFile)) return {
2108
+ schemaVersion: 1,
2109
+ revision: 0,
2110
+ teamId: this.teamId,
2111
+ rounds: []
2112
+ };
2113
+ try {
2114
+ return parseReviewsState(await readJson(this.location.reviewsFile), this.teamId);
2115
+ } catch (error) {
2116
+ if (error instanceof LiveTeamsError) throw error;
2117
+ throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `cannot read ${this.location.reviewsFile}`, { cause: error });
2118
+ }
2119
+ }
2120
+ async readTeamLocked() {
2121
+ let value;
2122
+ try {
2123
+ value = await readJson(this.location.teamFile);
2124
+ } catch (error) {
2125
+ throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, `cannot read team state: ${this.location.teamFile}`, { cause: error });
2126
+ }
2127
+ const team = objectRecord(value);
2128
+ if (team === void 0 || team.teamId !== this.teamId || !Array.isArray(team.members)) throw new LiveTeamsError(ERROR_CODES.STATE_CORRUPT, "invalid team state");
2129
+ return team;
2130
+ }
2131
+ validateCreate(request, team, existing) {
2132
+ return this.validateContract({
2133
+ title: request.title,
2134
+ kind: request.kind,
2135
+ assignee: request.assignee,
2136
+ route: request.route,
2137
+ humanContact: request.humanContact ?? "none",
2138
+ acceptance: request.acceptance,
2139
+ checks: request.checks,
2140
+ why: request.why,
2141
+ body: request.body,
2142
+ roundLimit: request.roundLimit ?? 5,
2143
+ dependsOn: request.dependsOn,
2144
+ paths: request.paths
2145
+ }, team, existing, void 0);
2146
+ }
2147
+ /**
2148
+ * The one place the contract rules live, shared by creation (B19) and editing
2149
+ * (B27): a second copy of them is how the writer and the reader start to
2150
+ * disagree about what a valid contract is.
2151
+ */
2152
+ validateContract(input, team, existing, selfId) {
2153
+ const request = input;
2154
+ if (!nonEmpty(request.title)) fail("title must be non-empty");
2155
+ const title = request.title.trim();
2156
+ if (chars(title) > MAX_TITLE) fail(`title must be at most ${MAX_TITLE} characters`);
2157
+ if (!TASK_KINDS.includes(request.kind)) fail(`unsupported task kind: ${String(request.kind)}`);
2158
+ if (request.assignee !== void 0) {
2159
+ if (typeof request.assignee !== "string" || memberById(team, request.assignee) === void 0) fail(`assignee is not a current team member: ${String(request.assignee)}`);
2160
+ }
2161
+ const route = request.route === void 0 ? void 0 : validateRoute(request.route, team, request.assignee);
2162
+ const humanContact = request.humanContact ?? "none";
2163
+ if (humanContact !== "none" && humanContact !== "expected" && humanContact !== "required") fail("humanContact must be none, expected, or required");
2164
+ if (!Array.isArray(request.acceptance)) fail("acceptance must be an array");
2165
+ const acceptance = request.acceptance.map((entry) => typeof entry === "string" ? entry.trim() : "");
2166
+ const status = request.assignee !== void 0 && route !== void 0 ? "ready" : "draft";
2167
+ if (!acceptance.every((entry) => entry.length > 0) && status === "ready") fail("acceptance criteria must be non-empty strings");
2168
+ if (status === "ready" && acceptance.length === 0) fail("ready tasks require non-empty acceptance");
2169
+ const roundLimit = request.roundLimit ?? 5;
2170
+ if (!Number.isInteger(roundLimit) || roundLimit < 1 || roundLimit > 20) fail("roundLimit must be an integer from 1 to 20");
2171
+ if (request.body !== void 0 && (typeof request.body !== "string" || chars(request.body) > MAX_BODY)) fail(`body must be at most ${MAX_BODY} characters`);
2172
+ if (request.why !== void 0 && typeof request.why !== "string") fail("why must be a string");
2173
+ if (request.checks !== void 0 && (!Array.isArray(request.checks) || !request.checks.every((entry) => typeof entry === "string"))) fail("checks must be an array of strings");
2174
+ const checks = request.checks === void 0 ? [] : request.checks.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
2175
+ const why = request.why === void 0 ? void 0 : request.why.trim();
2176
+ const body = request.body === void 0 ? void 0 : request.body;
2177
+ if (status === "ready" && (route === void 0 || acceptance.length === 0)) fail("ready tasks require route and acceptance");
2178
+ const dependsOn = validateDependencies(request.dependsOn, selfId, existing);
2179
+ let paths;
2180
+ if (request.paths !== void 0 && request.paths !== null) {
2181
+ paths = normalizeScope(request.paths);
2182
+ if (paths === void 0) fail(`paths must be 1 to 32 unique workspace-relative globs without ".." or an absolute path`);
2183
+ }
2184
+ return {
2185
+ title,
2186
+ kind: request.kind,
2187
+ ...request.assignee === void 0 ? {} : { assignee: request.assignee },
2188
+ ...route === void 0 ? {} : { route },
2189
+ humanContact,
2190
+ acceptance,
2191
+ checks,
2192
+ ...why === void 0 ? {} : { why },
2193
+ ...body === void 0 ? {} : { body },
2194
+ roundLimit,
2195
+ ...dependsOn === void 0 ? {} : { dependsOn },
2196
+ ...paths === void 0 ? {} : { paths },
2197
+ status
2198
+ };
2199
+ }
2200
+ };
2201
+ function createTaskStore(options) {
2202
+ return new TaskStore(options);
2203
+ }
2204
+ //#endregion
2205
+ export { TaskStore, assignmentRouteText, blockedBy, createTaskStore, deriveRouteContacts, renderDocument, routeAllowsContact, routeText, taskDocumentPath };