@danypops/papyrus 0.60.1 → 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.
@@ -0,0 +1,340 @@
1
+ import type { CommandContext } from "@stricli/core";
2
+ import { buildApplication, buildCommand, buildRouteMap, numberParser } from "@stricli/core";
3
+ import type { PapyrusClient } from "../client.ts";
4
+ import type { OperationName } from "../service.ts";
5
+ import { artifactLabel, type CliArtifact } from "./shared.ts";
6
+ import { runStricliToString } from "./stricli-run.ts";
7
+
8
+ type BindersClient = Pick<PapyrusClient, "call">;
9
+
10
+ interface BindersContext extends CommandContext {
11
+ readonly client: BindersClient;
12
+ readonly json: boolean;
13
+ }
14
+
15
+ function parseStringArray(value: string): string[] {
16
+ const parsed = JSON.parse(value) as unknown;
17
+ if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) throw new Error("value must be a JSON string array");
18
+ return parsed as string[];
19
+ }
20
+
21
+ function render(this: BindersContext, result: unknown, human: string): void {
22
+ this.process.stdout.write(this.json ? JSON.stringify(result) : human);
23
+ }
24
+
25
+ const createCommand = buildCommand({
26
+ func: async function (
27
+ this: BindersContext,
28
+ flags: { title: string; labelsJson?: string[]; parentId?: string; projectRoot?: string; projectsJson?: string[] },
29
+ ) {
30
+ const binder = await this.client.call<Record<string, unknown>, CliArtifact>("binders.create", {
31
+ title: flags.title,
32
+ labels: flags.labelsJson,
33
+ parent_id: flags.parentId,
34
+ project_root: flags.projectRoot,
35
+ projects: flags.projectsJson,
36
+ });
37
+ render.call(this, binder, `Created Binder: ${artifactLabel(binder)}`);
38
+ },
39
+ parameters: {
40
+ flags: {
41
+ title: { brief: "Binder name", kind: "parsed", parse: String, placeholder: "text" },
42
+ labelsJson: {
43
+ brief: "JSON string array of inherited labels",
44
+ kind: "parsed",
45
+ parse: parseStringArray,
46
+ placeholder: "json",
47
+ optional: true,
48
+ },
49
+ parentId: { brief: "Parent Binder id", kind: "parsed", parse: String, placeholder: "id", optional: true },
50
+ projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true },
51
+ projectsJson: { brief: "JSON project-reference array", kind: "parsed", parse: parseStringArray, placeholder: "json", optional: true },
52
+ },
53
+ },
54
+ docs: { brief: "Create a Binder" },
55
+ });
56
+
57
+ const listCommand = buildCommand({
58
+ func: async function (this: BindersContext, flags: { text?: string; limit?: number; projectRoot?: string; applicable?: boolean }) {
59
+ const rows = await this.client.call<Record<string, unknown>, CliArtifact[]>("binders.list", {
60
+ text: flags.text,
61
+ limit: flags.limit,
62
+ project_root: flags.projectRoot,
63
+ applicable: flags.applicable,
64
+ });
65
+ render.call(this, rows, rows.length === 0 ? "No Binders found." : rows.map(artifactLabel).join("\n"));
66
+ },
67
+ parameters: {
68
+ flags: {
69
+ text: { brief: "Substring match", kind: "parsed", parse: String, placeholder: "text", optional: true },
70
+ limit: { brief: "Maximum Binders", kind: "parsed", parse: numberParser, optional: true },
71
+ projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true },
72
+ applicable: { brief: "Include global and project-applicable Binders", kind: "boolean", optional: true },
73
+ },
74
+ },
75
+ docs: { brief: "List Binders" },
76
+ });
77
+
78
+ interface CliBinderTree {
79
+ nodes: Array<{ binder: CliArtifact; path: string; effectiveLabels: string[] }>;
80
+ artifacts: Array<{ artifactId: string; binderId?: string; inheritedLabels: string[]; effectiveLabels: string[] }>;
81
+ }
82
+
83
+ const treeCommand = buildCommand({
84
+ func: async function (this: BindersContext, flags: { projectRoot?: string; artifactIdsJson?: string[] }) {
85
+ const tree = await this.client.call<Record<string, unknown>, CliBinderTree>("binders.tree", {
86
+ project_root: flags.projectRoot,
87
+ artifact_ids: flags.artifactIdsJson,
88
+ });
89
+ const lines = tree.nodes.map((node) => `${node.path}${node.effectiveLabels.length ? ` [${node.effectiveLabels.join(", ")}]` : ""}`);
90
+ render.call(this, tree, lines.length === 0 ? "/ (no Binders)" : lines.join("\n"));
91
+ },
92
+ parameters: {
93
+ flags: {
94
+ projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true },
95
+ artifactIdsJson: {
96
+ brief: "JSON artifact-id array to project",
97
+ kind: "parsed",
98
+ parse: parseStringArray,
99
+ placeholder: "json",
100
+ optional: true,
101
+ },
102
+ },
103
+ },
104
+ docs: { brief: "Show Binder hierarchy and effective labels" },
105
+ });
106
+
107
+ const showCommand = buildCommand({
108
+ func: async function (this: BindersContext, flags: { projectRoot?: string }, id: string) {
109
+ const node = await this.client.call<Record<string, unknown>, { binder: CliArtifact; path: string; effectiveLabels: string[] }>(
110
+ "binders.show",
111
+ {
112
+ id,
113
+ project_root: flags.projectRoot,
114
+ },
115
+ );
116
+ render.call(this, node, `${node.path}${node.effectiveLabels.length ? ` [${node.effectiveLabels.join(", ")}]` : ""}`);
117
+ },
118
+ parameters: {
119
+ flags: { projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true } },
120
+ positional: { kind: "tuple", parameters: [{ brief: "Binder id", parse: String, placeholder: "id" }] },
121
+ },
122
+ docs: { brief: "Show one Binder" },
123
+ });
124
+
125
+ const updateCommand = buildCommand({
126
+ func: async function (this: BindersContext, flags: { title?: string; labelsJson?: string[]; projectRoot?: string }, id: string) {
127
+ if (flags.title === undefined && flags.labelsJson === undefined) throw new Error("binders update requires --title or --labels-json");
128
+ const binder = await this.client.call<Record<string, unknown>, CliArtifact>("binders.update", {
129
+ id,
130
+ title: flags.title,
131
+ labels: flags.labelsJson,
132
+ project_root: flags.projectRoot,
133
+ });
134
+ render.call(this, binder, artifactLabel(binder));
135
+ },
136
+ parameters: {
137
+ flags: {
138
+ title: { brief: "New Binder name", kind: "parsed", parse: String, placeholder: "text", optional: true },
139
+ labelsJson: { brief: "Replacement direct-label array", kind: "parsed", parse: parseStringArray, placeholder: "json", optional: true },
140
+ projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true },
141
+ },
142
+ positional: { kind: "tuple", parameters: [{ brief: "Binder id", parse: String, placeholder: "id" }] },
143
+ },
144
+ docs: { brief: "Rename or relabel a Binder" },
145
+ });
146
+
147
+ const moveCommand = buildCommand({
148
+ func: async function (this: BindersContext, flags: { projectRoot?: string }, id: string, parentId?: string) {
149
+ const node = await this.client.call<Record<string, unknown>, { path: string }>("binders.move", {
150
+ id,
151
+ parent_id: parentId,
152
+ project_root: flags.projectRoot,
153
+ });
154
+ render.call(this, node, `Moved ${id} to ${node.path}`);
155
+ },
156
+ parameters: {
157
+ flags: { projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true } },
158
+ positional: {
159
+ kind: "tuple",
160
+ parameters: [
161
+ { brief: "Binder id", parse: String, placeholder: "id" },
162
+ { brief: "Parent Binder id; omit for root", parse: String, placeholder: "parent-id", optional: true },
163
+ ],
164
+ },
165
+ },
166
+ docs: { brief: "Move a Binder" },
167
+ });
168
+
169
+ const fileCommand = buildCommand({
170
+ func: async function (this: BindersContext, flags: { projectRoot?: string }, artifactId: string, binderId: string) {
171
+ const placement = await this.client.call<Record<string, unknown>, { effectiveLabels: string[] }>("binders.file", {
172
+ artifact_id: artifactId,
173
+ binder_id: binderId,
174
+ project_root: flags.projectRoot,
175
+ });
176
+ render.call(this, placement, `Filed ${artifactId} in ${binderId}`);
177
+ },
178
+ parameters: {
179
+ flags: { projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true } },
180
+ positional: {
181
+ kind: "tuple",
182
+ parameters: [
183
+ { brief: "Artifact id", parse: String, placeholder: "artifact-id" },
184
+ { brief: "Binder id", parse: String, placeholder: "binder-id" },
185
+ ],
186
+ },
187
+ },
188
+ docs: { brief: "File an artifact in a Binder" },
189
+ });
190
+
191
+ const unfileCommand = buildCommand({
192
+ func: async function (this: BindersContext, flags: { projectRoot?: string }, artifactId: string) {
193
+ const placement = await this.client.call<Record<string, unknown>, unknown>("binders.unfile", {
194
+ artifact_id: artifactId,
195
+ project_root: flags.projectRoot,
196
+ });
197
+ render.call(this, placement, `Moved ${artifactId} to root`);
198
+ },
199
+ parameters: {
200
+ flags: { projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true } },
201
+ positional: { kind: "tuple", parameters: [{ brief: "Artifact id", parse: String, placeholder: "artifact-id" }] },
202
+ },
203
+ docs: { brief: "Move an artifact to Binder root" },
204
+ });
205
+
206
+ const removeCommand = buildCommand({
207
+ func: async function (this: BindersContext, flags: { projectRoot?: string; reason?: string }, id: string) {
208
+ const result = await this.client.call<Record<string, unknown>, unknown>("binders.remove", {
209
+ id,
210
+ project_root: flags.projectRoot,
211
+ reason: flags.reason,
212
+ });
213
+ render.call(this, result, `Removed empty Binder ${id}`);
214
+ },
215
+ parameters: {
216
+ flags: {
217
+ projectRoot: { brief: "Project context", kind: "parsed", parse: String, placeholder: "path", optional: true },
218
+ reason: { brief: "Audit reason", kind: "parsed", parse: String, placeholder: "text", optional: true },
219
+ },
220
+ positional: { kind: "tuple", parameters: [{ brief: "Binder id", parse: String, placeholder: "id" }] },
221
+ },
222
+ docs: { brief: "Remove an empty Binder" },
223
+ });
224
+
225
+ interface CliScope {
226
+ artifactId: string;
227
+ mode: string;
228
+ members: Array<{ type: string; id: string }>;
229
+ }
230
+
231
+ function scopeLabel(scope: CliScope): string {
232
+ return scope.mode === "all" ? "global" : `${scope.mode}: ${scope.members.map((member) => `${member.type}:${member.id}`).join(", ")}`;
233
+ }
234
+
235
+ function unaryScopeCommand(operation: "scope" | "set_global" | "set_none", brief: string) {
236
+ return buildCommand({
237
+ func: async function (this: BindersContext, _flags: Record<string, never>, id: string) {
238
+ const scope = await this.client.call<Record<string, unknown>, CliScope>(`binders.${operation}` as OperationName, { id });
239
+ render.call(this, scope, scopeLabel(scope));
240
+ },
241
+ parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Binder id", parse: String, placeholder: "id" }] } },
242
+ docs: { brief },
243
+ });
244
+ }
245
+
246
+ function memberCommand(operation: "add_project" | "remove_project" | "add_group" | "remove_group") {
247
+ const key = operation.endsWith("project") ? "project" : "group";
248
+ return buildCommand({
249
+ func: async function (this: BindersContext, _flags: Record<string, never>, id: string, member: string) {
250
+ const scope = await this.client.call<Record<string, unknown>, CliScope>(`binders.${operation}` as OperationName, {
251
+ id,
252
+ [key]: member,
253
+ });
254
+ render.call(this, scope, scopeLabel(scope));
255
+ },
256
+ parameters: {
257
+ flags: {},
258
+ positional: {
259
+ kind: "tuple",
260
+ parameters: [
261
+ { brief: "Binder id", parse: String, placeholder: "id" },
262
+ { brief: `${key} reference`, parse: String, placeholder: key },
263
+ ],
264
+ },
265
+ },
266
+ docs: { brief: `${operation.startsWith("add") ? "Add" : "Remove"} a ${key}` },
267
+ });
268
+ }
269
+
270
+ function replaceMembersCommand(operation: "replace_projects" | "replace_groups") {
271
+ const key = operation === "replace_projects" ? "projects" : "groups";
272
+ const flag = operation === "replace_projects" ? "projectsJson" : "groupsJson";
273
+ return buildCommand({
274
+ func: async function (this: BindersContext, flags: { projectsJson?: string[]; groupsJson?: string[] }, id: string) {
275
+ const values = flags[flag];
276
+ if (!values) throw new Error(`--${operation === "replace_projects" ? "projects-json" : "groups-json"} is required`);
277
+ const scope = await this.client.call<Record<string, unknown>, CliScope>(`binders.${operation}` as OperationName, {
278
+ id,
279
+ [key]: values,
280
+ });
281
+ render.call(this, scope, scopeLabel(scope));
282
+ },
283
+ parameters: {
284
+ flags: {
285
+ projectsJson: {
286
+ brief: "JSON project reference array",
287
+ kind: "parsed",
288
+ parse: parseStringArray,
289
+ placeholder: "json",
290
+ optional: true,
291
+ },
292
+ groupsJson: {
293
+ brief: "JSON scope-group reference array",
294
+ kind: "parsed",
295
+ parse: parseStringArray,
296
+ placeholder: "json",
297
+ optional: true,
298
+ },
299
+ },
300
+ positional: { kind: "tuple", parameters: [{ brief: "Binder id", parse: String, placeholder: "id" }] },
301
+ },
302
+ docs: { brief: `Replace Binder ${key}` },
303
+ });
304
+ }
305
+
306
+ const app = buildApplication(
307
+ buildRouteMap({
308
+ routes: {
309
+ create: createCommand,
310
+ list: listCommand,
311
+ tree: treeCommand,
312
+ show: showCommand,
313
+ update: updateCommand,
314
+ move: moveCommand,
315
+ file: fileCommand,
316
+ unfile: unfileCommand,
317
+ remove: removeCommand,
318
+ scope: unaryScopeCommand("scope", "Show Binder scope"),
319
+ "set-global": unaryScopeCommand("set_global", "Make a Binder global"),
320
+ "set-none": unaryScopeCommand("set_none", "Hide a Binder"),
321
+ "add-project": memberCommand("add_project"),
322
+ "remove-project": memberCommand("remove_project"),
323
+ "replace-projects": replaceMembersCommand("replace_projects"),
324
+ "add-group": memberCommand("add_group"),
325
+ "remove-group": memberCommand("remove_group"),
326
+ "replace-groups": replaceMembersCommand("replace_groups"),
327
+ },
328
+ docs: { brief: "Binder hierarchy and inherited-label operations" },
329
+ }),
330
+ { name: "binders", scanner: { caseStyle: "allow-kebab-for-camel" } },
331
+ );
332
+
333
+ export async function runBindersCli(args: string[], client: BindersClient): Promise<string> {
334
+ const json = args.includes("--json");
335
+ return runStricliToString(
336
+ app,
337
+ args.filter((argument) => argument !== "--json"),
338
+ { client, json },
339
+ );
340
+ }
@@ -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
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { createNodeServiceInstallDeps, generateSystemdUnit, installUserService, type ServiceSpec } from "@danypops/vehicle-server/service";
6
6
  import { runArtifactCli } from "./cli/artifact-command.ts";
7
7
  import { runBatchCli } from "./cli/batch-command.ts";
8
+ import { runBindersCli } from "./cli/binders-command.ts";
8
9
  import { runDaemonCli } from "./cli/daemon-command.ts";
9
10
  import { runDiscussCli } from "./cli/discuss-command.ts";
10
11
  import { runDocsCli } from "./cli/docs-command.ts";
@@ -114,6 +115,13 @@ const USAGE = `Usage:
114
115
  papyrus artifact restore <id> [--json]
115
116
  papyrus artifact trash-status <id> [--json]
116
117
  papyrus artifact trash-list [--json]
118
+ papyrus binders create --title <name> [--labels-json <json>] [--parent-id <id>] [--project-root <path>] [--json]
119
+ papyrus binders list|tree [--project-root <path>] [--json]
120
+ papyrus binders show|remove <id> [--project-root <path>] [--json]
121
+ papyrus binders update <id> [--title <name>] [--labels-json <json>] [--project-root <path>] [--json]
122
+ papyrus binders move <id> [parent-id] [--project-root <path>] [--json]
123
+ papyrus binders file <artifact-id> <binder-id> [--project-root <path>] [--json]
124
+ papyrus binders unfile <artifact-id> [--project-root <path>] [--json]
117
125
  papyrus docs create --title <title> [--body <body>] [--subtype <subtype>] [--labels-json <json>] [--extra-json <json>] [--template-id <id>] [--project-root <path>] [--json]
118
126
  papyrus docs list [--status <status>] [--text <query>] [--limit <count>] [--project-root <path>] [--json]
119
127
  papyrus docs show <id> [--json]
@@ -144,6 +152,7 @@ const USAGE = `Usage:
144
152
  papyrus playbooks undepend <id> <dependency-id> [--json]
145
153
  papyrus notes capture <request> [--title <title>] [--json]
146
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]
147
156
  papyrus notes show <id> [--json]
148
157
  papyrus notes consume <id> [--reason <reason>] [--json]
149
158
  papyrus notes promote <id> <target-id> [--reason <reason>] [--json]
@@ -196,6 +205,7 @@ const USAGE = `Usage:
196
205
  papyrus tasks uncontain <parent-id> <child-id> [--reason <reason>] [--session-id <id>] [--json]
197
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]
198
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]
199
209
  papyrus tasks show <id> [--json]
200
210
  papyrus tasks run-gates <id> [--json]
201
211
  papyrus tasks set-checklist <id> --checklist-json <json> [--json]
@@ -355,6 +365,7 @@ export function runIdMigrationCli(args: string[]): string {
355
365
  export {
356
366
  runArtifactCli,
357
367
  runBatchCli,
368
+ runBindersCli,
358
369
  runDiscussCli,
359
370
  runDocsCli,
360
371
  runGatesCli,
@@ -381,6 +392,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
381
392
  console.log(await runTaskCli(args.slice(1), client));
382
393
  return;
383
394
  }
395
+ if (command === "binders") {
396
+ const client = await connectPapyrusClient();
397
+ console.log(await runBindersCli(args.slice(1), client));
398
+ return;
399
+ }
384
400
  if (command === "playbooks") {
385
401
  const client = await connectPapyrusClient();
386
402
  console.log(await runPlaybooksCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -17,7 +17,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
17
17
  export const DAEMON_UNIT_NAME = "papyrus.service";
18
18
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
19
19
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
20
- export const SQLITE_SCHEMA_VERSION = 30;
20
+ export const SQLITE_SCHEMA_VERSION = 31;
21
21
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
22
22
 
23
23
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
@@ -163,6 +163,11 @@ export const PLAYBOOK_INVOCATION_MAX_CREATED_TASKS = 200;
163
163
  export const TASK_CANCEL_SUBTREE_MAX_NODES = 500;
164
164
  /** artifact.remove_subtree walks `contains` transitively across any artifact kind (a task tree, or a playbook's own nested-playbook children) -- same bound rationale as TASK_CANCEL_SUBTREE_MAX_NODES, kept separate since the two traversals serve different operations. */
165
165
  export const ARTIFACT_REMOVE_SUBTREE_MAX_NODES = 500;
166
+ /** Filesystem-style Binder projection bounds. A tree is one project-context view, never an unbounded graph export. */
167
+ export const BINDER_TREE_MAX_ARTIFACTS = 1_000;
168
+ export const BINDER_TREE_MAX_RELATIONSHIPS = 10_000;
169
+ export const BINDER_TREE_MAX_DEPTH = 32;
170
+ export const BINDER_EFFECTIVE_LABEL_MAX_COUNT = 256;
166
171
 
167
172
  /**
168
173
  * At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
@@ -303,6 +308,9 @@ export const SESSION_IDENTITY_MAX_ROWS = 2_000;
303
308
  export const ARTIFACT_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
304
309
  /** Persisted project and focused-graph Task view bounds. */
305
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;
306
314
  /** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
307
315
  export const ARTIFACT_SCOPE_MAX_ARTIFACTS = 1_000;
308
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. */
package/src/db.ts CHANGED
@@ -361,6 +361,7 @@ INSERT OR IGNORE INTO kinds VALUES ('doc','Knowledge — what we know (specs, de
361
361
  INSERT OR IGNORE INTO kinds VALUES ('task','Work — what we are doing (objectives, steps, checklists)');
362
362
  INSERT OR IGNORE INTO kinds VALUES ('rule','Governance — when doing X, follow Y');
363
363
  INSERT OR IGNORE INTO kinds VALUES ('playbook','Reusable procedure — a trigger and an ordered list of steps an agent reads and follows, not a mechanically instantiated blueprint');
364
+ INSERT OR IGNORE INTO kinds VALUES ('binder','Organizational directory — a stable path segment for browsing artifacts without changing their identity');
364
365
  INSERT OR IGNORE INTO statuses VALUES ('draft','doc');
365
366
  INSERT OR IGNORE INTO statuses VALUES ('active','doc');
366
367
  INSERT OR IGNORE INTO statuses VALUES ('archived','doc');
@@ -375,6 +376,7 @@ INSERT OR IGNORE INTO statuses VALUES ('active','rule');
375
376
  INSERT OR IGNORE INTO statuses VALUES ('deprecated','rule');
376
377
  INSERT OR IGNORE INTO statuses VALUES ('active','playbook');
377
378
  INSERT OR IGNORE INTO statuses VALUES ('deprecated','playbook');
379
+ INSERT OR IGNORE INTO statuses VALUES ('active','binder');
378
380
  INSERT OR IGNORE INTO relation_names VALUES ('references','Source material (doc→doc, doc→task, doc→rule)');
379
381
  INSERT OR IGNORE INTO relation_names VALUES ('implements','This work satisfies that (task→doc, task→rule)');
380
382
  INSERT OR IGNORE INTO relation_names VALUES ('follows','This work obeys that (task→rule, task→playbook)');
@@ -387,6 +389,8 @@ INSERT OR IGNORE INTO relation_names VALUES ('gates','This rule gates that task
387
389
  INSERT OR IGNORE INTO relation_names VALUES ('triggers','This playbook run applies to that work (playbook→task)');
388
390
  INSERT OR IGNORE INTO relation_names VALUES ('contains','Parent contains a nested artifact (any→any)');
389
391
  INSERT OR IGNORE INTO relation_names VALUES ('part_of','Artifact belongs to a parent artifact (any→any)');
392
+ INSERT OR IGNORE INTO relation_names VALUES ('organizes','Binder provides an organizational location for an artifact without execution semantics');
393
+ INSERT OR IGNORE INTO relation_names VALUES ('filed_in','Artifact is filed in a Binder; reverse of organizes');
390
394
  CREATE INDEX IF NOT EXISTS edges_to_id_idx ON edges(to_id);
391
395
  `;
392
396
 
@@ -436,6 +440,11 @@ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; check
436
440
  { version: 6, name: "artifact-trash", checksum: "4a75dbec2892deb54bcc1afdf0d51d81f03a8d10861787d083784a29e5c7e8f9" },
437
441
  { version: 7, name: "discuss-native", checksum: "ab7bdd04824bd93681917807b817d6e08b9825af90161e3ccd6d6663021dc6a0" },
438
442
  { version: 8, name: "discuss-options", checksum: "ba1fc5ab7cfe9166d71cc1842f13bc32a0905d08696006cec202196a70c832e8" },
443
+ {
444
+ version: 9,
445
+ name: "binder-hierarchy-and-label-inheritance",
446
+ checksum: "fbd0c8059363ab4acea78a151262da73ef87766755fe799586cb63a6405d9e3c",
447
+ },
439
448
  ];
440
449
 
441
450
  export function migrationLedger(db: Db): ModuleMigrationRow[] {
@@ -1047,6 +1056,20 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
1047
1056
  }
1048
1057
  },
1049
1058
  },
1059
+ {
1060
+ version: 31,
1061
+ name: "binder-hierarchy-and-label-inheritance",
1062
+ // Binders reuse the existing artifacts/edges/scope stores. Only the kind/status vocabulary
1063
+ // and organizational relation pair are new; paths and inherited labels stay computed views.
1064
+ up: (db) => {
1065
+ db.exec(`
1066
+ INSERT OR IGNORE INTO kinds VALUES ('binder','Organizational directory — a stable path segment for browsing artifacts without changing their identity');
1067
+ INSERT OR IGNORE INTO statuses VALUES ('active','binder');
1068
+ INSERT OR IGNORE INTO relation_names VALUES ('organizes','Binder provides an organizational location for an artifact without execution semantics');
1069
+ INSERT OR IGNORE INTO relation_names VALUES ('filed_in','Artifact is filed in a Binder; reverse of organizes');
1070
+ `);
1071
+ },
1072
+ },
1050
1073
  ];
1051
1074
 
1052
1075
  /**
@@ -8,6 +8,7 @@ import type { VehicleRegistry } from "@danypops/vehicle-server";
8
8
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
9
9
  import { removeArtifactSubtree } from "../artifact/artifact-subtree.ts";
10
10
  import type { ArtifactTrashStore } from "../artifact/artifact-trash-store.ts";
11
+ import { BINDER_KIND } from "../binder/binder.ts";
11
12
  import { looseObjectSchema, numberProp, passthroughOutput, stringProp, validationError } from "./shared.ts";
12
13
 
13
14
  const OWNER = "artifact";
@@ -30,6 +31,14 @@ function requireId(input: Record<string, unknown>): string {
30
31
  return id;
31
32
  }
32
33
 
34
+ function requireGenericTrashId(artifacts: ArtifactStore, input: Record<string, unknown>): string {
35
+ const id = requireId(input);
36
+ if (artifacts.get(id)?.kind === BINDER_KIND) {
37
+ throw validationError("Binder removal requires binders.remove so a non-empty directory cannot be orphaned");
38
+ }
39
+ return id;
40
+ }
41
+
33
42
  export function registerArtifactTrashOperations(registry: VehicleRegistry, artifacts: ArtifactStore & ArtifactTrashStore): void {
34
43
  const define = (
35
44
  action: string,
@@ -72,12 +81,12 @@ export function registerArtifactTrashOperations(registry: VehicleRegistry, artif
72
81
 
73
82
  define(
74
83
  "remove",
75
- "Moves any artifact to a time-gated trash, excluded from list/query but still directly showable, restorable via artifact.restore until the purge deadline.",
84
+ "Moves a non-Binder artifact to a time-gated trash, excluded from list/query but still directly showable, restorable via artifact.restore until the purge deadline. Binders require binders.remove so non-empty directories are protected.",
76
85
  "local-write",
77
86
  { id: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
78
87
  ["id"],
79
88
  (input) =>
80
- artifacts.trash(requireId(input), {
89
+ artifacts.trash(requireGenericTrashId(artifacts, input), {
81
90
  reason: typeof input.reason === "string" ? input.reason : undefined,
82
91
  context: eventContext(input),
83
92
  }),
@@ -90,7 +99,7 @@ export function registerArtifactTrashOperations(registry: VehicleRegistry, artif
90
99
  { id: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
91
100
  ["id"],
92
101
  (input) =>
93
- removeArtifactSubtree(artifacts, requireId(input), {
102
+ removeArtifactSubtree(artifacts, requireGenericTrashId(artifacts, input), {
94
103
  reason: typeof input.reason === "string" ? input.reason : undefined,
95
104
  context: eventContext(input),
96
105
  }),