@danypops/papyrus 0.60.2 → 0.60.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.60.2",
3
+ "version": "0.60.3",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -64,6 +64,11 @@ export interface UpdateArtifactInput {
64
64
  alias?: string;
65
65
  }
66
66
 
67
+ export interface ArtifactQueryCursor {
68
+ createdAt: string;
69
+ id: string;
70
+ }
71
+
67
72
  export interface ArtifactQuery {
68
73
  kind?: string;
69
74
  status?: string;
@@ -74,6 +79,10 @@ export interface ArtifactQuery {
74
79
  labels?: string[];
75
80
  extraEquals?: Record<string, string | number | boolean>;
76
81
  limit?: number;
82
+ /** Stable inventory ordering, independent of content updates. Defaults to updated_at descending. */
83
+ order?: "updated_desc" | "created_desc";
84
+ /** Exclusive keyset cursor for created_desc ordering. */
85
+ after?: ArtifactQueryCursor;
77
86
  /** Trashed artifacts (see artifact-trash.ts) are excluded from every query by default; set true to include them, e.g. for a trash-listing view. */
78
87
  includeTrashed?: boolean;
79
88
  /**
@@ -59,6 +59,34 @@ const listCommand = buildCommand({
59
59
  docs: { brief: "List open (draft/active) notes, or a specific status" },
60
60
  });
61
61
 
62
+ const listPageCommand = buildCommand({
63
+ func: async function (
64
+ this: NoteContext,
65
+ flags: { status?: string; text?: string; limit?: number; cursor?: string; allProjects?: boolean },
66
+ ) {
67
+ const page = (await this.client.call("notes.list_page", {
68
+ ...(flags.allProjects ? {} : { project_root: this.projectRoot }),
69
+ ...(flags.status ? { status: flags.status } : {}),
70
+ ...(flags.text ? { text: flags.text } : {}),
71
+ ...(flags.limit === undefined ? {} : { limit: flags.limit }),
72
+ ...(flags.cursor ? { cursor: flags.cursor } : {}),
73
+ })) as { items: CliArtifact[]; nextCursor?: string };
74
+ const lines = page.items.map((note) => `[${note.status}] ${artifactLabel(note)}`);
75
+ if (page.nextCursor) lines.push(`Next cursor: ${page.nextCursor}`);
76
+ renderResult.call(this, page, lines.length > 0 ? lines.join("\n") : "No open notes.");
77
+ },
78
+ parameters: {
79
+ flags: {
80
+ status: { brief: "Filter by status (draft|active|archived)", kind: "parsed", parse: String, placeholder: "status", optional: true },
81
+ text: { brief: "Substring match against title/body", kind: "parsed", parse: String, placeholder: "text", optional: true },
82
+ limit: { brief: "Page size", kind: "parsed", parse: numberParser, optional: true },
83
+ cursor: { brief: "Opaque nextCursor from the preceding page", kind: "parsed", parse: String, placeholder: "cursor", optional: true },
84
+ allProjects: { brief: "Inventory Notes across every project", kind: "boolean", optional: true },
85
+ },
86
+ },
87
+ docs: { brief: "Cursor-paginate a stable Note inventory" },
88
+ });
89
+
62
90
  const showCommand = buildCommand({
63
91
  func: async function (this: NoteContext, _flags: Record<string, never>, id: string) {
64
92
  const result = (await this.client.call("notes.show", { id, project_root: this.projectRoot })) as CliArtifact;
@@ -168,6 +196,7 @@ const app = buildApplication(
168
196
  routes: {
169
197
  capture: captureCommand,
170
198
  list: listCommand,
199
+ page: listPageCommand,
171
200
  show: showCommand,
172
201
  history: historyCommand,
173
202
  consume: consumeCommand,
@@ -456,6 +456,56 @@ const listCommand = buildCommand({
456
456
  docs: { brief: "List Tasks" },
457
457
  });
458
458
 
459
+ const listPageCommand = buildCommand({
460
+ func: async function (
461
+ this: TaskContext,
462
+ flags: {
463
+ status?: string;
464
+ text?: string;
465
+ limit?: number;
466
+ labelsJson?: string[];
467
+ scope?: "project" | "graph" | "all";
468
+ rootTaskId?: string;
469
+ sessionId?: string;
470
+ cursor?: string;
471
+ },
472
+ ) {
473
+ const page = await this.client.call<Record<string, unknown>, { items: CliArtifact[]; nextCursor?: string }>("tasks.list_page", {
474
+ status: flags.status,
475
+ text: flags.text,
476
+ limit: flags.limit,
477
+ labels: flags.labelsJson,
478
+ project_root: this.projectRoot,
479
+ scope: flags.scope,
480
+ root_task_id: flags.rootTaskId,
481
+ session_id: flags.sessionId,
482
+ cursor: flags.cursor,
483
+ });
484
+ const rows = page.items.map((row) => artifactLabel(row));
485
+ if (page.nextCursor) rows.push(`Next cursor: ${page.nextCursor}`);
486
+ render.call(this, page, rows.length === 0 ? "No tasks found." : rows.join("\n"));
487
+ },
488
+ parameters: {
489
+ flags: {
490
+ status: { brief: "Filter by status", kind: "parsed", parse: String, placeholder: "status", optional: true },
491
+ text: { brief: "Substring match against title/body", kind: "parsed", parse: String, placeholder: "text", optional: true },
492
+ limit: { brief: "Page size", kind: "parsed", parse: numberParser, optional: true },
493
+ labelsJson: {
494
+ brief: "JSON string array of labels to filter by",
495
+ kind: "parsed",
496
+ parse: parseStringArray,
497
+ placeholder: "json",
498
+ optional: true,
499
+ },
500
+ scope: { brief: "project|graph|all", kind: "enum", values: ["project", "graph", "all"], optional: true },
501
+ rootTaskId: { brief: "Root task id, required with graph scope", kind: "parsed", parse: String, placeholder: "id", optional: true },
502
+ sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true },
503
+ cursor: { brief: "Opaque nextCursor from the preceding page", kind: "parsed", parse: String, placeholder: "cursor", optional: true },
504
+ },
505
+ },
506
+ docs: { brief: "Cursor-paginate a stable Task inventory" },
507
+ });
508
+
459
509
  const showCommand = buildCommand({
460
510
  func: async function (this: TaskContext, _flags: Record<string, never>, id: string) {
461
511
  const artifact = await this.client.call<Record<string, unknown>, CliArtifact>("tasks.show", { id });
@@ -963,6 +1013,7 @@ const app = buildApplication(
963
1013
  "mutation-status": mutationStatusCommand,
964
1014
  create: createCommand,
965
1015
  list: listCommand,
1016
+ page: listPageCommand,
966
1017
  show: showCommand,
967
1018
  "run-gates": runGatesCommand,
968
1019
  "set-checklist": setChecklistCommand,
package/src/cli.ts CHANGED
@@ -152,6 +152,7 @@ const USAGE = `Usage:
152
152
  papyrus playbooks undepend <id> <dependency-id> [--json]
153
153
  papyrus notes capture <request> [--title <title>] [--json]
154
154
  papyrus notes list [--status <draft|active|archived>] [--text <query>] [--limit <count>] [--json]
155
+ papyrus notes page [--status <draft|active|archived>] [--text <query>] [--limit <count>] [--cursor <cursor>] [--all-projects] [--json]
155
156
  papyrus notes show <id> [--json]
156
157
  papyrus notes consume <id> [--reason <reason>] [--json]
157
158
  papyrus notes promote <id> <target-id> [--reason <reason>] [--json]
@@ -204,6 +205,7 @@ const USAGE = `Usage:
204
205
  papyrus tasks uncontain <parent-id> <child-id> [--reason <reason>] [--session-id <id>] [--json]
205
206
  papyrus tasks create --title <title> [--body <body>] [--status <status>] [--labels-json <json>] [--extra-json <json>] [--gates-json <json>] [--checklist-json <json>] [--template-id <id>] [--parent-id <id>] [--depends-on-json <json>] [--session-id <id>] [--json]
206
207
  papyrus tasks list [--status <status>] [--text <query>] [--limit <count>] [--scope <project|graph|all>] [--root-task-id <id>] [--session-id <id>] [--json]
208
+ papyrus tasks page [--status <status>] [--text <query>] [--limit <count>] [--cursor <cursor>] [--scope <project|graph|all>] [--root-task-id <id>] [--session-id <id>] [--json]
207
209
  papyrus tasks show <id> [--json]
208
210
  papyrus tasks run-gates <id> [--json]
209
211
  papyrus tasks set-checklist <id> --checklist-json <json> [--json]
package/src/constants.ts CHANGED
@@ -308,6 +308,9 @@ export const SESSION_IDENTITY_MAX_ROWS = 2_000;
308
308
  export const ARTIFACT_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
309
309
  /** Persisted project and focused-graph Task view bounds. */
310
310
  export const TASK_SCOPE_MAX_TASKS = 1_000;
311
+ /** Cursor-paged Task inventory bounds; pages stay comfortably below Vehicle response limits. */
312
+ export const TASK_LIST_PAGE_DEFAULT_LIMIT = 100;
313
+ export const TASK_LIST_PAGE_MAX_LIMIT = 200;
311
314
  /** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
312
315
  export const ARTIFACT_SCOPE_MAX_ARTIFACTS = 1_000;
313
316
  /** How many distinct registered projects a single Doc/Rule/Playbook may belong to at once, in "projects" scope mode. Kept alongside ARTIFACT_SCOPE_MAX_MEMBERS_PER_ARTIFACT (identical value) for the pure-project call sites/tests that predate mixed project+group membership. */
@@ -60,6 +60,21 @@ export function registerNotesVehicleOperations(registry: VehicleRegistry, notes:
60
60
  (input) => input,
61
61
  );
62
62
 
63
+ define(
64
+ "list_page",
65
+ "Cursor-paginates a stable Note inventory. Omit project_root only for an intentional cross-project audit; use nextCursor until absent.",
66
+ "read",
67
+ {
68
+ project_root: stringProp,
69
+ status: { type: "string", enum: ["draft", "active", "archived"] },
70
+ text: stringProp,
71
+ limit: numberProp,
72
+ cursor: stringProp,
73
+ },
74
+ [],
75
+ (input) => input,
76
+ );
77
+
63
78
  define(
64
79
  "show",
65
80
  "Shows one note by id or title.",
@@ -246,6 +246,7 @@ const readSchemaProps = {
246
246
 
247
247
  /** list-only: opts into full Artifact bodies instead of the lean summarizeArtifact() default (modules/tasks.ts). */
248
248
  const listSchemaProps = { ...readSchemaProps, full: booleanProp };
249
+ const listPageSchemaProps = { ...listSchemaProps, cursor: stringProp };
249
250
 
250
251
  /** Same gate/checklist narrative lines the removed tool built client-side. */
251
252
  function completionContentText(labels: Map<string, string>, result: TaskCompletion): string {
@@ -447,6 +448,18 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
447
448
  },
448
449
  );
449
450
 
451
+ define(
452
+ "list_page",
453
+ "Cursor-paginates a stable creation-ordered Task inventory. Use nextCursor until it is absent. Existing tasks.list remains the lean array convenience API.",
454
+ "read",
455
+ listPageSchemaProps,
456
+ ["project_root"],
457
+ (input) => {
458
+ const rootTaskId = resolveRootTaskId(artifacts, tasks, input.project_root as string, input.root_task_id, input.root_task_name);
459
+ return { ...input, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) };
460
+ },
461
+ );
462
+
450
463
  define(
451
464
  "graph",
452
465
  "Returns the full task graph (nodes with parent/child/dependency ids) for the requested scope. project_root is required.",
@@ -24,6 +24,7 @@ const MODULE_ID = "notes";
24
24
  export const NOTES_OPERATION_NAMES = [
25
25
  "notes.capture",
26
26
  "notes.list",
27
+ "notes.list_page",
27
28
  "notes.show",
28
29
  "notes.history",
29
30
  "notes.consume",
@@ -57,6 +58,15 @@ export function notesOperations(notes: Notes): OperationDefinition[] {
57
58
  limit: optionalNumber(input, "limit"),
58
59
  }),
59
60
  ),
61
+ define("notes.list_page", (input: OperationInput) =>
62
+ notes.listPage({
63
+ projectRoot: optionalString(input, "project_root"),
64
+ status: optionalString(input, "status") as "draft" | "active" | "archived" | undefined,
65
+ text: optionalString(input, "text"),
66
+ limit: optionalNumber(input, "limit"),
67
+ cursor: optionalString(input, "cursor"),
68
+ }),
69
+ ),
60
70
  define("notes.show", (input: OperationInput) => notes.show(string(input, "id"), string(input, "project_root"))),
61
71
  define("notes.history", (input: OperationInput) =>
62
72
  notes.history(string(input, "id"), string(input, "project_root"), {
@@ -54,6 +54,7 @@ const taskFilter = (input: OperationInput) => ({
54
54
  rootTaskId: optionalString(input, "root_task_id"),
55
55
  sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
56
56
  labels: optionalStringArray(input, "labels"),
57
+ cursor: optionalString(input, "cursor"),
57
58
  });
58
59
 
59
60
  /**
@@ -69,6 +70,7 @@ export const TASKS_OPERATION_NAMES = [
69
70
  "tasks.create",
70
71
  "tasks.update",
71
72
  "tasks.list",
73
+ "tasks.list_page",
72
74
  "tasks.graph",
73
75
  "tasks.plan",
74
76
  "tasks.show",
@@ -164,6 +166,13 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
164
166
  const rows = tasks.list(taskFilter(input));
165
167
  return optionalBoolean(input, "full") === true ? rows : rows.map(summarizeArtifact);
166
168
  }),
169
+ define("tasks.list_page", (input: OperationInput) => {
170
+ const page = tasks.listPage(taskFilter(input));
171
+ return {
172
+ ...page,
173
+ items: optionalBoolean(input, "full") === true ? page.items : page.items.map(summarizeArtifact),
174
+ };
175
+ }),
167
176
  define("tasks.graph", (input: OperationInput) => tasks.graph(taskFilter(input))),
168
177
  define("tasks.plan", (input: OperationInput) => projectTaskExecution(tasks.graph(taskFilter(input)))),
169
178
  define("tasks.show", (input: OperationInput) => tasks.show(string(input, "id"))),
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { Artifact } from "../artifact/artifact.ts";
2
3
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
3
4
  import { requireAtomicArtifactStore } from "../artifact/atomic-artifact-store.ts";
@@ -37,6 +38,27 @@ export interface ListNotesInput {
37
38
  limit?: number;
38
39
  }
39
40
 
41
+ export interface ListNotesPageInput {
42
+ /** Omit only for an explicit cross-project inventory. */
43
+ projectRoot?: string;
44
+ status?: "draft" | "active" | "archived";
45
+ text?: string;
46
+ limit?: number;
47
+ cursor?: string;
48
+ }
49
+
50
+ export interface NotesPage {
51
+ items: Artifact[];
52
+ nextCursor?: string;
53
+ }
54
+
55
+ interface NotesPageCursor {
56
+ v: 1;
57
+ createdAt: string;
58
+ id: string;
59
+ filterHash: string;
60
+ }
61
+
40
62
  export interface ArchiveNoteInput extends NoteProvenance {
41
63
  projectRoot: string;
42
64
  disposition: NoteDisposition;
@@ -54,6 +76,29 @@ function optionalBounded(value: string | undefined, field: string, maximum: numb
54
76
  return requiredBounded(value, field, maximum);
55
77
  }
56
78
 
79
+ function notesPageFilterHash(input: ListNotesPageInput, projectRoot: string | undefined): string {
80
+ return createHash("sha256")
81
+ .update(JSON.stringify({ projectRoot, status: input.status, text: input.text }))
82
+ .digest("base64url");
83
+ }
84
+
85
+ function decodeNotesPageCursor(cursor: string | undefined, filterHash: string): NotesPageCursor | undefined {
86
+ if (cursor === undefined) return undefined;
87
+ try {
88
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Partial<NotesPageCursor>;
89
+ if (parsed.v !== 1 || !parsed.createdAt || !parsed.id || parsed.filterHash !== filterHash) throw new Error("invalid cursor");
90
+ return parsed as NotesPageCursor;
91
+ } catch {
92
+ throw new Error("notes page cursor is invalid or does not match the requested filters");
93
+ }
94
+ }
95
+
96
+ function encodeNotesPageCursor(note: Artifact, filterHash: string): string {
97
+ return Buffer.from(JSON.stringify({ v: 1, createdAt: note.created_at, id: note.id, filterHash } satisfies NotesPageCursor)).toString(
98
+ "base64url",
99
+ );
100
+ }
101
+
57
102
  function noteTitle(body: string, requested?: string): string {
58
103
  if (requested !== undefined) return requiredBounded(requested, "note title", NOTE_TITLE_MAX_CHARACTERS);
59
104
  const firstLine = body.split(/\r?\n/, 1)[0]!.replace(/\s+/g, " ").trim();
@@ -110,6 +155,33 @@ export class Notes {
110
155
  });
111
156
  }
112
157
 
158
+ /** Cursor-paged inventory; omitting projectRoot intentionally enumerates notes across projects. */
159
+ listPage(input: ListNotesPageInput): NotesPage {
160
+ const projectRoot =
161
+ input.projectRoot === undefined ? undefined : requiredBounded(input.projectRoot, "project_root", TASK_PROJECT_ROOT_MAX_LENGTH);
162
+ const limit = input.limit ?? NOTE_LIST_DEFAULT_LIMIT;
163
+ if (!Number.isInteger(limit) || limit < 1 || limit > NOTE_LIST_MAX_LIMIT) {
164
+ throw new Error(`note limit must be an integer from 1 to ${NOTE_LIST_MAX_LIMIT}`);
165
+ }
166
+ const filterHash = notesPageFilterHash(input, projectRoot);
167
+ const cursor = decodeNotesPageCursor(input.cursor, filterHash);
168
+ const candidates = this.artifacts.query({
169
+ kind: "doc",
170
+ subtype: NOTE_SUBTYPE,
171
+ ...(input.status ? { status: input.status } : { statuses: ["draft", "active"] }),
172
+ ...(input.text ? { text: input.text } : {}),
173
+ ...(projectRoot ? { extraEquals: { projectRoot } } : {}),
174
+ order: "created_desc",
175
+ ...(cursor ? { after: { createdAt: cursor.createdAt, id: cursor.id } } : {}),
176
+ limit: limit + 1,
177
+ });
178
+ const items = candidates.slice(0, limit);
179
+ return {
180
+ items,
181
+ ...(candidates.length > limit && items.length > 0 ? { nextCursor: encodeNotesPageCursor(items.at(-1)!, filterHash) } : {}),
182
+ };
183
+ }
184
+
113
185
  show(id: string, projectRoot: string): Artifact {
114
186
  const note = this.requireNote(id);
115
187
  this.requireProject(note, projectRoot);
package/src/ops.ts CHANGED
@@ -367,8 +367,14 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
367
367
  conditions.push("json_extract(extra, ?) = ?");
368
368
  params.push(`$.${key}`, value);
369
369
  }
370
+ if (filter.after) {
371
+ if (filter.order !== "created_desc") throw new Error("artifact query cursor requires created_desc ordering");
372
+ if (!filter.after.createdAt || !filter.after.id) throw new Error("artifact query cursor is invalid");
373
+ conditions.push("(created_at < ? OR (created_at = ? AND id > ?))");
374
+ params.push(filter.after.createdAt, filter.after.createdAt, filter.after.id);
375
+ }
370
376
  if (conditions.length) sql += ` WHERE ${conditions.join(" AND ")}`;
371
- sql += " ORDER BY updated_at DESC";
377
+ sql += filter.order === "created_desc" ? " ORDER BY created_at DESC, id ASC" : " ORDER BY updated_at DESC, id ASC";
372
378
  if (filter.limit !== undefined) {
373
379
  if (!Number.isInteger(filter.limit) || filter.limit < 1) throw new Error("artifact query limit must be a positive integer");
374
380
  sql += " LIMIT ?";
package/src/service.ts CHANGED
@@ -409,6 +409,7 @@ function handlers(
409
409
  "tasks.create": forwardToModule("tasks.create"),
410
410
  "tasks.update": forwardToModule("tasks.update"),
411
411
  "tasks.list": forwardToModule("tasks.list"),
412
+ "tasks.list_page": forwardToModule("tasks.list_page"),
412
413
  "tasks.graph": forwardToModule("tasks.graph"),
413
414
  "tasks.plan": forwardToModule("tasks.plan"),
414
415
  "tasks.show": forwardToModule("tasks.show"),
@@ -487,6 +488,7 @@ function handlers(
487
488
  "docs.update": forwardToModule("docs.update"),
488
489
  "notes.capture": forwardToModule("notes.capture"),
489
490
  "notes.list": forwardToModule("notes.list"),
491
+ "notes.list_page": forwardToModule("notes.list_page"),
490
492
  "notes.show": forwardToModule("notes.show"),
491
493
  "notes.history": forwardToModule("notes.history"),
492
494
  "notes.consume": forwardToModule("notes.consume"),
@@ -11,6 +11,8 @@ import {
11
11
  TASK_EXECUTION_MAX_NODES,
12
12
  TASK_LABEL_MAX_COUNT,
13
13
  TASK_LABEL_MAX_LENGTH,
14
+ TASK_LIST_PAGE_DEFAULT_LIMIT,
15
+ TASK_LIST_PAGE_MAX_LIMIT,
14
16
  TASK_SCOPE_MAX_TASKS,
15
17
  TASK_TITLE_MAX_LENGTH,
16
18
  } from "../constants.ts";
@@ -98,6 +100,15 @@ export interface TaskFilter {
98
100
  labels?: string[];
99
101
  }
100
102
 
103
+ export interface TaskPageFilter extends TaskFilter {
104
+ cursor?: string;
105
+ }
106
+
107
+ export interface TaskPage {
108
+ items: Artifact[];
109
+ nextCursor?: string;
110
+ }
111
+
101
112
  export type TaskStatus = TaskLifecycleStatus;
102
113
 
103
114
  export interface TaskMutationMetadata {
@@ -207,6 +218,45 @@ function canonicalJson(value: unknown): string {
207
218
  return JSON.stringify(value) ?? "null";
208
219
  }
209
220
 
221
+ interface TaskPageCursor {
222
+ v: 1;
223
+ createdAt: string;
224
+ id: string;
225
+ filterHash: string;
226
+ }
227
+
228
+ function taskPageFilterHash(filter: TaskFilter, selection: TaskViewSelection): string {
229
+ return createHash("sha256")
230
+ .update(
231
+ canonicalJson({
232
+ status: filter.status,
233
+ text: filter.text,
234
+ labels: [...(filter.labels ?? [])].sort(),
235
+ mode: selection.mode,
236
+ projectRoot: selection.projectRoot,
237
+ rootTaskId: selection.rootTaskId,
238
+ }),
239
+ )
240
+ .digest("base64url");
241
+ }
242
+
243
+ function encodeTaskPageCursor(task: Artifact, filterHash: string): string {
244
+ return Buffer.from(JSON.stringify({ v: 1, createdAt: task.created_at, id: task.id, filterHash } satisfies TaskPageCursor)).toString(
245
+ "base64url",
246
+ );
247
+ }
248
+
249
+ function decodeTaskPageCursor(cursor: string | undefined, filterHash: string): TaskPageCursor | undefined {
250
+ if (cursor === undefined) return undefined;
251
+ try {
252
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Partial<TaskPageCursor>;
253
+ if (parsed.v !== 1 || !parsed.createdAt || !parsed.id || parsed.filterHash !== filterHash) throw new Error("invalid cursor");
254
+ return parsed as TaskPageCursor;
255
+ } catch {
256
+ throw new Error("task page cursor is invalid or does not match the requested filters");
257
+ }
258
+ }
259
+
210
260
  export class Tasks {
211
261
  constructor(
212
262
  private readonly artifacts: ArtifactStore,
@@ -428,6 +478,52 @@ export class Tasks {
428
478
  .slice(0, limit);
429
479
  }
430
480
 
481
+ /** Stable, cursor-paged Task inventory. Creation order is immutable, so content updates cannot move an item between pages. */
482
+ listPage(filter: TaskPageFilter = {}): TaskPage {
483
+ const selection = this.projectScope.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
484
+ const limit = filter.limit ?? TASK_LIST_PAGE_DEFAULT_LIMIT;
485
+ if (!Number.isInteger(limit) || limit < 1 || limit > TASK_LIST_PAGE_MAX_LIMIT) {
486
+ throw new Error(`task page limit must be between 1 and ${TASK_LIST_PAGE_MAX_LIMIT}`);
487
+ }
488
+ const filterHash = taskPageFilterHash(filter, selection);
489
+ const cursor = decodeTaskPageCursor(filter.cursor, filterHash);
490
+ let candidates: Artifact[];
491
+ if (selection.mode === "all") {
492
+ candidates = this.artifacts.query({
493
+ kind: "task",
494
+ excludeSubtype: DISCUSSION_SUBTYPE,
495
+ status: filter.status,
496
+ text: filter.text,
497
+ labels: filter.labels,
498
+ order: "created_desc",
499
+ ...(cursor ? { after: { createdAt: cursor.createdAt, id: cursor.id } } : {}),
500
+ limit: limit + 1,
501
+ });
502
+ } else {
503
+ const ids = this.scopes.taskIds(selection.projectRoot, TASK_SCOPE_MAX_TASKS + 1);
504
+ if (ids.length > TASK_SCOPE_MAX_TASKS) throw new Error(`task project scope exceeds ${TASK_SCOPE_MAX_TASKS} tasks`);
505
+ const selectedIds = selection.mode === "graph" ? this.descendantIds(selection.rootTaskId!, ids) : new Set(ids);
506
+ const text = filter.text?.toLowerCase();
507
+ const labels = filter.labels ?? [];
508
+ candidates = this.artifacts
509
+ .query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE, ids: [...selectedIds] })
510
+ .filter((task) => filter.status === undefined || task.status === filter.status)
511
+ .filter((task) => text === undefined || task.title.toLowerCase().includes(text) || task.body.toLowerCase().includes(text))
512
+ .filter((task) => labels.every((label) => task.labels.includes(label)))
513
+ .sort((left, right) => right.created_at.localeCompare(left.created_at) || left.id.localeCompare(right.id))
514
+ .filter(
515
+ (task) =>
516
+ cursor === undefined || task.created_at < cursor.createdAt || (task.created_at === cursor.createdAt && task.id > cursor.id),
517
+ )
518
+ .slice(0, limit + 1);
519
+ }
520
+ const items = candidates.slice(0, limit);
521
+ return {
522
+ items,
523
+ ...(candidates.length > limit && items.length > 0 ? { nextCursor: encodeTaskPageCursor(items.at(-1)!, filterHash) } : {}),
524
+ };
525
+ }
526
+
431
527
  scopeSelection(projectRoot?: string, mode?: TaskViewMode, rootTaskId?: string): TaskViewSelection {
432
528
  return this.projectScope.scopeSelection(projectRoot, mode, rootTaskId);
433
529
  }