@danypops/papyrus 0.31.1 → 0.32.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.
@@ -1,6 +1,7 @@
1
1
  import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import type { Artifact } from "../../src/domain/artifact.ts";
4
+ import type { TaskLease } from "../../src/domain/task-lease.ts";
4
5
  import { PROOF_TYPES } from "../../src/domain/checklist.ts";
5
6
  import type { GateResult } from "../../src/domain/gate.ts";
6
7
  import type { TaskExecutionPlan } from "../../src/task-execution.ts";
@@ -237,7 +238,7 @@ export function registerTasksTool(pi: ExtensionAPI): void {
237
238
  pi.registerTool({
238
239
  name: "tasks",
239
240
  label: "Tasks",
240
- description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). PREFER addressing a task by `name` (its exact title) over `id` for every action -- id is a backend implementation detail, resolved from name automatically, and only needs to appear explicitly when a name is genuinely ambiguous (two tasks share a title; the error will say so and list the real ids to disambiguate with). Task results likewise show name and status, not id, unless two shown tasks share a title. `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the name-based equivalents of `dependency_id`/`parent_id`/`child_id`/`root_task_id`/`depends_on`. Prefer this over low-level papyrus_* tools for task work.",
241
+ description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore, claim, heartbeat_lease, release_lease, lease, event_feed. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). claim/heartbeat_lease/release_lease/lease manage a bounded work-reservation lease -- independent of both lifecycle status and Focus, so multiple sessions can Focus the same task while only one owner holds its lease at a time; claim throws if a DIFFERENT owner already holds a live lease, release/heartbeat require the exact token claim returned. `owner` defaults to this session's own id when omitted. PREFER addressing a task by `name` (its exact title) over `id` for every action -- id is a backend implementation detail, resolved from name automatically, and only needs to appear explicitly when a name is genuinely ambiguous (two tasks share a title; the error will say so and list the real ids to disambiguate with). Task results likewise show name and status, not id, unless two shown tasks share a title. `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the name-based equivalents of `dependency_id`/`parent_id`/`child_id`/`root_task_id`/`depends_on`. Prefer this over low-level papyrus_* tools for task work.",
241
242
  parameters: Type.Object({
242
243
  action: Type.String(),
243
244
  id: Type.Optional(Type.String()),
@@ -268,6 +269,11 @@ export function registerTasksTool(pi: ExtensionAPI): void {
268
269
  scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
269
270
  root_task_id: Type.Optional(Type.String()),
270
271
  root_task_name: Type.Optional(Type.String()),
272
+ owner: Type.Optional(Type.String()),
273
+ token: Type.Optional(Type.String()),
274
+ ttl_ms: Type.Optional(Type.Number()),
275
+ note: Type.Optional(Type.String()),
276
+ event_types: Type.Optional(Type.Array(Type.String())),
271
277
  }),
272
278
  renderCall(args, theme) { return renderPapyrusToolCall("Tasks", args, theme); },
273
279
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
@@ -413,6 +419,23 @@ export function registerTasksTool(pi: ExtensionAPI): void {
413
419
  }))),
414
420
  );
415
421
  }
422
+ if (action === "event_feed") {
423
+ const page = await callService<Record<string, unknown>, { events: Array<{ id: number; occurredAt: string; taskId: string; type: string }>; nextCursor?: number }>("tasks.event_feed", { cursor: params.cursor, limit: params.limit, event_types: params.event_types });
424
+ const output = page.events.length === 0 ? "No events." : page.events.map((event) => `${event.id} ${event.occurredAt} ${event.taskId} ${event.type}`).join("\n");
425
+ return text(page.nextCursor !== undefined ? `${output}\n\n(more available -- resume with cursor: ${page.nextCursor})` : output, createPreviewDetails("tasks.event_feed", "Task event feed", output));
426
+ }
427
+ if (action === "claim" || action === "heartbeat_lease" || action === "release_lease" || action === "lease") {
428
+ const leaseRequest = { ...request, owner: (params.owner as string | undefined) ?? resolvedSessionId };
429
+ if (action === "release_lease") {
430
+ const released = await callService<Record<string, unknown>, { released: boolean }>("tasks.release_lease", leaseRequest);
431
+ const output = released.released ? "Lease released." : "No live lease to release.";
432
+ return text(output, createPreviewDetails("tasks.release_lease", "Task lease", output));
433
+ }
434
+ const operation = action === "claim" ? "tasks.claim" : action === "heartbeat_lease" ? "tasks.heartbeat_lease" : "tasks.lease";
435
+ const lease = await callService<Record<string, unknown>, TaskLease | null>(operation, leaseRequest);
436
+ const output = lease ? `Leased by "${lease.owner}" until ${lease.leaseExpiresAt} (token ${lease.token}).` : "No live lease.";
437
+ return text(output, createPreviewDetails(operation, "Task lease", output));
438
+ }
416
439
  const trashResult = await handleArtifactRemoveRestore(action, params);
417
440
  if (trashResult) return trashResult;
418
441
  const operations = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.31.1",
3
+ "version": "0.32.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -1,11 +1,14 @@
1
1
  import type { Db } from "../db.ts";
2
2
  import { inTransaction } from "../db.ts";
3
3
  import {
4
+ normalizeTaskEventFeedQuery,
4
5
  normalizeTaskHistoryQuery,
5
6
  validateTaskEvent,
6
7
  type AppendTaskEvent,
7
8
  type TaskEvent,
8
9
  type TaskEventEvidence,
10
+ type TaskEventFeedPage,
11
+ type TaskEventFeedQuery,
9
12
  type TaskEventType,
10
13
  type TaskHistoryPage,
11
14
  type TaskHistoryQuery,
@@ -89,4 +92,22 @@ export class SQLiteTaskEventStore implements TaskEventStore {
89
92
  const events = rows.slice(0, limit).map(mapRow);
90
93
  return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
91
94
  }
95
+
96
+ feed(query: TaskEventFeedQuery = {}): TaskEventFeedPage {
97
+ const { limit, cursor, eventTypes } = normalizeTaskEventFeedQuery(query);
98
+ const typeFilter = eventTypes ? `AND event_type IN (${eventTypes.map(() => "?").join(", ")})` : "";
99
+ const params: unknown[] = [];
100
+ if (cursor !== undefined) params.push(cursor);
101
+ if (eventTypes) params.push(...eventTypes);
102
+ params.push(limit + 1);
103
+ const rows = this.db.prepare(`
104
+ SELECT * FROM task_events
105
+ WHERE 1=1 ${cursor === undefined ? "" : "AND id > ?"} ${typeFilter}
106
+ ORDER BY id ASC
107
+ LIMIT ?
108
+ `).all(...params) as TaskEventRow[];
109
+ const hasMore = rows.length > limit;
110
+ const events = rows.slice(0, limit).map(mapRow);
111
+ return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
112
+ }
92
113
  }
@@ -0,0 +1,98 @@
1
+ import { TASK_LEASE_DEFAULT_TTL_MS } from "../constants.ts";
2
+ import type { Db } from "../db.ts";
3
+ import { inTransaction } from "../db.ts";
4
+ import { isLeaseExpired, validateLeaseNote, validateLeaseOwner, validateLeaseTtlMs, type TaskLease } from "../domain/task-lease.ts";
5
+ import type { TaskLeaseStore } from "../ports/task-lease-store.ts";
6
+
7
+ interface TaskLeaseRow {
8
+ task_id: string;
9
+ owner: string;
10
+ token: string;
11
+ claimed_at: string;
12
+ lease_expires_at: string;
13
+ heartbeat_at: string | null;
14
+ note: string | null;
15
+ }
16
+
17
+ function fromRow(row: TaskLeaseRow): TaskLease {
18
+ return {
19
+ taskId: row.task_id,
20
+ owner: row.owner,
21
+ token: row.token,
22
+ claimedAt: row.claimed_at,
23
+ leaseExpiresAt: row.lease_expires_at,
24
+ ...(row.heartbeat_at ? { heartbeatAt: row.heartbeat_at } : {}),
25
+ ...(row.note ? { note: row.note } : {}),
26
+ };
27
+ }
28
+
29
+ export class SQLiteTaskLeaseStore implements TaskLeaseStore {
30
+ constructor(private readonly db: Db) {}
31
+
32
+ private row(taskId: string): TaskLeaseRow | undefined {
33
+ return (this.db.prepare("SELECT * FROM task_leases WHERE task_id = ?").get(taskId) as TaskLeaseRow | null) ?? undefined;
34
+ }
35
+
36
+ claim(taskId: string, owner: string, ttlMs: number = TASK_LEASE_DEFAULT_TTL_MS, note?: string): TaskLease {
37
+ validateLeaseOwner(owner);
38
+ validateLeaseNote(note);
39
+ validateLeaseTtlMs(ttlMs);
40
+ return inTransaction(this.db, () => {
41
+ const now = new Date();
42
+ const nowIso = now.toISOString();
43
+ const existing = this.row(taskId);
44
+ const current = existing ? fromRow(existing) : undefined;
45
+ if (current && !isLeaseExpired(current, nowIso) && current.owner !== owner) {
46
+ throw new Error(`task "${taskId}" is already leased by "${current.owner}" until ${current.leaseExpiresAt}`);
47
+ }
48
+ const renewingSameOwner = current !== undefined && !isLeaseExpired(current, nowIso) && current.owner === owner;
49
+ const token = renewingSameOwner ? current!.token : crypto.randomUUID();
50
+ const claimedAt = renewingSameOwner ? current!.claimedAt : nowIso;
51
+ const leaseExpiresAt = new Date(now.getTime() + ttlMs).toISOString();
52
+ this.db.prepare(`
53
+ INSERT INTO task_leases (task_id, owner, token, claimed_at, lease_expires_at, heartbeat_at, note)
54
+ VALUES (?, ?, ?, ?, ?, NULL, ?)
55
+ ON CONFLICT(task_id) DO UPDATE SET owner = excluded.owner, token = excluded.token, claimed_at = excluded.claimed_at,
56
+ lease_expires_at = excluded.lease_expires_at, heartbeat_at = NULL, note = excluded.note
57
+ `).run(taskId, owner, token, claimedAt, leaseExpiresAt, note ?? null);
58
+ return fromRow(this.row(taskId)!);
59
+ });
60
+ }
61
+
62
+ heartbeat(taskId: string, owner: string, token: string, ttlMs: number = TASK_LEASE_DEFAULT_TTL_MS): TaskLease {
63
+ validateLeaseTtlMs(ttlMs);
64
+ return inTransaction(this.db, () => {
65
+ const nowIso = new Date().toISOString();
66
+ const existing = this.row(taskId);
67
+ const current = existing ? fromRow(existing) : undefined;
68
+ if (!current || isLeaseExpired(current, nowIso)) throw new Error(`task "${taskId}" has no live lease to renew`);
69
+ if (current.owner !== owner || current.token !== token) throw new Error(`lease for task "${taskId}" is held by a different owner/token`);
70
+ const leaseExpiresAt = new Date(Date.now() + ttlMs).toISOString();
71
+ this.db.prepare("UPDATE task_leases SET lease_expires_at = ?, heartbeat_at = ? WHERE task_id = ?").run(leaseExpiresAt, nowIso, taskId);
72
+ return fromRow(this.row(taskId)!);
73
+ });
74
+ }
75
+
76
+ release(taskId: string, owner: string, token: string): { released: boolean } {
77
+ return inTransaction(this.db, () => {
78
+ const nowIso = new Date().toISOString();
79
+ const existing = this.row(taskId);
80
+ const current = existing ? fromRow(existing) : undefined;
81
+ if (!current || isLeaseExpired(current, nowIso)) return { released: false };
82
+ if (current.owner !== owner || current.token !== token) throw new Error(`lease for task "${taskId}" is held by a different owner/token`);
83
+ this.db.prepare("DELETE FROM task_leases WHERE task_id = ?").run(taskId);
84
+ return { released: true };
85
+ });
86
+ }
87
+
88
+ get(taskId: string): TaskLease | undefined {
89
+ const existing = this.row(taskId);
90
+ if (!existing) return undefined;
91
+ const lease = fromRow(existing);
92
+ return isLeaseExpired(lease, new Date().toISOString()) ? undefined : lease;
93
+ }
94
+
95
+ reapExpired(olderThanIso: string): number {
96
+ return this.db.prepare("DELETE FROM task_leases WHERE lease_expires_at < ?").run(olderThanIso).changes;
97
+ }
98
+ }
package/src/cli.ts CHANGED
@@ -148,6 +148,12 @@ const USAGE = `Usage:
148
148
  papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--json]
149
149
  papyrus tasks clear-focus [--session-id <id>] [--session-secret <secret>] [--json]
150
150
  papyrus tasks reap-stale-focus [--json]
151
+ papyrus tasks claim <id> --owner <owner> [--ttl-ms <ms>] [--note <text>] [--json]
152
+ papyrus tasks heartbeat-lease <id> --owner <owner> --token <token> [--ttl-ms <ms>] [--json]
153
+ papyrus tasks release-lease <id> --owner <owner> --token <token> [--json]
154
+ papyrus tasks lease <id> [--json]
155
+ papyrus tasks reap-stale-leases [--json]
156
+ papyrus tasks event-feed [--cursor <n>] [--limit <n>] [--event-types-json <json>] [--json]
151
157
  papyrus tasks history <id> [--json]
152
158
  papyrus tasks scope [project|all|graph <root-id>] [--json]
153
159
  papyrus tasks assign-project <id> [project-root] [--json]
@@ -182,6 +188,7 @@ function usage(): never {
182
188
  type TaskCliClient = Pick<PapyrusClient, "call">;
183
189
  type MigrationResult = { from: number; to: number; applied: string[] };
184
190
  type CliArtifact = { id: string; title: string; status: string; body?: string };
191
+ type CliTaskLease = { taskId: string; owner: string; token: string; claimedAt: string; leaseExpiresAt: string; heartbeatAt?: string; note?: string };
185
192
  type CliCompletion = Omit<TaskCompletion, "artifact" | "blocked"> & {
186
193
  artifact: CliArtifact;
187
194
  blocked: Array<Omit<TaskBlockage, "artifact"> & { artifact: CliArtifact }>;
@@ -1320,6 +1327,12 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1320
1327
  let limit: number | undefined;
1321
1328
  let listScope: "project" | "graph" | "all" | undefined;
1322
1329
  let rootTaskId: string | undefined;
1330
+ let owner: string | undefined;
1331
+ let token: string | undefined;
1332
+ let ttlMs: number | undefined;
1333
+ let note: string | undefined;
1334
+ let cursor: number | undefined;
1335
+ let eventTypes: string[] | undefined;
1323
1336
  for (let index = 0; index < args.length; index++) {
1324
1337
  const argument = args[index]!;
1325
1338
  if (argument === "--json") continue;
@@ -1375,6 +1388,22 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1375
1388
  continue;
1376
1389
  }
1377
1390
  if (argument === "--root-task-id") { rootTaskId = args[++index]; if (!rootTaskId) throw new Error("--root-task-id requires a value"); continue; }
1391
+ if (argument === "--owner") { owner = args[++index]; if (!owner) throw new Error("--owner requires a value"); continue; }
1392
+ if (argument === "--token") { token = args[++index]; if (!token) throw new Error("--token requires a value"); continue; }
1393
+ if (argument === "--note") { note = args[++index]; if (note === undefined) throw new Error("--note requires a value"); continue; }
1394
+ if (argument === "--ttl-ms") {
1395
+ const value = args[++index];
1396
+ if (!value || Number.isNaN(Number(value))) throw new Error("--ttl-ms requires a numeric value");
1397
+ ttlMs = Number(value);
1398
+ continue;
1399
+ }
1400
+ if (argument === "--cursor") {
1401
+ const value = args[++index];
1402
+ if (!value || Number.isNaN(Number(value))) throw new Error("--cursor requires a numeric value");
1403
+ cursor = Number(value);
1404
+ continue;
1405
+ }
1406
+ if (argument === "--event-types-json") { eventTypes = parseJsonStringArrayFlag(args[++index]!, "--event-types-json"); continue; }
1378
1407
  if (argument.startsWith("--")) throw new Error(`unknown tasks option ${argument}`);
1379
1408
  positional.push(argument);
1380
1409
  }
@@ -1426,6 +1455,53 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1426
1455
  human = `Reaped ${reaped.removed} stale Focus scope(s).`;
1427
1456
  break;
1428
1457
  }
1458
+ case "claim": {
1459
+ if (!id || dependencyId) throw new Error("tasks claim requires exactly one task id");
1460
+ if (!owner) throw new Error("tasks claim requires --owner");
1461
+ const lease = await client.call<Record<string, unknown>, CliTaskLease>("tasks.claim", { id, owner, ttl_ms: ttlMs, note });
1462
+ result = lease;
1463
+ human = `Claimed by "${lease.owner}" until ${lease.leaseExpiresAt} (token ${lease.token}).`;
1464
+ break;
1465
+ }
1466
+ case "heartbeat-lease": {
1467
+ if (!id || dependencyId) throw new Error("tasks heartbeat-lease requires exactly one task id");
1468
+ if (!owner || !token) throw new Error("tasks heartbeat-lease requires --owner and --token");
1469
+ const lease = await client.call<Record<string, unknown>, CliTaskLease>("tasks.heartbeat_lease", { id, owner, token, ttl_ms: ttlMs });
1470
+ result = lease;
1471
+ human = `Renewed until ${lease.leaseExpiresAt}.`;
1472
+ break;
1473
+ }
1474
+ case "release-lease": {
1475
+ if (!id || dependencyId) throw new Error("tasks release-lease requires exactly one task id");
1476
+ if (!owner || !token) throw new Error("tasks release-lease requires --owner and --token");
1477
+ const released = await client.call<Record<string, unknown>, { released: boolean }>("tasks.release_lease", { id, owner, token });
1478
+ result = released;
1479
+ human = released.released ? "Lease released." : "No live lease to release.";
1480
+ break;
1481
+ }
1482
+ case "lease": {
1483
+ if (!id || dependencyId) throw new Error("tasks lease requires exactly one task id");
1484
+ const lease = await client.call<Record<string, unknown>, CliTaskLease | null>("tasks.lease", { id });
1485
+ result = lease;
1486
+ human = lease ? `Leased by "${lease.owner}" until ${lease.leaseExpiresAt}.` : "No live lease.";
1487
+ break;
1488
+ }
1489
+ case "reap-stale-leases": {
1490
+ if (id) throw new Error("tasks reap-stale-leases accepts no positional arguments");
1491
+ const reaped = await client.call<Record<string, unknown>, { removed: number }>("tasks.reap_stale_leases", {});
1492
+ result = reaped;
1493
+ human = `Reaped ${reaped.removed} expired lease(s).`;
1494
+ break;
1495
+ }
1496
+ case "event-feed": {
1497
+ if (id) throw new Error("tasks event-feed accepts no positional arguments");
1498
+ const page = await client.call<Record<string, unknown>, import("./domain/task-event.ts").TaskEventFeedPage>("tasks.event_feed", { cursor, limit, event_types: eventTypes });
1499
+ result = page;
1500
+ human = page.events.length === 0
1501
+ ? "No events."
1502
+ : page.events.map((event) => `${event.id} ${event.occurredAt} ${event.taskId} ${event.type}`).join("\n");
1503
+ break;
1504
+ }
1429
1505
  case "create": {
1430
1506
  if (id) throw new Error("tasks create accepts no positional arguments");
1431
1507
  if (!title) throw new Error("tasks create requires --title");
package/src/constants.ts CHANGED
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
7
7
  export const DAEMON_UNIT_NAME = "papyrus.service";
8
8
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
9
9
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
10
- export const SQLITE_SCHEMA_VERSION = 19;
10
+ export const SQLITE_SCHEMA_VERSION = 20;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
 
13
13
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
@@ -134,6 +134,15 @@ export const TASK_HISTORY_MAX_LIMIT = 100;
134
134
  export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
135
135
  export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
136
136
  export const TASK_EVENT_REASON_MAX_LENGTH = 2_000;
137
+ /** Task lease claims: bounded work reservation, orthogonal to Task lifecycle and Focus. */
138
+ export const TASK_LEASE_DEFAULT_TTL_MS = 10 * 60 * 1000;
139
+ export const TASK_LEASE_MIN_TTL_MS = 1_000;
140
+ export const TASK_LEASE_MAX_TTL_MS = 4 * 60 * 60 * 1000;
141
+ export const TASK_LEASE_OWNER_MAX_LENGTH = 128;
142
+ export const TASK_LEASE_NOTE_MAX_LENGTH = 500;
143
+ /** Global, cross-task sequenced Task event feed (readiness/lifecycle subscriptions), distinct from tasks.history's per-task cursor. */
144
+ export const TASK_EVENT_FEED_DEFAULT_LIMIT = 50;
145
+ export const TASK_EVENT_FEED_MAX_LIMIT = 200;
137
146
  /** Deferred human Note payload, inbox, and provenance bounds. */
138
147
  export const NOTE_BODY_MAX_CHARACTERS = 10_000;
139
148
  export const NOTE_TITLE_MAX_CHARACTERS = 80;
package/src/db.ts CHANGED
@@ -256,6 +256,16 @@ BEGIN SELECT RAISE(ABORT, 'discussion_rounds are append-only'); END;
256
256
  CREATE TRIGGER IF NOT EXISTS discussion_rounds_no_delete BEFORE DELETE ON discussion_rounds
257
257
  WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.discussion_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
258
258
  BEGIN SELECT RAISE(ABORT, 'discussion_rounds are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
259
+ CREATE TABLE IF NOT EXISTS task_leases (
260
+ task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
261
+ owner TEXT NOT NULL,
262
+ token TEXT NOT NULL,
263
+ claimed_at TEXT NOT NULL,
264
+ lease_expires_at TEXT NOT NULL,
265
+ heartbeat_at TEXT,
266
+ note TEXT
267
+ );
268
+ CREATE INDEX IF NOT EXISTS task_leases_expiry_idx ON task_leases(lease_expires_at);
259
269
  `;
260
270
 
261
271
  const SEED_SQL = `
@@ -545,6 +555,27 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
545
555
  if (!existing.has("option_descriptions")) db.exec("ALTER TABLE discussion_rounds ADD COLUMN option_descriptions TEXT");
546
556
  },
547
557
  },
558
+ {
559
+ version: 20,
560
+ name: "task-leases",
561
+ // See domain/task-lease.ts. One row per task (a task has at most one live lease at a time),
562
+ // same PRIMARY KEY-per-task shape as task_focus -- but a lease is deliberately orthogonal to
563
+ // Focus (multiple sessions can focus the same task; only one owner can hold its lease).
564
+ up: (db) => {
565
+ db.exec(`
566
+ CREATE TABLE IF NOT EXISTS task_leases (
567
+ task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
568
+ owner TEXT NOT NULL,
569
+ token TEXT NOT NULL,
570
+ claimed_at TEXT NOT NULL,
571
+ lease_expires_at TEXT NOT NULL,
572
+ heartbeat_at TEXT,
573
+ note TEXT
574
+ );
575
+ CREATE INDEX IF NOT EXISTS task_leases_expiry_idx ON task_leases(lease_expires_at);
576
+ `);
577
+ },
578
+ },
548
579
  ];
549
580
 
550
581
  /**
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  TASK_EVENT_ACTOR_MAX_LENGTH,
3
+ TASK_EVENT_FEED_DEFAULT_LIMIT,
4
+ TASK_EVENT_FEED_MAX_LIMIT,
3
5
  TASK_EVENT_MAX_EVIDENCE_BYTES,
4
6
  TASK_EVENT_REASON_MAX_LENGTH,
5
7
  TASK_HISTORY_DEFAULT_LIMIT,
@@ -28,6 +30,7 @@ export const TASK_EVENT_TYPES = [
28
30
  "dependency_removed",
29
31
  "containment_added",
30
32
  "containment_removed",
33
+ "became_ready",
31
34
  ] as const;
32
35
 
33
36
  export type TaskEventType = typeof TASK_EVENT_TYPES[number];
@@ -86,6 +89,39 @@ export interface TaskHistoryPage {
86
89
  nextCursor?: number;
87
90
  }
88
91
 
92
+ /**
93
+ * A bounded, sequenced, cross-task replay feed -- distinct from TaskHistoryQuery, which is
94
+ * scoped to one task. A consumer resumes exactly where it left off via nextCursor rather than
95
+ * re-scanning the whole graph; always ascending (a subscription only ever replays forward).
96
+ */
97
+ export interface TaskEventFeedQuery {
98
+ cursor?: number;
99
+ limit?: number;
100
+ eventTypes?: TaskEventType[];
101
+ }
102
+
103
+ export interface TaskEventFeedPage {
104
+ events: TaskEvent[];
105
+ nextCursor?: number;
106
+ }
107
+
108
+ export function normalizeTaskEventFeedQuery(query: TaskEventFeedQuery = {}): Required<Pick<TaskEventFeedQuery, "limit">> & Pick<TaskEventFeedQuery, "cursor" | "eventTypes"> {
109
+ const limit = query.limit ?? TASK_EVENT_FEED_DEFAULT_LIMIT;
110
+ if (!Number.isInteger(limit) || limit < 1 || limit > TASK_EVENT_FEED_MAX_LIMIT) {
111
+ throw new Error(`task event feed limit must be between 1 and ${TASK_EVENT_FEED_MAX_LIMIT}`);
112
+ }
113
+ if (query.cursor !== undefined && (!Number.isInteger(query.cursor) || query.cursor < 0)) {
114
+ throw new Error("task event feed cursor must be a non-negative integer");
115
+ }
116
+ if (query.eventTypes !== undefined) {
117
+ if (query.eventTypes.length === 0) throw new Error("task event feed eventTypes, if provided, must be non-empty");
118
+ for (const type of query.eventTypes) {
119
+ if (!TASK_EVENT_TYPES.includes(type)) throw new Error(`unknown task event type "${type}"`);
120
+ }
121
+ }
122
+ return { limit, cursor: query.cursor, eventTypes: query.eventTypes };
123
+ }
124
+
89
125
  export function normalizeTaskHistoryQuery(query: TaskHistoryQuery = {}): Required<Pick<TaskHistoryQuery, "limit" | "direction">> & Pick<TaskHistoryQuery, "cursor"> {
90
126
  const limit = query.limit ?? TASK_HISTORY_DEFAULT_LIMIT;
91
127
  if (!Number.isInteger(limit) || limit < 1 || limit > TASK_HISTORY_MAX_LIMIT) {
@@ -0,0 +1,42 @@
1
+ import { TASK_LEASE_MAX_TTL_MS, TASK_LEASE_MIN_TTL_MS, TASK_LEASE_NOTE_MAX_LENGTH, TASK_LEASE_OWNER_MAX_LENGTH } from "../constants.ts";
2
+
3
+ /**
4
+ * A bounded work reservation on a Task, independent of both the Task's own lifecycle status
5
+ * and of per-scope Task Focus -- expresses "I intend to work this" for concurrent workers
6
+ * coordinating over the same Task graph, without claiming the Task actually started or that
7
+ * any particular session is looking at it.
8
+ */
9
+ export interface TaskLease {
10
+ taskId: string;
11
+ owner: string;
12
+ token: string;
13
+ claimedAt: string;
14
+ leaseExpiresAt: string;
15
+ heartbeatAt?: string;
16
+ note?: string;
17
+ }
18
+
19
+ export function validateLeaseOwner(owner: string): string {
20
+ if (owner.length === 0 || owner.length > TASK_LEASE_OWNER_MAX_LENGTH) {
21
+ throw new Error(`lease owner must be between 1 and ${TASK_LEASE_OWNER_MAX_LENGTH} characters`);
22
+ }
23
+ return owner;
24
+ }
25
+
26
+ export function validateLeaseNote(note: string | undefined): string | undefined {
27
+ if (note !== undefined && note.length > TASK_LEASE_NOTE_MAX_LENGTH) {
28
+ throw new Error(`lease note cannot exceed ${TASK_LEASE_NOTE_MAX_LENGTH} characters`);
29
+ }
30
+ return note;
31
+ }
32
+
33
+ export function validateLeaseTtlMs(ttlMs: number): number {
34
+ if (!Number.isFinite(ttlMs) || ttlMs < TASK_LEASE_MIN_TTL_MS || ttlMs > TASK_LEASE_MAX_TTL_MS) {
35
+ throw new Error(`lease ttl_ms must be between ${TASK_LEASE_MIN_TTL_MS} and ${TASK_LEASE_MAX_TTL_MS}`);
36
+ }
37
+ return ttlMs;
38
+ }
39
+
40
+ export function isLeaseExpired(lease: Pick<TaskLease, "leaseExpiresAt">, now: string): boolean {
41
+ return lease.leaseExpiresAt <= now;
42
+ }
@@ -19,7 +19,7 @@
19
19
  * including the composition root's own helpers.
20
20
  */
21
21
  import type { Checklist } from "../domain/checklist.ts";
22
- import type { TaskEventContext, TaskEventDirection } from "../domain/task-event.ts";
22
+ import type { TaskEventContext, TaskEventDirection, TaskEventFeedQuery } from "../domain/task-event.ts";
23
23
  import type { TaskViewMode } from "../domain/task-scope.ts";
24
24
  import type { OperationDefinition } from "../module-registry.ts";
25
25
  import type { ArtifactStore } from "../ports/artifact-store.ts";
@@ -92,6 +92,7 @@ export const TASKS_OPERATION_NAMES = [
92
92
  "tasks.pause", "tasks.unpause", "tasks.clear_focus", "tasks.start", "tasks.submit", "tasks.complete",
93
93
  "tasks.run_gates", "tasks.set_checklist", "tasks.set_gates", "tasks.context", "tasks.reject", "tasks.retry", "tasks.cancel",
94
94
  "tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain", "tasks.reap_stale_focus",
95
+ "tasks.claim", "tasks.heartbeat_lease", "tasks.release_lease", "tasks.lease", "tasks.reap_stale_leases", "tasks.event_feed",
95
96
  ] as const;
96
97
 
97
98
  export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionIdentity: SessionIdentity): OperationDefinition[] {
@@ -171,5 +172,15 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
171
172
  define("tasks.undepend", (input: OperationInput) => tasks.undepend(string(input, "id"), string(input, "dependency_id"), eventContext(input))),
172
173
  define("tasks.contain", (input: OperationInput) => tasks.contain(string(input, "parent_id"), string(input, "child_id"), eventContext(input))),
173
174
  define("tasks.uncontain", (input: OperationInput) => tasks.uncontain(string(input, "parent_id"), string(input, "child_id"), eventContext(input))),
175
+ define("tasks.claim", (input: OperationInput) => tasks.claimLease(string(input, "id"), string(input, "owner"), optionalNumber(input, "ttl_ms"), optionalString(input, "note"))),
176
+ define("tasks.heartbeat_lease", (input: OperationInput) => tasks.heartbeatLease(string(input, "id"), string(input, "owner"), string(input, "token"), optionalNumber(input, "ttl_ms"))),
177
+ define("tasks.release_lease", (input: OperationInput) => tasks.releaseLease(string(input, "id"), string(input, "owner"), string(input, "token"))),
178
+ define("tasks.lease", (input: OperationInput) => tasks.getLease(string(input, "id")) ?? null),
179
+ define("tasks.reap_stale_leases", () => ({ removed: tasks.reapStaleLeases() })),
180
+ define("tasks.event_feed", (input: OperationInput) => tasks.eventFeed({
181
+ cursor: optionalNumber(input, "cursor"),
182
+ limit: optionalNumber(input, "limit"),
183
+ eventTypes: optionalStringArray(input, "event_types") as TaskEventFeedQuery["eventTypes"],
184
+ })),
174
185
  ];
175
186
  }
@@ -1,9 +1,21 @@
1
- import { normalizeTaskHistoryQuery, validateTaskEvent, type AppendTaskEvent, type TaskEvent, type TaskHistoryPage, type TaskHistoryQuery } from "../domain/task-event.ts";
1
+ import {
2
+ normalizeTaskEventFeedQuery,
3
+ normalizeTaskHistoryQuery,
4
+ validateTaskEvent,
5
+ type AppendTaskEvent,
6
+ type TaskEvent,
7
+ type TaskEventFeedPage,
8
+ type TaskEventFeedQuery,
9
+ type TaskHistoryPage,
10
+ type TaskHistoryQuery,
11
+ } from "../domain/task-event.ts";
2
12
 
3
13
  export interface TaskEventStore {
4
14
  atomic<T>(operation: () => T): T;
5
15
  append(event: AppendTaskEvent): TaskEvent;
6
16
  history(taskId: string, query?: TaskHistoryQuery): TaskHistoryPage;
17
+ /** Bounded, sequenced, cross-task replay feed -- see TaskEventFeedQuery. */
18
+ feed(query?: TaskEventFeedQuery): TaskEventFeedPage;
7
19
  }
8
20
 
9
21
  export class InMemoryTaskEventStore implements TaskEventStore {
@@ -40,4 +52,14 @@ export class InMemoryTaskEventStore implements TaskEventStore {
40
52
  const events = ordered.slice(0, limit);
41
53
  return { events, ...(ordered.length > limit ? { nextCursor: events.at(-1)!.id } : {}) };
42
54
  }
55
+
56
+ feed(query: TaskEventFeedQuery = {}): TaskEventFeedPage {
57
+ const { limit, cursor, eventTypes } = normalizeTaskEventFeedQuery(query);
58
+ const types = eventTypes ? new Set(eventTypes) : undefined;
59
+ const ordered = this.events
60
+ .filter((event) => (cursor === undefined || event.id > cursor) && (types === undefined || types.has(event.type)))
61
+ .sort((left, right) => left.id - right.id);
62
+ const events = ordered.slice(0, limit);
63
+ return { events, ...(ordered.length > limit ? { nextCursor: events.at(-1)!.id } : {}) };
64
+ }
43
65
  }
@@ -0,0 +1,82 @@
1
+ import { TASK_LEASE_DEFAULT_TTL_MS } from "../constants.ts";
2
+ import { isLeaseExpired, validateLeaseNote, validateLeaseOwner, validateLeaseTtlMs, type TaskLease } from "../domain/task-lease.ts";
3
+
4
+ export interface TaskLeaseStore {
5
+ /**
6
+ * Creates a new lease, or renews the caller's own live lease in place (same token, extended
7
+ * expiry). Throws if a *different* owner already holds a live (non-expired) lease.
8
+ */
9
+ claim(taskId: string, owner: string, ttlMs?: number, note?: string): TaskLease;
10
+ /** Extends an existing live lease's expiry. Throws if no live lease exists, or owner/token do not match the current holder -- a stale or forged renewal must never silently succeed. */
11
+ heartbeat(taskId: string, owner: string, token: string, ttlMs?: number): TaskLease;
12
+ /** No-op (released: false) if no live lease exists. Throws if a live lease exists but owner/token do not match -- releasing a claim you don't hold is a real conflict, not a benign no-op. */
13
+ release(taskId: string, owner: string, token: string): { released: boolean };
14
+ /** The current live lease, or undefined if none exists or it has expired (an expired row reads as absent even before a reap sweep runs). */
15
+ get(taskId: string): TaskLease | undefined;
16
+ /** Deletes every lease row whose leaseExpiresAt is strictly before olderThanIso. Returns how many were removed. */
17
+ reapExpired(olderThanIso: string): number;
18
+ }
19
+
20
+ function newToken(): string {
21
+ return crypto.randomUUID();
22
+ }
23
+
24
+ export class InMemoryTaskLeaseStore implements TaskLeaseStore {
25
+ private readonly leases = new Map<string, TaskLease>();
26
+
27
+ claim(taskId: string, owner: string, ttlMs: number = TASK_LEASE_DEFAULT_TTL_MS, note?: string): TaskLease {
28
+ validateLeaseOwner(owner);
29
+ validateLeaseNote(note);
30
+ validateLeaseTtlMs(ttlMs);
31
+ const now = new Date();
32
+ const nowIso = now.toISOString();
33
+ const current = this.leases.get(taskId);
34
+ if (current && !isLeaseExpired(current, nowIso) && current.owner !== owner) {
35
+ throw new Error(`task "${taskId}" is already leased by "${current.owner}" until ${current.leaseExpiresAt}`);
36
+ }
37
+ const lease: TaskLease = {
38
+ taskId,
39
+ owner,
40
+ token: current && !isLeaseExpired(current, nowIso) && current.owner === owner ? current.token : newToken(),
41
+ claimedAt: current && !isLeaseExpired(current, nowIso) && current.owner === owner ? current.claimedAt : nowIso,
42
+ leaseExpiresAt: new Date(now.getTime() + ttlMs).toISOString(),
43
+ ...(note !== undefined ? { note } : {}),
44
+ };
45
+ this.leases.set(taskId, lease);
46
+ return lease;
47
+ }
48
+
49
+ heartbeat(taskId: string, owner: string, token: string, ttlMs: number = TASK_LEASE_DEFAULT_TTL_MS): TaskLease {
50
+ validateLeaseTtlMs(ttlMs);
51
+ const nowIso = new Date().toISOString();
52
+ const current = this.leases.get(taskId);
53
+ if (!current || isLeaseExpired(current, nowIso)) throw new Error(`task "${taskId}" has no live lease to renew`);
54
+ if (current.owner !== owner || current.token !== token) throw new Error(`lease for task "${taskId}" is held by a different owner/token`);
55
+ const renewed: TaskLease = { ...current, leaseExpiresAt: new Date(Date.now() + ttlMs).toISOString(), heartbeatAt: nowIso };
56
+ this.leases.set(taskId, renewed);
57
+ return renewed;
58
+ }
59
+
60
+ release(taskId: string, owner: string, token: string): { released: boolean } {
61
+ const nowIso = new Date().toISOString();
62
+ const current = this.leases.get(taskId);
63
+ if (!current || isLeaseExpired(current, nowIso)) return { released: false };
64
+ if (current.owner !== owner || current.token !== token) throw new Error(`lease for task "${taskId}" is held by a different owner/token`);
65
+ this.leases.delete(taskId);
66
+ return { released: true };
67
+ }
68
+
69
+ get(taskId: string): TaskLease | undefined {
70
+ const current = this.leases.get(taskId);
71
+ if (!current || isLeaseExpired(current, new Date().toISOString())) return undefined;
72
+ return current;
73
+ }
74
+
75
+ reapExpired(olderThanIso: string): number {
76
+ let removed = 0;
77
+ for (const [taskId, lease] of this.leases) {
78
+ if (lease.leaseExpiresAt < olderThanIso) { this.leases.delete(taskId); removed++; }
79
+ }
80
+ return removed;
81
+ }
82
+ }
package/src/service.ts CHANGED
@@ -6,6 +6,7 @@ import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
6
6
  import { SQLiteArtifactScopeStore } from "./adapters/sqlite-artifact-scope-store.ts";
7
7
  import { SQLiteGraphProjectionStore } from "./adapters/sqlite-graph-projection-store.ts";
8
8
  import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
9
+ import { SQLiteTaskLeaseStore } from "./adapters/sqlite-task-lease-store.ts";
9
10
  import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
10
11
  import { SQLiteTaskScopeStore } from "./adapters/sqlite-task-scope-store.ts";
11
12
  import { SQLiteSessionIdentityStore } from "./adapters/sqlite-session-identity-store.ts";
@@ -339,6 +340,12 @@ function handlers(
339
340
  "tasks.undepend": forwardToModule("tasks.undepend"),
340
341
  "tasks.contain": forwardToModule("tasks.contain"),
341
342
  "tasks.uncontain": forwardToModule("tasks.uncontain"),
343
+ "tasks.claim": forwardToModule("tasks.claim"),
344
+ "tasks.heartbeat_lease": forwardToModule("tasks.heartbeat_lease"),
345
+ "tasks.release_lease": forwardToModule("tasks.release_lease"),
346
+ "tasks.lease": forwardToModule("tasks.lease"),
347
+ "tasks.reap_stale_leases": forwardToModule("tasks.reap_stale_leases"),
348
+ "tasks.event_feed": forwardToModule("tasks.event_feed"),
342
349
  "tasks.reap_stale_focus": forwardToModule("tasks.reap_stale_focus"),
343
350
  "docs.create": forwardToModule("docs.create"),
344
351
  "docs.list": forwardToModule("docs.list"),
@@ -426,7 +433,8 @@ export function createPapyrusService(path: string): PapyrusService {
426
433
  const focus = new SQLiteTaskFocusStore(db);
427
434
  const events = new SQLiteTaskEventStore(db);
428
435
  const scopes = new SQLiteTaskScopeStore(db);
429
- const tasks = new Tasks(artifacts, gates, focus, events, scopes);
436
+ const leases = new SQLiteTaskLeaseStore(db);
437
+ const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases);
430
438
  const notes = new Notes(artifacts);
431
439
  const projections = new SQLiteGraphProjectionStore(db);
432
440
  const artifactScopes = new SQLiteArtifactScopeStore(db);
@@ -13,12 +13,14 @@ import type { Artifact } from "./domain/artifact.ts";
13
13
  import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
14
14
  import { DISCUSSION_SUBTYPE, isDiscussionArtifact, readDiscussionExtra } from "./domain/discussion.ts";
15
15
  import { validateGates, type Gate, type GateResult } from "./domain/gate.ts";
16
- import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
16
+ import type { AppendTaskEvent, TaskEventContext, TaskEventFeedPage, TaskEventFeedQuery, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
17
17
  import { normalizeProjectRoot, taskScopeLabel, type TaskScopeSource, type TaskViewMode, type TaskViewSelection } from "./domain/task-scope.ts";
18
18
  import type { ArtifactStore } from "./ports/artifact-store.ts";
19
19
  import type { GateRunner } from "./ports/gate-runner.ts";
20
20
  import { InMemoryTaskFocusStore, type TaskFocusStatus, type TaskFocusStore } from "./ports/task-focus-store.ts";
21
21
  import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-store.ts";
22
+ import { InMemoryTaskLeaseStore, type TaskLeaseStore } from "./ports/task-lease-store.ts";
23
+ import type { TaskLease } from "./domain/task-lease.ts";
22
24
  import { InMemoryTaskScopeStore, type TaskScopeStore } from "./ports/task-scope-store.ts";
23
25
  import { assertDependencyEdgeAllowed } from "./task-execution.ts";
24
26
 
@@ -126,6 +128,7 @@ export class Tasks {
126
128
  private readonly focusStore: TaskFocusStore = new InMemoryTaskFocusStore(),
127
129
  private readonly events: TaskEventStore = new InMemoryTaskEventStore(),
128
130
  private readonly scopes: TaskScopeStore = new InMemoryTaskScopeStore(),
131
+ private readonly leases: TaskLeaseStore = new InMemoryTaskLeaseStore(),
129
132
  ) {}
130
133
 
131
134
  private require(id: string): Artifact {
@@ -403,6 +406,32 @@ export class Tasks {
403
406
  return this.focusStore.reapStale(cutoff);
404
407
  }
405
408
 
409
+ /** A lease is orthogonal to lifecycle and Focus: claiming a task does not start it, and does not require it to be Focused. */
410
+ claimLease(id: string, owner: string, ttlMs?: number, note?: string): TaskLease {
411
+ this.require(id);
412
+ return this.leases.claim(id, owner, ttlMs, note);
413
+ }
414
+
415
+ heartbeatLease(id: string, owner: string, token: string, ttlMs?: number): TaskLease {
416
+ this.require(id);
417
+ return this.leases.heartbeat(id, owner, token, ttlMs);
418
+ }
419
+
420
+ /** Idempotent for an already-absent or already-expired lease, matching undepend/uncontain's precedent -- never throws merely because there was nothing left to release. */
421
+ releaseLease(id: string, owner: string, token: string): { released: boolean } {
422
+ this.require(id);
423
+ return this.leases.release(id, owner, token);
424
+ }
425
+
426
+ getLease(id: string): TaskLease | undefined {
427
+ this.require(id);
428
+ return this.leases.get(id);
429
+ }
430
+
431
+ reapStaleLeases(now: () => string = () => new Date().toISOString()): number {
432
+ return this.leases.reapExpired(now());
433
+ }
434
+
406
435
  transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
407
436
  return this.events.atomic(() => {
408
437
  const task = this.require(id);
@@ -459,6 +488,11 @@ export class Tasks {
459
488
  return this.events.history(id, query);
460
489
  }
461
490
 
491
+ /** Bounded, sequenced, cross-task replay feed -- see TaskEventFeedQuery. Not scoped to any one task, unlike history(). */
492
+ eventFeed(query: TaskEventFeedQuery = {}): TaskEventFeedPage {
493
+ return this.events.feed(query);
494
+ }
495
+
462
496
  setChecklist(id: string, checklist: Checklist): Artifact {
463
497
  const task = this.require(id);
464
498
  return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
@@ -498,10 +532,16 @@ export class Tasks {
498
532
  /** Idempotent: undepending an already-absent dependency is a no-op. Never starts, completes, or focuses work — only removes the edge. */
499
533
  undepend(id: string, dependencyId: string, context: TaskEventContext = {}): Artifact {
500
534
  return this.events.atomic(() => {
501
- this.require(id);
502
- this.require(dependencyId);
535
+ const task = this.require(id);
536
+ const dependency = this.require(dependencyId);
503
537
  const removed = this.artifacts.unlink({ from: id, relation: "depends_on", to: dependencyId }, context);
504
538
  if (removed) this.appendEvent({ taskId: id, type: "dependency_removed", reason: context.reason }, context);
539
+ // Only meaningful if the removed edge was itself unmet -- removing an already-satisfied
540
+ // dependency, or removing one from a task that was already unblocked, changes nothing.
541
+ if (removed && task.status === "todo" && dependency.status !== "done") {
542
+ const stillBlocking = this.dependencyIds(id).filter((remainingId) => this.require(remainingId).status !== "done");
543
+ if (stillBlocking.length === 0) this.appendEvent({ taskId: id, type: "became_ready" }, context);
544
+ }
505
545
  return this.show(id);
506
546
  });
507
547
  }
@@ -676,6 +716,9 @@ export class Tasks {
676
716
  blocked.push({ artifact: successor, dependencyIds });
677
717
  continue;
678
718
  }
719
+ // Every successor whose last unmet dependency was this completion, not only the one
720
+ // auto-focused below -- readiness is a real state change for all of them.
721
+ this.appendEvent({ taskId: successor.id, type: "became_ready" }, context);
679
722
  if (options.focusSuccessor !== false && !focused) {
680
723
  this.focusStore.set(successor.id, context.sessionId);
681
724
  focused = successor;