@danypops/papyrus 0.11.2 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +100 -12
  3. package/src/db.ts +81 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
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"],
package/src/cli.ts CHANGED
@@ -82,7 +82,10 @@ const USAGE = `Usage:
82
82
  papyrus tasks reject <id> [--json]
83
83
  papyrus tasks retry <id> [--json]
84
84
  papyrus tasks cancel <id> [--json]
85
- papyrus tasks depend <id> <prerequisite-id> [--json]`;
85
+ papyrus tasks depend <id> <prerequisite-id> [--json]
86
+ 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>] [--json]
87
+ papyrus tasks list [--status <status>] [--text <query>] [--limit <count>] [--json]
88
+ papyrus tasks show <id> [--json]`;
86
89
 
87
90
  function usage(): never {
88
91
  console.error(USAGE);
@@ -98,6 +101,20 @@ type CliCompletion = Omit<TaskCompletion, "artifact" | "blocked"> & {
98
101
  gates: GateResult[];
99
102
  };
100
103
 
104
+ function parseJsonObjectFlag(value: string | undefined, flag: string): Record<string, unknown> {
105
+ if (value === undefined) throw new Error(`${flag} requires a value`);
106
+ const parsed = JSON.parse(value) as unknown;
107
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${flag} must be a JSON object`);
108
+ return parsed as Record<string, unknown>;
109
+ }
110
+
111
+ function parseJsonStringArrayFlag(value: string | undefined, flag: string): string[] {
112
+ if (value === undefined) throw new Error(`${flag} requires a value`);
113
+ const parsed = JSON.parse(value) as unknown;
114
+ if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) throw new Error(`${flag} must be a JSON string array`);
115
+ return parsed as string[];
116
+ }
117
+
101
118
  function artifactLabel(artifact: CliArtifact): string {
102
119
  return `${artifact.id} ${artifact.title}`;
103
120
  }
@@ -237,24 +254,48 @@ export async function runNoteCli(args: string[], client: TaskCliClient, projectR
237
254
  export async function runTaskCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
238
255
  const json = args.includes("--json");
239
256
  const positional: string[] = [];
240
- const updateInput: { title?: string; body?: string; labels?: string[]; status?: "todo" } = {};
241
257
  let reason: string | undefined;
258
+ let title: string | undefined;
259
+ let body: string | undefined;
260
+ let labels: string[] | undefined;
261
+ // Deliberately unrestricted here -- tasks update alone restricts this to "todo" (accidental-
262
+ // creation recovery only), enforced in that case body, not in parsing shared by every action.
263
+ let status: string | undefined;
264
+ let extra: Record<string, unknown> | undefined;
265
+ let gates: unknown[] | undefined;
266
+ let checklist: Record<string, unknown> | undefined;
267
+ let templateId: string | undefined;
268
+ let parentId: string | undefined;
269
+ let dependsOn: string[] | undefined;
270
+ let text: string | undefined;
271
+ let limit: number | undefined;
242
272
  for (let index = 0; index < args.length; index++) {
243
273
  const argument = args[index]!;
244
274
  if (argument === "--json") continue;
245
- if (argument === "--title" || argument === "--body" || argument === "--labels-json" || argument === "--status" || argument === "--reason") {
275
+ if (argument === "--title" || argument === "--body" || argument === "--labels-json" || argument === "--status" || argument === "--reason"
276
+ || argument === "--extra-json" || argument === "--gates-json" || argument === "--checklist-json" || argument === "--template-id"
277
+ || argument === "--parent-id" || argument === "--depends-on-json" || argument === "--text" || argument === "--limit") {
246
278
  const value = args[++index];
247
279
  if (value === undefined) throw new Error(`${argument} requires a value`);
248
- if (argument === "--title") updateInput.title = value;
249
- else if (argument === "--body") updateInput.body = value;
280
+ if (argument === "--title") title = value;
281
+ else if (argument === "--body") body = value;
250
282
  else if (argument === "--reason") reason = value;
251
- else if (argument === "--status") {
252
- if (value !== "todo") throw new Error("--status only supports todo for accidental creation recovery");
253
- updateInput.status = value;
254
- } else {
283
+ else if (argument === "--status") status = value;
284
+ else if (argument === "--extra-json") extra = parseJsonObjectFlag(value, "--extra-json");
285
+ else if (argument === "--checklist-json") checklist = parseJsonObjectFlag(value, "--checklist-json");
286
+ else if (argument === "--template-id") templateId = value;
287
+ else if (argument === "--parent-id") parentId = value;
288
+ else if (argument === "--depends-on-json") dependsOn = parseJsonStringArrayFlag(value, "--depends-on-json");
289
+ else if (argument === "--text") text = value;
290
+ else if (argument === "--limit") {
291
+ if (Number.isNaN(Number(value))) throw new Error("--limit requires a numeric value");
292
+ limit = Number(value);
293
+ } else if (argument === "--gates-json") {
255
294
  const parsed = JSON.parse(value) as unknown;
256
- if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) throw new Error("--labels-json requires a JSON string array");
257
- updateInput.labels = parsed as string[];
295
+ if (!Array.isArray(parsed)) throw new Error("--gates-json must be a JSON array");
296
+ gates = parsed;
297
+ } else {
298
+ labels = parseJsonStringArrayFlag(value, "--labels-json");
258
299
  }
259
300
  continue;
260
301
  }
@@ -297,6 +338,14 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
297
338
  }
298
339
  case "update": {
299
340
  if (!id || dependencyId) throw new Error("tasks update requires exactly one task id");
341
+ const updateInput: { title?: string; body?: string; labels?: string[]; status?: "todo" } = {};
342
+ if (title !== undefined) updateInput.title = title;
343
+ if (body !== undefined) updateInput.body = body;
344
+ if (labels !== undefined) updateInput.labels = labels;
345
+ if (status !== undefined) {
346
+ if (status !== "todo") throw new Error("--status only supports todo for accidental creation recovery");
347
+ updateInput.status = status;
348
+ }
300
349
  if (Object.keys(updateInput).length === 0) throw new Error("tasks update requires --title, --body, --labels-json, or --status todo");
301
350
  if (updateInput.status !== undefined && !reason?.trim()) throw new Error("tasks update --status requires --reason");
302
351
  if (reason !== undefined && updateInput.status === undefined) throw new Error("tasks update --reason requires --status todo");
@@ -307,6 +356,45 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
307
356
  human = `Updated: ${artifactLabel(artifact)}`;
308
357
  break;
309
358
  }
359
+ case "create": {
360
+ if (id) throw new Error("tasks create accepts no positional arguments");
361
+ if (!title) throw new Error("tasks create requires --title");
362
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.create", {
363
+ title,
364
+ ...(body !== undefined ? { body } : {}),
365
+ ...(status !== undefined ? { status } : {}),
366
+ ...(labels !== undefined ? { labels } : {}),
367
+ ...(extra !== undefined ? { extra } : {}),
368
+ ...(gates !== undefined ? { gates } : {}),
369
+ ...(checklist !== undefined ? { checklist } : {}),
370
+ ...(templateId !== undefined ? { template_id: templateId } : {}),
371
+ ...(parentId !== undefined ? { parent_id: parentId } : {}),
372
+ ...(dependsOn !== undefined ? { depends_on: dependsOn } : {}),
373
+ project_root: projectRoot, actor: "user", source: "cli",
374
+ });
375
+ result = artifact;
376
+ human = `Created task: ${artifactLabel(artifact)}`;
377
+ break;
378
+ }
379
+ case "list": {
380
+ if (id) throw new Error("tasks list accepts no positional arguments");
381
+ const rows = await client.call<Record<string, unknown>, CliArtifact[]>("tasks.list", {
382
+ ...(status !== undefined ? { status } : {}),
383
+ ...(text !== undefined ? { text } : {}),
384
+ ...(limit !== undefined ? { limit } : {}),
385
+ project_root: projectRoot,
386
+ });
387
+ result = rows;
388
+ human = rows.length === 0 ? "No tasks found." : rows.map((row) => artifactLabel(row)).join("\n");
389
+ break;
390
+ }
391
+ case "show": {
392
+ if (!id || dependencyId) throw new Error("tasks show requires exactly one task id");
393
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.show", { id });
394
+ result = artifact;
395
+ human = `${artifactLabel(artifact)}\n\n${artifact.body ?? ""}`;
396
+ break;
397
+ }
310
398
  case "history": {
311
399
  if (!id || dependencyId) throw new Error("tasks history requires exactly one task id");
312
400
  const page = await client.call<{ id: string; direction: "desc" }, import("./domain/task-event.ts").TaskHistoryPage>("tasks.history", { id, direction: "desc" });
@@ -415,7 +503,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
415
503
  break;
416
504
  }
417
505
  default:
418
- throw new Error("tasks action must be active, focused, focus, pause, unpause, clear-focus, update, graph, plan, history, scope, assign-project, complete, start, submit, reject, retry, cancel, or depend");
506
+ throw new Error("tasks action must be create, list, show, active, focused, focus, pause, unpause, clear-focus, update, graph, plan, history, scope, assign-project, complete, start, submit, reject, retry, cancel, or depend");
419
507
  }
420
508
  return json ? JSON.stringify(result) : human;
421
509
  }
package/src/db.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Dual-runtime: bun:sqlite (Bun) / node:sqlite (Node/pi host).
4
4
  * Four kinds (doc/task/rule/skill) are FK-enforced; relations are universal (any→any).
5
5
  */
6
+ import { createHash } from "node:crypto";
6
7
  import { createRequire } from "node:module";
7
8
  import { mkdirSync } from "node:fs";
8
9
  import { join, dirname } from "node:path";
@@ -65,6 +66,14 @@ export function inTransaction<T>(db: Db, fn: () => T): T {
65
66
  }
66
67
 
67
68
  const SCHEMA = `
69
+ CREATE TABLE IF NOT EXISTS module_migrations (
70
+ module_id TEXT NOT NULL,
71
+ version INTEGER NOT NULL,
72
+ name TEXT NOT NULL,
73
+ checksum TEXT NOT NULL,
74
+ applied_at TEXT NOT NULL,
75
+ PRIMARY KEY (module_id, version)
76
+ );
68
77
  CREATE TABLE IF NOT EXISTS kinds (
69
78
  name TEXT PRIMARY KEY,
70
79
  description TEXT
@@ -183,16 +192,81 @@ export function schemaVersion(db: Db): number {
183
192
  return (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
184
193
  }
185
194
 
195
+ /**
196
+ * One row per (module_id, version) applied migration, checksummed so a since-edited
197
+ * definition is detected rather than silently trusted. "core" consolidates this
198
+ * repository's entire pre-ledger migration history (every schemaVersion 1..CURRENT
199
+ * branch below) into one baseline, checked against the exact SCHEMA+SEED_SQL text those
200
+ * branches converge on -- new modules going forward register their own migrations
201
+ * independently, without touching "core" or duplicating a separate bootstrap path.
202
+ */
203
+ export interface ModuleMigrationRow {
204
+ readonly moduleId: string;
205
+ readonly version: number;
206
+ readonly name: string;
207
+ readonly checksum: string;
208
+ readonly appliedAt: string;
209
+ }
210
+
211
+ const CORE_BASELINE_CHECKSUM = createHash("sha256").update(SCHEMA + SEED_SQL).digest("hex");
212
+
213
+ export function migrationLedger(db: Db): ModuleMigrationRow[] {
214
+ // A database that has never reached current schema (still awaiting explicit migrateDb())
215
+ // has no ledger table yet -- "nothing recorded" is the correct answer, not an error.
216
+ const tableExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'module_migrations'").get() != null;
217
+ if (!tableExists) return [];
218
+ const rows = db.prepare("SELECT module_id, version, name, checksum, applied_at FROM module_migrations ORDER BY module_id, version").all() as Array<{
219
+ module_id: string; version: number; name: string; checksum: string; applied_at: string;
220
+ }>;
221
+ return rows.map((row) => ({ moduleId: row.module_id, version: row.version, name: row.name, checksum: row.checksum, appliedAt: row.applied_at }));
222
+ }
223
+
224
+ /**
225
+ * Ensures the ledger correctly reflects "core" once a database is confirmed at the full
226
+ * current schema, however it got there: a truly empty database runs the baseline DDL and
227
+ * records it; a database that already reached current shape (a fresh bootstrap from a
228
+ * past release, or a full upgrade through the pre-ledger sequential migrateDb() chain
229
+ * below) is backfilled without re-running any DDL against data that already exists.
230
+ * Verifies the stored checksum on every open so a since-edited baseline is caught, not
231
+ * silently trusted.
232
+ */
233
+ function ensureCoreBaseline(db: Db, alreadyAtCurrentSchema: boolean): void {
234
+ // Idempotent and standalone: must succeed even on a truly empty database, before the rest
235
+ // of SCHEMA (which also declares this table) has run.
236
+ db.exec(`
237
+ CREATE TABLE IF NOT EXISTS module_migrations (
238
+ module_id TEXT NOT NULL,
239
+ version INTEGER NOT NULL,
240
+ name TEXT NOT NULL,
241
+ checksum TEXT NOT NULL,
242
+ applied_at TEXT NOT NULL,
243
+ PRIMARY KEY (module_id, version)
244
+ );
245
+ `);
246
+ const existingRow = db.prepare("SELECT checksum FROM module_migrations WHERE module_id = 'core' AND version = 1").get() as { checksum: string } | null;
247
+ if (existingRow != null) {
248
+ if (existingRow.checksum !== CORE_BASELINE_CHECKSUM) {
249
+ throw new Error('module migration "core" version 1 checksum mismatch: the baseline definition changed since it was applied');
250
+ }
251
+ return;
252
+ }
253
+ inTransaction(db, () => {
254
+ if (!alreadyAtCurrentSchema) {
255
+ db.exec(SCHEMA);
256
+ db.exec(SEED_SQL);
257
+ db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
258
+ }
259
+ db.prepare("INSERT INTO module_migrations (module_id, version, name, checksum, applied_at) VALUES ('core', 1, 'baseline', ?, ?)")
260
+ .run(CORE_BASELINE_CHECKSUM, new Date().toISOString());
261
+ });
262
+ }
263
+
186
264
  function bootstrapEmptyDatabase(db: Db): void {
187
265
  const existing = db
188
266
  .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1")
189
267
  .get();
190
268
  if (existing) throw new Error("database schema is unversioned; refusing to migrate existing data during boot");
191
- inTransaction(db, () => {
192
- db.exec(SCHEMA);
193
- db.exec(SEED_SQL);
194
- db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
195
- });
269
+ ensureCoreBaseline(db, false);
196
270
  }
197
271
 
198
272
  export function migrateDb(db: Db): MigrationResult {
@@ -291,6 +365,7 @@ export function migrateDb(db: Db): MigrationResult {
291
365
  applied.push("task-focus-continuation");
292
366
  }
293
367
  });
368
+ if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreBaseline(db, true);
294
369
  return { from, to: schemaVersion(db), applied };
295
370
  }
296
371
 
@@ -306,6 +381,7 @@ export function openDb(path: string): Db {
306
381
  throw new Error(`database schema ${current} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
307
382
  }
308
383
  if (current === 0) bootstrapEmptyDatabase(db);
384
+ else if (current === SQLITE_SCHEMA_VERSION) ensureCoreBaseline(db, true);
309
385
  db.exec("PRAGMA optimize=0x10002");
310
386
  return db;
311
387
  }