@guided-context-ledger/core 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.
package/dist/events.js ADDED
@@ -0,0 +1,1159 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Event log — the AI-to-AI coordination layer.
5
+ *
6
+ * The markdown notes (see workspace.ts) are the human layer and the source of
7
+ * truth for documents. This module adds an append-only EVENT log for agent
8
+ * coordination/handoff, because a shared markdown file is unsafe under
9
+ * concurrent multi-agent writes (proven in the 2026-06-10 Claude↔Codex test).
10
+ *
11
+ * Design (validated against a multi-process hammer test):
12
+ * - Storage: one append-only JSONL file per thread, plain files (portable, no DB).
13
+ * - Cross-process safety: an exclusive lockfile around each append. Multiple
14
+ * server processes (one per client, e.g. Claude Desktop + Codex) write the
15
+ * same file, so an in-process mutex is NOT enough — the lockfile serializes
16
+ * appends across processes and prevents line interleaving even for large bodies.
17
+ * - Ordering: the canonical `seq` is the line's 1-based ordinal at READ time.
18
+ * This sidesteps any cross-process "who got seq N" race — order is simply
19
+ * file order, assigned deterministically by the reader. `seq` is the cursor.
20
+ * - Server stamps `created_at` (clients no longer invent ordering metadata).
21
+ */
22
+ const THREAD_RE = /^[A-Za-z0-9._-]{1,128}$/;
23
+ export const EVENT_TYPES = [
24
+ "message",
25
+ "ack",
26
+ "handoff",
27
+ "conflict",
28
+ "claim",
29
+ // Capability-routed task pool (the GCL coordination spec).
30
+ "task", // creation: task_id := event_id; born unassigned (§2.0). Forward work = a typed successor task (succeeds/succession), never a reopen.
31
+ "task_state", // truth lifecycle: completed | cancelled — both TERMINAL & IMMUTABLE (no reopen; reopening is tampering)
32
+ "task_authorization", // human assignment: approved | revoked — changes claim eligibility, not truth
33
+ "task_condition", // declared condition assertion: pending | blocked, set/cleared (the author-declared half of the conditions axis)
34
+ ];
35
+ /** Lifecycle states a `claim` event can assert over a work item. Ratified: the GCL coordination spec (codex). */
36
+ export const CLAIM_STATUSES = ["claimed", "released", "completed"];
37
+ /**
38
+ * Truth lifecycle a `task_state` event asserts over a task. Distinct from claim ownership (§2.3).
39
+ * Both values are TERMINAL and IMMUTABLE: once a task is completed or cancelled its truth is settled
40
+ * for good. There is deliberately no `reopened` — reopening a closed task retroactively mutates a
41
+ * recorded completion, which is tampering with the append-only truth trail (Kyle, 2026-06-19). Forward
42
+ * work is a NEW successor task that references the original (see SUCCESSION_RELATIONS), yielding a
43
+ * visible lineage chain instead of a muddied open/close/open history.
44
+ */
45
+ export const TASK_STATUSES = ["completed", "cancelled"];
46
+ /**
47
+ * How a successor task relates to the original it supersedes (forward work after a terminal close,
48
+ * GCL-State-Model §"Task re-opening"). The relation tells an observer WHY a closed task reappeared:
49
+ * - `continuation` — a clean follow-on; the original finished and this carries it forward.
50
+ * - `redo` — the original regressed or proved insufficient; redo the work.
51
+ * - `correction` — the original was closed in error or is disputed; this corrects the record.
52
+ * Carried on the SUCCESSOR's creation event via `succeeds` (the original task_id) + `succession`.
53
+ */
54
+ export const SUCCESSION_RELATIONS = ["continuation", "redo", "correction"];
55
+ /**
56
+ * Conditions an item can carry (GCL-State-Model §"The dimensions" axis 3). FACTS ONLY — the system
57
+ * reports facts, the human assigns meaning (facts-vs-meaning, locked). Conditions STACK and each carries
58
+ * an inspectable reason. They are orthogonal to lifecycle (an in-progress task can become blocked/stale).
59
+ * - `overdue` — DERIVED fact: now is past `due_date` (zero-threshold, no "soon" opinion).
60
+ * - `stale` — DERIVED fact: no qualifying/semantic-transition activity within the cadence window.
61
+ * - `pending` — DECLARED: a decision/input is awaited but a path forward exists (the ball is in
62
+ * someone's court who CAN act). Author-set; no auto-derivation mechanism in v0.
63
+ * - `blocked` — DECLARED: a hard stop (missing parts/upstream/broken) — cannot proceed regardless.
64
+ * Author-set; no auto-derivation mechanism in v0.
65
+ * - `undetermined` — honest fallback: a determination was expected but inputs are missing (e.g. no
66
+ * `due_date` → can't assess overdue). Distinct from *clear*; quiet, not an alarm.
67
+ * v0 auto-emits only the two purely-derived facts (`overdue`, `stale`); `pending`/`blocked` await a
68
+ * declaration mechanism and `undetermined` is emitted only where a determination is genuinely expected.
69
+ */
70
+ export const TASK_CONDITIONS = ["pending", "blocked", "overdue", "stale", "undetermined"];
71
+ /**
72
+ * The DECLARED conditions — the author-set subset of the conditions axis (the rest are derived facts).
73
+ * `pending` (a decision/input is awaited but a path forward exists) vs `blocked` (a hard stop). Declared
74
+ * via a `task_condition` event, not derived, because intent isn't computable from timestamps.
75
+ */
76
+ export const DECLARABLE_CONDITIONS = ["pending", "blocked"];
77
+ /** A `task_condition` event either asserts a condition (`set`) or retracts a specific prior assertion (`cleared`). */
78
+ export const CONDITION_STATES = ["set", "cleared"];
79
+ /** A `task_authorization` event's assertion. `approved` makes a task claim-eligible; `revoked` withdraws it. */
80
+ export const TASK_AUTHORIZATIONS = ["approved", "revoked"];
81
+ /**
82
+ * Workspace pull policy — gates autonomous CLAIM eligibility over the pool, never visibility.
83
+ * Default `human_directed` (unassigned-by-default; Kyle #239 / codex #242). A default, not a constant.
84
+ */
85
+ export const TASK_PULL_POLICIES = ["human_directed", "proactive_until_stale", "proactive"];
86
+ export const DEFAULT_TASK_PULL_POLICY = "human_directed";
87
+ /** Default lease window for a claim with no explicit lease_expires_at (interactive coordination work). */
88
+ export const DEFAULT_LEASE_MS = 30 * 60 * 1000; // 30 min — codex #196; a default, not a protocol constant.
89
+ /**
90
+ * Whether an event TYPE implies the addressee owes a response, when the writer
91
+ * does not say explicitly. A handoff/conflict is an open obligation by default;
92
+ * a message/ack is not. A claim is ownership, not an obligation on others.
93
+ * Ratified: the GCL coordination spec (codex).
94
+ */
95
+ const RESPONSE_REQUIRED_BY_TYPE = {
96
+ message: false,
97
+ ack: false,
98
+ handoff: true,
99
+ conflict: true,
100
+ claim: false,
101
+ // A task is an open obligation on the pool until terminally closed (its whole point: the need
102
+ // never gets lost). task_state/task_authorization are bookkeeping, not obligations on others.
103
+ task: true,
104
+ task_state: false,
105
+ task_authorization: false,
106
+ task_condition: false,
107
+ };
108
+ function defaultRequiresResponse(type) {
109
+ return RESPONSE_REQUIRED_BY_TYPE[type] ?? false;
110
+ }
111
+ /** Normalize an addressed_to list: trimmed, non-empty, de-duplicated, order-stable. */
112
+ function normalizeActors(list) {
113
+ if (!Array.isArray(list))
114
+ return [];
115
+ const out = [];
116
+ for (const v of list) {
117
+ const s = String(v ?? "").trim();
118
+ if (s && !out.includes(s))
119
+ out.push(s);
120
+ }
121
+ return out;
122
+ }
123
+ /** Normalize a task scope envelope: keep only non-empty string members; null if nothing survives. */
124
+ function normalizeScope(s) {
125
+ if (!s || typeof s !== "object")
126
+ return null;
127
+ const src = s;
128
+ const out = {};
129
+ if (typeof src.project === "string" && src.project.trim())
130
+ out.project = src.project.trim();
131
+ if (typeof src.space === "string" && src.space.trim())
132
+ out.space = src.space.trim();
133
+ if (typeof src.principal_scope === "string" && src.principal_scope.trim())
134
+ out.principal_scope = src.principal_scope.trim();
135
+ return Object.keys(out).length ? out : null;
136
+ }
137
+ const MAX_BODY = 200_000; // safety cap on a single event body
138
+ const LOCK_TIMEOUT_MS = 5_000; // give up acquiring the lock after this
139
+ const LOCK_STALE_MS = 4_000; // a lock older than this is considered abandoned
140
+ const MAX_WAIT_MS = 50_000; // long-poll cap (must stay under client tool timeouts)
141
+ const POLL_INTERVAL_MS = 400;
142
+ export class EventError extends Error {
143
+ code;
144
+ constructor(message, code) {
145
+ super(message);
146
+ this.code = code;
147
+ this.name = "EventError";
148
+ }
149
+ }
150
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
151
+ export class EventLog {
152
+ root; // workspace root
153
+ dir; // events directory inside the workspace
154
+ constructor(workspaceRoot) {
155
+ this.root = path.resolve(workspaceRoot);
156
+ this.dir = path.join(this.root, "events");
157
+ }
158
+ threadFile(thread) {
159
+ if (!THREAD_RE.test(thread)) {
160
+ throw new EventError(`Invalid thread id "${thread}". Allowed: letters, digits, dot, underscore, hyphen; max 128 chars.`, "BAD_THREAD");
161
+ }
162
+ const file = path.join(this.dir, `${thread}.jsonl`);
163
+ const rel = path.relative(this.dir, file);
164
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
165
+ throw new EventError(`Thread escapes the events directory: ${thread}`, "BAD_THREAD");
166
+ }
167
+ return file;
168
+ }
169
+ /** The workspace root must already exist — we never silently create it. */
170
+ async assertRoot() {
171
+ try {
172
+ const s = await fs.stat(this.root);
173
+ if (!s.isDirectory())
174
+ throw new EventError(`Workspace path is not a folder: ${this.root}`, "WORKSPACE_NOT_DIR");
175
+ }
176
+ catch (e) {
177
+ if (e instanceof EventError)
178
+ throw e;
179
+ throw new EventError(`Workspace folder does not exist: ${this.root}`, "WORKSPACE_MISSING");
180
+ }
181
+ }
182
+ async acquireLock(lock) {
183
+ const start = Date.now();
184
+ for (;;) {
185
+ try {
186
+ const fd = await fs.open(lock, "wx"); // exclusive create; fails if held
187
+ await fd.writeFile(`${process.pid}:${Date.now()}`);
188
+ await fd.close();
189
+ return;
190
+ }
191
+ catch (e) {
192
+ const lockBusy = e.code === "EEXIST" || e.code === "EPERM" || e.code === "EACCES";
193
+ if (!lockBusy)
194
+ throw e;
195
+ // Steal an abandoned lock (a process that crashed mid-append). On
196
+ // Windows, concurrent exclusive opens can transiently surface as
197
+ // EPERM/EACCES while another handle is settling; treat that as busy.
198
+ try {
199
+ const st = await fs.stat(lock);
200
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
201
+ await fs.unlink(lock).catch(() => { });
202
+ continue;
203
+ }
204
+ }
205
+ catch {
206
+ if (e.code === "EEXIST")
207
+ continue; // lock vanished between checks.
208
+ }
209
+ if (Date.now() - start > LOCK_TIMEOUT_MS) {
210
+ throw new EventError("Could not acquire the thread write lock (busy).", "LOCK_TIMEOUT");
211
+ }
212
+ await sleep(2 + Math.floor(Math.random() * 8));
213
+ }
214
+ }
215
+ }
216
+ async releaseLock(lock) {
217
+ await fs.unlink(lock).catch(() => { });
218
+ }
219
+ async countLines(file) {
220
+ try {
221
+ const c = await fs.readFile(file, "utf8");
222
+ return c.split("\n").filter((l) => l.length > 0).length;
223
+ }
224
+ catch (e) {
225
+ if (e.code === "ENOENT")
226
+ return 0;
227
+ throw e;
228
+ }
229
+ }
230
+ /**
231
+ * Append one event. Server-stamps created_at. Returns the assigned seq
232
+ * (line ordinal) and a stable event_id. Cross-process safe via the lockfile.
233
+ */
234
+ async append(thread, actor, body, type = "message", parentEventId = null, opts = {}) {
235
+ await this.assertRoot();
236
+ if (!actor || !actor.trim())
237
+ throw new EventError("actor is required.", "BAD_ACTOR");
238
+ if (!EVENT_TYPES.includes(type)) {
239
+ throw new EventError(`Invalid type "${type}". Allowed: ${EVENT_TYPES.join(", ")}.`, "BAD_TYPE");
240
+ }
241
+ if (typeof body !== "string")
242
+ throw new EventError("body must be a string.", "BAD_BODY");
243
+ if (body.length > MAX_BODY) {
244
+ throw new EventError(`body too large (${body.length} > ${MAX_BODY} chars).`, "BODY_TOO_LARGE");
245
+ }
246
+ const file = this.threadFile(thread);
247
+ const lock = `${file}.lock`;
248
+ await fs.mkdir(this.dir, { recursive: true });
249
+ const created_at = new Date().toISOString();
250
+ const addressed_to = normalizeActors(opts.addressedTo);
251
+ const requires_response = typeof opts.requiresResponse === "boolean" ? opts.requiresResponse : defaultRequiresResponse(type);
252
+ // Claim fields: only meaningful for type=claim. A claim MUST reference the work item
253
+ // it claims via parent_event_id (codex #196.1) and MUST carry a claim_status.
254
+ let claim_status = null;
255
+ let lease_expires_at = null;
256
+ if (type === "claim") {
257
+ if (!parentEventId) {
258
+ throw new EventError("a claim event must reference the work item via parent_event_id.", "CLAIM_NO_PARENT");
259
+ }
260
+ if (!opts.claimStatus || !CLAIM_STATUSES.includes(opts.claimStatus)) {
261
+ throw new EventError(`a claim event needs claim_status (one of: ${CLAIM_STATUSES.join(", ")}).`, "BAD_CLAIM_STATUS");
262
+ }
263
+ claim_status = opts.claimStatus;
264
+ if (claim_status === "claimed") {
265
+ // Omitted → default lease; explicit null → indefinite hold; explicit string → validate ISO.
266
+ if (opts.leaseExpiresAt === undefined) {
267
+ lease_expires_at = new Date(Date.parse(created_at) + DEFAULT_LEASE_MS).toISOString();
268
+ }
269
+ else if (opts.leaseExpiresAt === null) {
270
+ lease_expires_at = null;
271
+ }
272
+ else if (Number.isNaN(Date.parse(opts.leaseExpiresAt))) {
273
+ throw new EventError(`lease_expires_at is not a valid ISO-8601 timestamp: ${opts.leaseExpiresAt}`, "BAD_LEASE");
274
+ }
275
+ else {
276
+ lease_expires_at = new Date(Date.parse(opts.leaseExpiresAt)).toISOString();
277
+ }
278
+ }
279
+ }
280
+ // Task-pool fields: only meaningful for the task event family. A task is born unassigned
281
+ // (no owner here — ownership comes only from a later claim, §2.0). task_state/task_authorization
282
+ // reference their task via parent_event_id (= the task's event_id).
283
+ let task_title = null;
284
+ let requires = [];
285
+ let role = null;
286
+ let scope = null;
287
+ let succeeds = null;
288
+ let succession = null;
289
+ let start_date = null;
290
+ let due_date = null;
291
+ let task_status = null;
292
+ let condition = null;
293
+ let condition_state = null;
294
+ let authorization = null;
295
+ let reason = null;
296
+ let result_refs = [];
297
+ if (type === "task") {
298
+ task_title =
299
+ typeof opts.taskTitle === "string" && opts.taskTitle.trim() ? opts.taskTitle.trim() : firstLine(body) || null;
300
+ requires = normalizeActors(opts.requires); // capability tokens: same trim/dedupe/order-stable shape
301
+ role = typeof opts.role === "string" && opts.role.trim() ? opts.role.trim() : null;
302
+ scope = normalizeScope(opts.scope);
303
+ // Forward work after a terminal close: a successor references the original via succeeds + a typed
304
+ // relation. The two travel together — a target with no relation (or vice versa) is meaningless.
305
+ // Cross-task existence is a read-time/consumer concern; append stays single-event and append-only.
306
+ const succeedsRaw = typeof opts.succeeds === "string" ? opts.succeeds.trim() : "";
307
+ if (succeedsRaw || opts.succession !== undefined) {
308
+ if (!succeedsRaw) {
309
+ throw new EventError("a task with `succession` must also name the task it `succeeds`.", "BAD_SUCCESSION");
310
+ }
311
+ if (!opts.succession || !SUCCESSION_RELATIONS.includes(opts.succession)) {
312
+ throw new EventError(`a successor task needs a succession relation (one of: ${SUCCESSION_RELATIONS.join(", ")}).`, "BAD_SUCCESSION");
313
+ }
314
+ succeeds = succeedsRaw;
315
+ succession = opts.succession;
316
+ }
317
+ // Raw temporal facts (sparse). Stored verbatim — derivation (overdue/days_remaining) happens at
318
+ // read time, never baked as truth. Kept only when Date-parseable so a derived `overdue` is sound.
319
+ const startRaw = typeof opts.startDate === "string" ? opts.startDate.trim() : "";
320
+ if (startRaw && !Number.isNaN(Date.parse(startRaw)))
321
+ start_date = startRaw;
322
+ const dueRaw = typeof opts.dueDate === "string" ? opts.dueDate.trim() : "";
323
+ if (dueRaw && !Number.isNaN(Date.parse(dueRaw)))
324
+ due_date = dueRaw;
325
+ }
326
+ else if (type === "task_state") {
327
+ if (!parentEventId) {
328
+ throw new EventError("a task_state event must reference its task via parent_event_id.", "TASK_NO_PARENT");
329
+ }
330
+ if (!opts.taskStatus || !TASK_STATUSES.includes(opts.taskStatus)) {
331
+ throw new EventError(`a task_state event needs task_status (one of: ${TASK_STATUSES.join(", ")}).`, "BAD_TASK_STATUS");
332
+ }
333
+ task_status = opts.taskStatus;
334
+ reason = typeof opts.reason === "string" && opts.reason.trim() ? opts.reason.trim() : null;
335
+ result_refs = normalizeActors(opts.resultRefs);
336
+ }
337
+ else if (type === "task_authorization") {
338
+ if (!parentEventId) {
339
+ throw new EventError("a task_authorization event must reference its task via parent_event_id.", "TASK_NO_PARENT");
340
+ }
341
+ if (!opts.authorization || !TASK_AUTHORIZATIONS.includes(opts.authorization)) {
342
+ throw new EventError(`a task_authorization event needs authorization (one of: ${TASK_AUTHORIZATIONS.join(", ")}).`, "BAD_AUTHORIZATION");
343
+ }
344
+ authorization = opts.authorization;
345
+ reason = typeof opts.reason === "string" && opts.reason.trim() ? opts.reason.trim() : null;
346
+ }
347
+ else if (type === "task_condition") {
348
+ // Declared condition assertion. `set` parents the TASK and asserts pending|blocked with a reason;
349
+ // `cleared` parents the SET assertion it retracts (assertion identity = the set event's id), so
350
+ // clearing one actor's blocker can't erase another's. Append-only; clearing is a new event, not a delete.
351
+ if (!parentEventId) {
352
+ throw new EventError("a task_condition event must reference its task (set) or the assertion it clears (cleared) via parent_event_id.", "TASK_NO_PARENT");
353
+ }
354
+ if (!opts.conditionState || !CONDITION_STATES.includes(opts.conditionState)) {
355
+ throw new EventError(`a task_condition event needs condition_state (one of: ${CONDITION_STATES.join(", ")}).`, "BAD_CONDITION_STATE");
356
+ }
357
+ condition_state = opts.conditionState;
358
+ reason = typeof opts.reason === "string" && opts.reason.trim() ? opts.reason.trim() : null;
359
+ if (condition_state === "set") {
360
+ if (!opts.condition || !DECLARABLE_CONDITIONS.includes(opts.condition)) {
361
+ throw new EventError(`a task_condition 'set' needs condition (one of: ${DECLARABLE_CONDITIONS.join(", ")}).`, "BAD_CONDITION");
362
+ }
363
+ if (!reason) {
364
+ throw new EventError("a task_condition 'set' needs a non-empty reason (the reason carries the granularity).", "CONDITION_NO_REASON");
365
+ }
366
+ condition = opts.condition;
367
+ }
368
+ }
369
+ await this.acquireLock(lock);
370
+ try {
371
+ const record = {
372
+ actor,
373
+ type,
374
+ parent_event_id: parentEventId,
375
+ created_at,
376
+ body,
377
+ addressed_to,
378
+ requires_response,
379
+ claim_status,
380
+ lease_expires_at,
381
+ };
382
+ // Task-family fields are written only on their own event types, so ordinary
383
+ // message/ack/handoff lines stay lean (no forest of null task columns).
384
+ if (type === "task") {
385
+ record.task_title = task_title;
386
+ record.requires = requires;
387
+ record.role = role;
388
+ record.scope = scope;
389
+ // Only written when this task is a successor — ordinary tasks stay lean (no null lineage columns).
390
+ if (succeeds) {
391
+ record.succeeds = succeeds;
392
+ record.succession = succession;
393
+ }
394
+ // Sparse temporal facts — written only when present, so dateless tasks stay lean.
395
+ if (start_date)
396
+ record.start_date = start_date;
397
+ if (due_date)
398
+ record.due_date = due_date;
399
+ }
400
+ else if (type === "task_state") {
401
+ record.task_status = task_status;
402
+ record.reason = reason;
403
+ record.result_refs = result_refs;
404
+ }
405
+ else if (type === "task_authorization") {
406
+ record.authorization = authorization;
407
+ record.reason = reason;
408
+ }
409
+ else if (type === "task_condition") {
410
+ if (condition)
411
+ record.condition = condition; // present only on a `set`
412
+ record.condition_state = condition_state;
413
+ record.reason = reason;
414
+ }
415
+ // One newline-terminated JSON line; the lock guarantees no interleave.
416
+ await fs.appendFile(file, JSON.stringify(record) + "\n", "utf8");
417
+ const seq = await this.countLines(file);
418
+ const event_id = `${thread}#${seq}`;
419
+ await this.touchPresence(actor, created_at); // best-effort
420
+ return { seq, event_id, created_at };
421
+ }
422
+ finally {
423
+ await this.releaseLock(lock);
424
+ }
425
+ }
426
+ parseLine(thread, line, seq) {
427
+ try {
428
+ const o = JSON.parse(line);
429
+ const type = (o.type ?? "message");
430
+ return {
431
+ seq,
432
+ event_id: `${thread}#${seq}`,
433
+ thread,
434
+ actor: String(o.actor ?? "unknown"),
435
+ type,
436
+ parent_event_id: o.parent_event_id ?? null,
437
+ created_at: String(o.created_at ?? ""),
438
+ body: String(o.body ?? ""),
439
+ // Legacy events predate structured addressing: absent → unaddressed, and
440
+ // requires_response falls back to the type default (so old handoffs still read as open).
441
+ addressed_to: normalizeActors(o.addressed_to),
442
+ requires_response: typeof o.requires_response === "boolean" ? o.requires_response : defaultRequiresResponse(type),
443
+ // Claim fields only carry meaning for type=claim; absent/invalid → null (legacy events have neither).
444
+ claim_status: type === "claim" && CLAIM_STATUSES.includes(o.claim_status) ? o.claim_status : null,
445
+ lease_expires_at: type === "claim" && typeof o.lease_expires_at === "string" ? o.lease_expires_at : null,
446
+ task_title: type === "task" && typeof o.task_title === "string" ? o.task_title : null,
447
+ requires: type === "task" ? normalizeActors(o.requires) : [],
448
+ role: type === "task" && typeof o.role === "string" ? o.role : null,
449
+ scope: type === "task" ? normalizeScope(o.scope) : null,
450
+ succeeds: type === "task" && typeof o.succeeds === "string" && o.succeeds.trim() ? o.succeeds : null,
451
+ succession: type === "task" && SUCCESSION_RELATIONS.includes(o.succession) ? o.succession : null,
452
+ start_date: type === "task" && typeof o.start_date === "string" && o.start_date.trim() ? o.start_date : null,
453
+ due_date: type === "task" && typeof o.due_date === "string" && o.due_date.trim() ? o.due_date : null,
454
+ // A legacy `reopened` value is no longer in TASK_STATUSES, so it parses to null here and is inert in
455
+ // the fold (it can never un-close a terminal task) — the migration that makes old reopens harmless.
456
+ task_status: type === "task_state" && TASK_STATUSES.includes(o.task_status) ? o.task_status : null,
457
+ condition: type === "task_condition" && DECLARABLE_CONDITIONS.includes(o.condition) ? o.condition : null,
458
+ condition_state: type === "task_condition" && CONDITION_STATES.includes(o.condition_state) ? o.condition_state : null,
459
+ authorization: type === "task_authorization" && TASK_AUTHORIZATIONS.includes(o.authorization)
460
+ ? o.authorization
461
+ : null,
462
+ reason: (type === "task_state" || type === "task_authorization" || type === "task_condition") && typeof o.reason === "string"
463
+ ? o.reason
464
+ : null,
465
+ result_refs: type === "task_state" ? normalizeActors(o.result_refs) : [],
466
+ };
467
+ }
468
+ catch {
469
+ return null;
470
+ }
471
+ }
472
+ async readRaw(thread, afterSeq) {
473
+ const file = this.threadFile(thread);
474
+ let content = "";
475
+ try {
476
+ content = await fs.readFile(file, "utf8");
477
+ }
478
+ catch (e) {
479
+ if (e.code === "ENOENT")
480
+ return { events: [], latest_seq: 0, corrupt: 0 };
481
+ throw e;
482
+ }
483
+ const lines = content.split("\n").filter((l) => l.length > 0);
484
+ const events = [];
485
+ let corrupt = 0;
486
+ lines.forEach((l, i) => {
487
+ const seq = i + 1;
488
+ if (seq <= afterSeq)
489
+ return;
490
+ const ev = this.parseLine(thread, l, seq);
491
+ if (ev)
492
+ events.push(ev);
493
+ else
494
+ corrupt++;
495
+ });
496
+ return { events, latest_seq: lines.length, corrupt };
497
+ }
498
+ /**
499
+ * Read events with seq > afterSeq (the cursor → only deltas). If waitMs > 0
500
+ * and there are no new events, long-poll: re-check until new events arrive or
501
+ * the wait elapses (collapses idle polling into one call). Pass `actor` to
502
+ * record presence (so peers can tell you are alive even while only reading).
503
+ */
504
+ async read(thread, afterSeq = 0, waitMs = 0, actor) {
505
+ await this.assertRoot();
506
+ if (actor && actor.trim())
507
+ await this.touchPresence(actor, new Date().toISOString()).catch(() => { });
508
+ const wait = Math.max(0, Math.min(waitMs || 0, MAX_WAIT_MS));
509
+ const start = Date.now();
510
+ let res = await this.readRaw(thread, afterSeq);
511
+ while (res.events.length === 0 && Date.now() - start < wait) {
512
+ await sleep(POLL_INTERVAL_MS);
513
+ res = await this.readRaw(thread, afterSeq);
514
+ }
515
+ const presence = await this.readPresence();
516
+ return { ...res, presence };
517
+ }
518
+ /** Advisory last-active map; last-write-wins (races tolerable, parse errors reset). */
519
+ async touchPresence(actor, iso) {
520
+ try {
521
+ await fs.mkdir(this.dir, { recursive: true });
522
+ const p = path.join(this.dir, "_presence.json");
523
+ let cur = {};
524
+ try {
525
+ cur = JSON.parse(await fs.readFile(p, "utf8"));
526
+ }
527
+ catch {
528
+ cur = {};
529
+ }
530
+ cur[actor] = iso;
531
+ await fs.writeFile(p, JSON.stringify(cur), "utf8");
532
+ }
533
+ catch {
534
+ /* presence is best-effort; never fail a real operation over it */
535
+ }
536
+ }
537
+ async readPresence() {
538
+ try {
539
+ return JSON.parse(await fs.readFile(path.join(this.dir, "_presence.json"), "utf8"));
540
+ }
541
+ catch {
542
+ return {};
543
+ }
544
+ }
545
+ /** List thread ids that have an event log. */
546
+ async listThreads() {
547
+ try {
548
+ const entries = await fs.readdir(this.dir);
549
+ return entries
550
+ .filter((e) => e.endsWith(".jsonl"))
551
+ .map((e) => e.slice(0, -".jsonl".length))
552
+ .sort();
553
+ }
554
+ catch {
555
+ return [];
556
+ }
557
+ }
558
+ /**
559
+ * Cross-thread overview for `orient`: per-thread cursor state relative to
560
+ * `actor` (deterministic, no inference). `unread` = events after the actor's
561
+ * own last post; `needs_me` flags threads whose latest event is from someone
562
+ * else and is unread by the actor. Records presence for the actor.
563
+ */
564
+ async overview(actor) {
565
+ if (actor && actor.trim())
566
+ await this.touchPresence(actor, new Date().toISOString()).catch(() => { });
567
+ const presence = await this.readPresence();
568
+ const knownActors = Object.keys(presence); // bounded source for the prose-handoff heuristic
569
+ const names = await this.listThreads();
570
+ const now = Date.now(); // single read-time clock for all lease-expiry comparisons this orient
571
+ // First pass: read every thread ONCE and accumulate all events into a single GLOBAL causal index.
572
+ // Owed-response discharge must follow the causal edge regardless of which thread carries the reply
573
+ // (the GCL coordination spec: thread location must not hide or prevent causal response resolution). Event
574
+ // refs are globally unique, so one cross-thread parent→child map is all it takes — zero schema change.
575
+ const perThread = [];
576
+ const allEvents = [];
577
+ for (const t of names) {
578
+ const { events, latest_seq } = await this.readRaw(t, 0);
579
+ perThread.push({ t, events, latest_seq });
580
+ allEvents.push(...events);
581
+ }
582
+ const globalChildren = indexChildren(allEvents); // cross-thread: keyed by globally-unique event_id
583
+ const threads = [];
584
+ const openForMe = [];
585
+ for (const { t, events, latest_seq } of perThread) {
586
+ let myLast = 0;
587
+ if (actor)
588
+ for (const e of events)
589
+ if (e.actor === actor)
590
+ myLast = e.seq;
591
+ const last = events.length ? events[events.length - 1] : null;
592
+ const unread = latest_seq - myLast;
593
+ // Ownership projection: fold the thread's claim events per work item (read-time expiry, no ledger
594
+ // mutation). Claims reference their work item within the same thread, so this stays thread-local.
595
+ const claimsByWorkItem = projectClaims(events, now);
596
+ // Open responsibilities directed at this actor: addressed AND requires_response, not yet causally
597
+ // answered by the actor — where "answered" now consults the GLOBAL index, so a reply in any thread
598
+ // discharges the obligation. Structured addressed_to is canonical; a prose "@actor" mention in a
599
+ // handoff is a labelled lower-confidence bridge for legacy/unstructured handoffs (source=heuristic).
600
+ const threadOpen = [];
601
+ if (actor) {
602
+ for (const e of events) {
603
+ if (e.actor === actor)
604
+ continue; // you don't owe yourself a response
605
+ const addr = addresseesFor(e, actor, knownActors);
606
+ if (!addr.matched || !e.requires_response)
607
+ continue;
608
+ if (actorRespondedCausally(globalChildren, e, actor))
609
+ continue; // cross-thread discharge
610
+ // Claim gate. An unexpired claim by SOMEONE ELSE suppresses the item (the race fix:
611
+ // the second eligible actor no longer sees it as unclaimed work). A live claim by ME
612
+ // surfaces the item as owned/in-progress. An expired (stale) claim does NOT suppress —
613
+ // the item re-enters the pool and is re-claimable, with the stale owner surfaced.
614
+ const cs = claimsByWorkItem.get(e.event_id);
615
+ let claim;
616
+ if (cs && cs.owner) {
617
+ if (!cs.stale && cs.owner !== actor)
618
+ continue; // owned by another, lease live → suppress
619
+ claim = { status: "claimed", claimed_by: cs.owner, expires: cs.expires, stale: cs.stale, mine: cs.owner === actor };
620
+ }
621
+ // Coordination-integrity signal: this owed event's own parent is in another thread → the
622
+ // discussion spans threads (the "right thread, wrong live discussion" split). Derived, cheap.
623
+ const cross_thread = !!e.parent_event_id && threadOfRef(e.parent_event_id) !== t;
624
+ threadOpen.push({
625
+ thread: t,
626
+ seq: e.seq,
627
+ event_id: e.event_id,
628
+ actor: e.actor,
629
+ type: e.type,
630
+ summary: firstLine(e.body),
631
+ source: addr.source,
632
+ ...(claim ? { claim } : {}),
633
+ ...(cross_thread ? { cross_thread: true } : {}),
634
+ });
635
+ }
636
+ }
637
+ openForMe.push(...threadOpen);
638
+ threads.push({
639
+ thread: t,
640
+ latest_seq,
641
+ my_last_seq: myLast,
642
+ unread,
643
+ last_event: last
644
+ ? { seq: last.seq, actor: last.actor, type: last.type, summary: firstLine(last.body) }
645
+ : null,
646
+ // An open obligation always needs me. Otherwise: there is unread from someone else, but a
647
+ // terminal event closes the loop — an ack (don't ping-pong on "you acked my ack"), a claim
648
+ // (someone taking ownership isn't a ping for me), OR a latest event the actor has already
649
+ // causally answered IN ANOTHER THREAD (cross-thread discharge must quiet the thread too, not
650
+ // just clear open_for_me — else a resolved discussion keeps nagging from its old home).
651
+ needs_me: threadOpen.length > 0 ||
652
+ (!!last &&
653
+ last.actor !== actor &&
654
+ unread > 0 &&
655
+ last.type !== "ack" &&
656
+ last.type !== "claim" &&
657
+ (!actor || !actorRespondedCausally(globalChildren, last, actor))),
658
+ open_for_me: threadOpen.length,
659
+ });
660
+ }
661
+ return { threads, open_for_me: openForMe, presence };
662
+ }
663
+ /**
664
+ * Cross-thread causal responses to an owed event — the addresser-side "answered in …" projection
665
+ * (the GCL coordination spec). Walks the GLOBAL causal DAG from `eventRef` across all threads and returns each
666
+ * non-`claim` descendant response, flagging the ones that landed in a different thread so a connector can
667
+ * render "answered in <thread>". A `claim` is excluded for the same reason discharge excludes it (taking
668
+ * work ≠ answering). Optionally filter to one actor's responses. Pure read-time projection, zero mutation.
669
+ */
670
+ async causalResponses(eventRef, opts = {}) {
671
+ await this.assertRoot();
672
+ const allEvents = [];
673
+ for (const t of await this.listThreads()) {
674
+ const { events } = await this.readRaw(t, 0);
675
+ allEvents.push(...events);
676
+ }
677
+ const children = indexChildren(allEvents);
678
+ const homeThread = threadOfRef(eventRef);
679
+ const out = [];
680
+ const stack = [...(children.get(eventRef) ?? [])];
681
+ const seen = new Set();
682
+ while (stack.length) {
683
+ const d = stack.pop();
684
+ if (seen.has(d.event_id))
685
+ continue;
686
+ seen.add(d.event_id);
687
+ if (d.type !== "claim" && (!opts.byActor || d.actor === opts.byActor)) {
688
+ out.push({ event_id: d.event_id, thread: d.thread, actor: d.actor, type: d.type, cross_thread: d.thread !== homeThread });
689
+ }
690
+ const kids = children.get(d.event_id);
691
+ if (kids)
692
+ stack.push(...kids);
693
+ }
694
+ out.sort((a, b) => (a.event_id < b.event_id ? -1 : a.event_id > b.event_id ? 1 : 0));
695
+ return out;
696
+ }
697
+ /**
698
+ * Project a SINGLE task's full state by id (truth, ownership, lineage, ignored terminal attempts).
699
+ * Closed tasks leave the open pool but remain inspectable here — this is the read path that makes the
700
+ * append-only invariant explainable: a later terminal attempt is ignored, not invisible (the GCL coordination spec).
701
+ */
702
+ async projectTask(thread, taskId, now = Date.now()) {
703
+ await this.assertRoot();
704
+ const { events } = await this.readRaw(thread, 0);
705
+ return projectTasks(events, now).get(taskId) ?? null;
706
+ }
707
+ /**
708
+ * Capability-routed pool of unassigned open tasks across all threads (the GCL coordination spec–#244).
709
+ * Visibility is unconditional; per-row `eligibility` reflects whether the active `policy`
710
+ * lets THIS actor claim now. Live-claimed (assigned) tasks are excluded — they are
711
+ * in-progress, not pool work; terminal tasks leave the pool but remain in history.
712
+ * Returns the full structured rows + total count — the discoverability anchor: a caller
713
+ * MUST NOT hide rows beyond this without surfacing the count and an expand path (AC n).
714
+ */
715
+ async taskPool(opts = {}) {
716
+ await this.assertRoot();
717
+ const now = opts.now ?? Date.now();
718
+ const policy = opts.policy ?? DEFAULT_TASK_PULL_POLICY;
719
+ const staleUnclaimedMs = opts.staleUnclaimedMs ?? DEFAULT_STALE_UNCLAIMED_MS;
720
+ const staleVerificationMs = opts.staleVerificationMs ?? DEFAULT_STALE_VERIFICATION_MS;
721
+ // No core default for the staleness cadence: absent a supplied policy, staleness is `undetermined`,
722
+ // never auto-`stale` at a baked threshold (codex the GCL coordination spec). Connectors may pass a labeled one.
723
+ const staleCadenceMs = opts.staleCadenceMs;
724
+ const rows = [];
725
+ for (const t of await this.listThreads()) {
726
+ const { events } = await this.readRaw(t, 0);
727
+ for (const p of projectTasks(events, now).values()) {
728
+ if (p.truth !== "open")
729
+ continue; // terminal → out of the active pool (history retains it)
730
+ const c = p.creation;
731
+ if (!scopeMatches(c.scope, opts.scope))
732
+ continue;
733
+ if (!capabilitiesCover(c.requires, opts.capabilities))
734
+ continue; // not in MY pool (AC d)
735
+ const liveOwner = !!p.ownership.owner && !p.ownership.stale;
736
+ if (liveOwner)
737
+ continue; // assigned/in-progress → not unassigned pool work
738
+ // Stale hygiene — time-based reasons are deterministic here. Registry-based reasons
739
+ // (no_capable_route / creator_unavailable) need a capability/route registry and are
740
+ // populated by the caller (the connector), not core. Never auto-closes/hides (§2.6).
741
+ const stale_reasons = [];
742
+ const everClaimed = p.ownership.status !== null;
743
+ if (!everClaimed && now - Date.parse(p.created_at) > staleUnclaimedMs) {
744
+ stale_reasons.push("unclaimed_age_exceeded");
745
+ }
746
+ if (p.verification_pending && now - Date.parse(p.last_activity_at) > staleVerificationMs) {
747
+ stale_reasons.push("verification_pending_age_exceeded");
748
+ }
749
+ // Derived condition FACTS (facts-vs-meaning): overdue + stale, each with an inspectable reason,
750
+ // plus the raw temporal views they read from. Terminal tasks already left the pool, so every row
751
+ // here is open — the only lifecycle state on which conditions are meaningful.
752
+ const days_since_activity = Math.max(0, Math.floor((now - Date.parse(p.last_activity_at)) / DAY_MS));
753
+ const dueMs = c.due_date ? Date.parse(c.due_date) : NaN;
754
+ const days_remaining = Number.isNaN(dueMs) ? null : Math.floor((dueMs - now) / DAY_MS);
755
+ const conditions = [
756
+ // Derived facts (overdue/stale) ...
757
+ ...deriveConditions({
758
+ now,
759
+ due_date: c.due_date,
760
+ dueMs,
761
+ days_remaining,
762
+ last_activity_at: p.last_activity_at,
763
+ days_since_activity,
764
+ staleCadenceMs,
765
+ }),
766
+ // ... plus author-DECLARED conditions (pending/blocked), each carrying its assertion's reason.
767
+ ...p.declared_conditions.map((d) => ({ condition: d.condition, reason: d.reason })),
768
+ ];
769
+ rows.push({
770
+ task_id: p.task_id,
771
+ thread: p.thread,
772
+ title: c.task_title ?? firstLine(c.body),
773
+ requires: c.requires,
774
+ role: c.role,
775
+ scope: c.scope,
776
+ succeeds: c.succeeds,
777
+ succession: c.succession,
778
+ truth: p.truth,
779
+ verification_pending: p.verification_pending,
780
+ authorized: p.authorized,
781
+ eligibility: eligibilityUnderPolicy(p, policy),
782
+ stale_reasons,
783
+ conditions,
784
+ start_date: c.start_date,
785
+ due_date: c.due_date,
786
+ days_remaining,
787
+ days_since_activity,
788
+ addressed_to: c.addressed_to,
789
+ created_at: p.created_at,
790
+ last_activity_at: p.last_activity_at,
791
+ age_seconds: Math.max(0, Math.floor((now - Date.parse(p.created_at)) / 1000)),
792
+ });
793
+ }
794
+ }
795
+ // Advisory default order (codex #243): authorized first → claimable-now → oldest first.
796
+ // A default sort only; grouping/threshold/relevance stay the client's view choice.
797
+ rows.sort((a, b) => {
798
+ if (a.authorized !== b.authorized)
799
+ return a.authorized ? -1 : 1;
800
+ if (a.eligibility !== b.eligibility)
801
+ return a.eligibility === "claimable_now" ? -1 : 1;
802
+ return b.age_seconds - a.age_seconds;
803
+ });
804
+ // Staleness coverage at the aggregate boundary: assessed against a supplied cadence, otherwise
805
+ // `undetermined` ONCE for the whole pool — never decorated per row (codex the GCL coordination spec).
806
+ const staleness_assessment = staleCadenceMs === undefined
807
+ ? { status: "undetermined", reason: "no_cadence_policy" }
808
+ : { status: "assessed", cadence_ms: staleCadenceMs };
809
+ return { open_pool: rows, total_eligible: rows.length, staleness_assessment };
810
+ }
811
+ }
812
+ /** The thread portion of an event ref `thread#seq` (event_ids/parent refs are globally unique). */
813
+ function threadOfRef(ref) {
814
+ const i = ref.lastIndexOf("#");
815
+ return i > 0 ? ref.slice(0, i) : ref;
816
+ }
817
+ /**
818
+ * Build a parent_event_id → children map for causal descent. Keys are globally-unique event refs
819
+ * (`thread#seq`), so passing events from MULTIPLE threads yields a cross-thread causal index — which is
820
+ * how an owed response is discharged by a causal reply that landed in another thread (the GCL coordination spec).
821
+ */
822
+ function indexChildren(events) {
823
+ const m = new Map();
824
+ for (const e of events) {
825
+ if (!e.parent_event_id)
826
+ continue;
827
+ const arr = m.get(e.parent_event_id);
828
+ if (arr)
829
+ arr.push(e);
830
+ else
831
+ m.set(e.parent_event_id, [e]);
832
+ }
833
+ return m;
834
+ }
835
+ /**
836
+ * True if `actor` posted any non-claim event causally descended from `e` — a real response
837
+ * that closes the obligation. A `claim` is deliberately excluded: claiming work means "I'm
838
+ * taking this, will respond later," so it must NOT close the item — otherwise the claimant's
839
+ * own owned work would vanish from their open_for_me instead of surfacing as in-progress.
840
+ */
841
+ function actorRespondedCausally(childrenByParent, e, actor) {
842
+ const stack = [...(childrenByParent.get(e.event_id) ?? [])];
843
+ const seen = new Set();
844
+ while (stack.length) {
845
+ const d = stack.pop();
846
+ if (seen.has(d.event_id))
847
+ continue;
848
+ seen.add(d.event_id);
849
+ if (d.actor === actor && d.type !== "claim")
850
+ return true;
851
+ const kids = childrenByParent.get(d.event_id);
852
+ if (kids)
853
+ stack.push(...kids);
854
+ }
855
+ return false;
856
+ }
857
+ /**
858
+ * Fold the thread's `claim` events into per-work-item ownership. Replayed in seq order:
859
+ * - first-seq-wins among live claims; a competing actor's claim is ignored while the owner's lease holds;
860
+ * - a same-actor claim renews/extends (latest lease wins); release/completed by the owner frees the item;
861
+ * - takeover by another actor is honoured only if the prior owner's lease had lapsed by that claim's time
862
+ * (an abandoned hold), which is exactly how a stale claim becomes re-claimable on the trail.
863
+ * Expiry against `now` is applied last, as a read-time projection — never written back (codex #196.3/.4).
864
+ */
865
+ function projectClaims(events, now) {
866
+ const byParent = new Map();
867
+ for (const e of events) {
868
+ if (e.type !== "claim" || !e.parent_event_id || !e.claim_status)
869
+ continue;
870
+ const arr = byParent.get(e.parent_event_id);
871
+ if (arr)
872
+ arr.push(e);
873
+ else
874
+ byParent.set(e.parent_event_id, [e]);
875
+ }
876
+ const out = new Map();
877
+ for (const [workItem, claims] of byParent) {
878
+ let owner = null;
879
+ let expires = null;
880
+ let status = null;
881
+ for (const c of claims) {
882
+ // events are in seq order; readRaw never reorders
883
+ if (owner === null) {
884
+ if (c.claim_status === "claimed") {
885
+ owner = c.actor;
886
+ expires = c.lease_expires_at;
887
+ status = "claimed";
888
+ }
889
+ else {
890
+ status = c.claim_status; // released/completed with nothing held — record terminal status
891
+ }
892
+ }
893
+ else if (c.actor === owner) {
894
+ if (c.claim_status === "claimed") {
895
+ expires = c.lease_expires_at; // renewal: latest same-actor lease wins
896
+ }
897
+ else {
898
+ owner = null; // owner released/completed
899
+ expires = null;
900
+ status = c.claim_status;
901
+ }
902
+ }
903
+ else {
904
+ // different actor: takeover only if the current lease had already lapsed when they claimed
905
+ if (c.claim_status === "claimed" && expires !== null && Date.parse(expires) <= Date.parse(c.created_at)) {
906
+ owner = c.actor;
907
+ expires = c.lease_expires_at;
908
+ status = "claimed";
909
+ }
910
+ // otherwise first-seq-wins (live owner holds); a non-owner release/completed is ignored
911
+ }
912
+ }
913
+ const stale = owner !== null && expires !== null && Date.parse(expires) <= now;
914
+ out.set(workItem, { owner, expires, stale, status });
915
+ }
916
+ return out;
917
+ }
918
+ function pushInto(m, k, v) {
919
+ const a = m.get(k);
920
+ if (a)
921
+ a.push(v);
922
+ else
923
+ m.set(k, [v]);
924
+ }
925
+ /**
926
+ * Fold one thread's events into per-task projections. Truth (task_state) and ownership (claim) are
927
+ * folded independently; authorization is the latest approved/revoked. Pure read-time projection.
928
+ */
929
+ function projectTasks(events, now) {
930
+ const claims = projectClaims(events, now);
931
+ const stateByTask = new Map();
932
+ const authByTask = new Map();
933
+ const claimByTask = new Map();
934
+ // Declared conditions: a `set` parents the task; a `cleared` parents the SET assertion it retracts
935
+ // (assertion identity = the set event's id), so one actor's clear can't erase another's blocker.
936
+ const conditionSetsByTask = new Map();
937
+ const conditionSetById = new Map(); // set event_id → the set, for clear validation
938
+ const clearEvents = [];
939
+ for (const e of events) {
940
+ if (!e.parent_event_id)
941
+ continue;
942
+ if (e.type === "task_state")
943
+ pushInto(stateByTask, e.parent_event_id, e);
944
+ else if (e.type === "task_authorization")
945
+ pushInto(authByTask, e.parent_event_id, e);
946
+ else if (e.type === "claim")
947
+ pushInto(claimByTask, e.parent_event_id, e);
948
+ else if (e.type === "task_condition") {
949
+ if (e.condition_state === "set") {
950
+ pushInto(conditionSetsByTask, e.parent_event_id, e);
951
+ conditionSetById.set(e.event_id, e);
952
+ }
953
+ else if (e.condition_state === "cleared") {
954
+ clearEvents.push(e);
955
+ }
956
+ }
957
+ }
958
+ // A `cleared` retracts its parent ONLY when that parent is a real `task_condition:set` assertion.
959
+ // The projection resolves the parent and IGNORES a clear that points anywhere else (the task itself, a
960
+ // message, a foreign event) — you cannot retract a blocker that was never asserted. Because a clear names
961
+ // the exact set by id, "same task/condition" is intrinsic once the parent resolves to a set (codex
962
+ // the GCL coordination spec). Collected after the full pass so a clear preceding its set in scan order still
963
+ // validates against the complete set index.
964
+ const clearedAssertionIds = new Set();
965
+ for (const c of clearEvents) {
966
+ const parent = c.parent_event_id; // non-null: the scan loop skipped parentless events
967
+ if (parent && conditionSetById.has(parent))
968
+ clearedAssertionIds.add(parent);
969
+ }
970
+ const out = new Map();
971
+ for (const e of events) {
972
+ if (e.type !== "task")
973
+ continue;
974
+ const taskId = e.event_id;
975
+ let truth = "open";
976
+ let settledByEventId = null; // the FIRST terminal event that settled truth — provenance
977
+ let lastAt = e.created_at;
978
+ const ignored_terminal_attempts = [];
979
+ for (const s of stateByTask.get(taskId) ?? []) {
980
+ if (s.created_at > lastAt)
981
+ lastAt = s.created_at;
982
+ // Terminal states are IMMUTABLE: the FIRST completed/cancelled settles the truth, and any later
983
+ // task_state event is ignored (it cannot un-close or flip a settled task — reopening is tampering).
984
+ // A later event still advances last_activity_at above, so the history stays visible.
985
+ if (truth !== "open") {
986
+ // Retain the ignored attempt with provenance — never silently invisible (codex the GCL coordination spec),
987
+ // and point it at the winning terminal so the conflict is self-explaining (codex the GCL coordination spec).
988
+ if (s.task_status === "completed" || s.task_status === "cancelled") {
989
+ ignored_terminal_attempts.push({
990
+ event_id: s.event_id,
991
+ actor: s.actor,
992
+ attempted_status: s.task_status,
993
+ at: s.created_at,
994
+ reason: "already_terminal",
995
+ settled_by_event_id: settledByEventId,
996
+ });
997
+ }
998
+ continue;
999
+ }
1000
+ if (s.task_status === "completed") {
1001
+ truth = "completed";
1002
+ settledByEventId = s.event_id;
1003
+ }
1004
+ else if (s.task_status === "cancelled") {
1005
+ truth = "cancelled";
1006
+ settledByEventId = s.event_id;
1007
+ }
1008
+ }
1009
+ let authorized = false;
1010
+ for (const a of authByTask.get(taskId) ?? []) {
1011
+ if (a.created_at > lastAt)
1012
+ lastAt = a.created_at;
1013
+ if (a.authorization === "approved")
1014
+ authorized = true;
1015
+ else if (a.authorization === "revoked")
1016
+ authorized = false;
1017
+ }
1018
+ for (const c of claimByTask.get(taskId) ?? [])
1019
+ if (c.created_at > lastAt)
1020
+ lastAt = c.created_at;
1021
+ // Active declared conditions = `set` assertions on this task not retracted by a later `cleared`.
1022
+ const declared_conditions = [];
1023
+ for (const s of conditionSetsByTask.get(taskId) ?? []) {
1024
+ if (s.created_at > lastAt)
1025
+ lastAt = s.created_at;
1026
+ if (clearedAssertionIds.has(s.event_id) || !s.condition)
1027
+ continue;
1028
+ declared_conditions.push({
1029
+ assertion_id: s.event_id,
1030
+ condition: s.condition,
1031
+ reason: s.reason ?? "",
1032
+ actor: s.actor,
1033
+ since: s.created_at,
1034
+ });
1035
+ }
1036
+ const ownership = claims.get(taskId) ?? { owner: null, expires: null, stale: false, status: null };
1037
+ // verification_pending: the owner closed their CLAIM as completed, but no task_state(completed)
1038
+ // closed the task's truth. The task is still open / pending acceptance — not done.
1039
+ const verification_pending = truth === "open" && ownership.status === "completed";
1040
+ out.set(taskId, {
1041
+ task_id: taskId,
1042
+ thread: e.thread,
1043
+ creation: e,
1044
+ truth,
1045
+ verification_pending,
1046
+ ownership,
1047
+ authorized,
1048
+ ignored_terminal_attempts,
1049
+ declared_conditions,
1050
+ created_at: e.created_at,
1051
+ last_activity_at: lastAt,
1052
+ });
1053
+ }
1054
+ return out;
1055
+ }
1056
+ const DAY_MS = 24 * 60 * 60 * 1000;
1057
+ const DEFAULT_STALE_UNCLAIMED_MS = 3 * DAY_MS; // 3 days — config, not a constant
1058
+ const DEFAULT_STALE_VERIFICATION_MS = 1 * DAY_MS; // 1 day — config, not a constant
1059
+ // NB: there is deliberately NO default stale CADENCE — staleness without a policy is `undetermined`, not a
1060
+ // baked 7d `stale`. Any 7d default belongs to a connector as a labeled display policy (codex the GCL coordination spec).
1061
+ /**
1062
+ * Derive an open task's condition FACTS from raw temporal data — facts only, never opinions (no "soon",
1063
+ * no risk, no ranking). Conditions stack; each carries an inspectable reason. v0 emits only the two
1064
+ * purely-derived facts; `pending`/`blocked` are author-declared (mechanism TBD) and `undetermined` is the
1065
+ * honest fallback for a determination that was expected but can't be made (none auto-fires for tasks yet).
1066
+ */
1067
+ function deriveConditions(input) {
1068
+ const out = [];
1069
+ // overdue — zero-threshold derived fact: now is past a real due_date.
1070
+ if (input.due_date && !Number.isNaN(input.dueMs) && input.now > input.dueMs) {
1071
+ const daysPast = input.days_remaining === null ? 0 : Math.abs(input.days_remaining);
1072
+ out.push({ condition: "overdue", reason: `due ${input.due_date}; ${daysPast}d past` });
1073
+ }
1074
+ // staleness is assessable ONLY against a cadence policy. Absent one, the dimension is `undetermined`
1075
+ // (honest "unassessed" — not a silent fresh, not a baked `stale after 7d`). A 7d default is a CONNECTOR
1076
+ // display policy, never a GCL fact (codex the GCL coordination spec). With a policy: stale-or-clear by activity.
1077
+ // The no-policy case is dimension-relative coverage, not a property of the work — so it is reported ONCE at
1078
+ // the aggregate boundary, not stamped on every row, unless an assessment context opts in per-row (#23).
1079
+ if (input.staleCadenceMs === undefined) {
1080
+ if (input.emitUndeterminedPerRow) {
1081
+ out.push({ condition: "undetermined", reason: "staleness unassessed — no cadence policy set" });
1082
+ }
1083
+ }
1084
+ else if (input.now - Date.parse(input.last_activity_at) > input.staleCadenceMs) {
1085
+ const cadenceDays = Math.round(input.staleCadenceMs / DAY_MS);
1086
+ out.push({ condition: "stale", reason: `no activity for ${input.days_since_activity}d (cadence ${cadenceDays}d)` });
1087
+ }
1088
+ return out;
1089
+ }
1090
+ function scopeMatches(taskScope, filter) {
1091
+ if (!filter)
1092
+ return true;
1093
+ if (filter.project && taskScope?.project !== filter.project)
1094
+ return false;
1095
+ if (filter.space && taskScope?.space !== filter.space)
1096
+ return false;
1097
+ return true; // principal_scope is opaque metadata; never gates visibility in v0 (AC e)
1098
+ }
1099
+ function capabilitiesCover(requires, caps) {
1100
+ if (caps === undefined)
1101
+ return true; // no capability filter supplied → show all
1102
+ return requires.every((r) => caps.includes(r));
1103
+ }
1104
+ function eligibilityUnderPolicy(p, policy) {
1105
+ const staleClaim = !!p.ownership.owner && p.ownership.stale;
1106
+ switch (policy) {
1107
+ case "proactive":
1108
+ return "claimable_now"; // fresh or stale; stale would be stamped stale_at_claim by the claimer
1109
+ case "proactive_until_stale":
1110
+ return staleClaim ? (p.authorized ? "claimable_now" : "needs_authorization") : "claimable_now";
1111
+ case "human_directed":
1112
+ default:
1113
+ return p.authorized ? "claimable_now" : "needs_authorization"; // unassigned-by-default
1114
+ }
1115
+ }
1116
+ /** Whether event `e` is directed at `actor`: structured addressed_to wins; a prose @mention on a handoff is the heuristic bridge. */
1117
+ function addresseesFor(e, actor, knownActors) {
1118
+ if (e.addressed_to.length) {
1119
+ return { matched: e.addressed_to.includes(actor), source: "structured" };
1120
+ }
1121
+ // No structured addressing: only a handoff carries a prose obligation worth bridging.
1122
+ if (e.type === "handoff" && knownActors.includes(actor) && mentions(e.body, actor)) {
1123
+ return { matched: true, source: "heuristic" };
1124
+ }
1125
+ return { matched: false, source: "structured" };
1126
+ }
1127
+ /**
1128
+ * Heuristic bridge for legacy/prose handoffs — matches only a DIRECTED address to
1129
+ * the actor, never a bare mention in arbitrary prose. Accepted forms (codex ruling,
1130
+ * the GCL coordination spec):
1131
+ * - "@actor" (@-mention)
1132
+ * - "→ actor" / "-> actor" (arrow)
1133
+ * - "actor:" / "actor (role):" / "actor / role:" at the start of a line (directed header)
1134
+ * All are whole-token, so "claude-coder" never matches "claude-code". Bare mid-prose
1135
+ * names (e.g. "...closed the codex, claude-code, and Gemini sessions" or "owned by
1136
+ * claude-code") are rejected — departure/sign-off handoffs list actors without
1137
+ * assigning the event to them, and stale false-positives pollute open_for_me. The
1138
+ * bridge is a transition aid only; structured addressed_to is canonical going forward,
1139
+ * so a false negative on ambiguous old prose is safer than a false positive.
1140
+ */
1141
+ function mentions(body, actor) {
1142
+ const a = escapeRe(actor);
1143
+ const rb = `(?![A-Za-z0-9_-])`; // right token boundary — no id char may follow
1144
+ const forms = [
1145
+ new RegExp(`@${a}${rb}`, "i"), // @actor
1146
+ new RegExp(`(?:→|->)\\s*${a}${rb}`, "i"), // → actor / -> actor
1147
+ // directed header at line start: "actor:", "actor (role):", "actor / role:"
1148
+ new RegExp(`(?:^|\\n)[ \\t]*${a}${rb}(?:\\s*\\([^)\\n]*\\)|\\s*/\\s*[^:\\n]+)?\\s*:`, "i"),
1149
+ ];
1150
+ return forms.some((re) => re.test(body));
1151
+ }
1152
+ function escapeRe(s) {
1153
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1154
+ }
1155
+ function firstLine(s) {
1156
+ const line = (s.split("\n")[0] ?? "").trim();
1157
+ return line.length > 200 ? line.slice(0, 200) + "…" : line;
1158
+ }
1159
+ //# sourceMappingURL=events.js.map