@danypops/papyrus 0.11.0 → 0.11.2

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/README.md CHANGED
@@ -168,6 +168,7 @@ papyrus tasks pause --json
168
168
  papyrus tasks unpause --json
169
169
  papyrus tasks clear-focus --json
170
170
  papyrus tasks update <id> --title "Revised title" --body "Revised body" --json
171
+ papyrus tasks update <id> --status todo --reason "created with legacy default" --json
171
172
  papyrus tasks start <id> --json
172
173
  papyrus tasks submit <id> --json
173
174
  papyrus tasks complete <id> --json
@@ -201,7 +202,7 @@ Papyrus also injects an Alef-style reconciliation block at `before_agent_start`
201
202
 
202
203
  After assembling each system-prompt addition, Papyrus emits a versioned `papyrus.context-injection.v1` observation on Pi's shared extension event bus. It contains only exact byte/character sizes, Rule count, a labeled token estimate, prompt share, sequence, and a SHA-256 payload fingerprint; Rule/Task text, prompts, project paths, and credentials are never included. Jittor can persist and assess these observations without Papyrus maintaining a second telemetry store.
203
204
 
204
- Task edits mutate the existing Papyrus-owned Task identity and append an `updated` event; title, body, and labels can be revised without canceling the Task or creating a replacement. Lifecycle, relationships, gates, checklist metadata, scope, and focus remain intact.
205
+ Task edits mutate the existing Papyrus-owned Task identity and append an `updated` event; title, body, and labels can be revised without canceling the Task or creating a replacement. Lifecycle, relationships, gates, checklist metadata, scope, and focus remain intact. The same `update` action provides a narrowly guarded recovery for Tasks accidentally created terminal by a legacy default: `status=todo` requires an audit reason, cannot be combined with content edits, only applies when `created` is the sole lifecycle event, and appends `creation_recovered` rather than rewriting history.
205
206
 
206
207
  ## Why
207
208
 
@@ -32,7 +32,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
32
32
  pi.registerTool({
33
33
  name: "tasks",
34
34
  label: "Tasks",
35
- 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, contain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. 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. Prefer this over low-level papyrus_* tools for task work.",
35
+ 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, contain. 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. Prefer this over low-level papyrus_* tools for task work.",
36
36
  parameters: Type.Object({
37
37
  action: Type.String(),
38
38
  id: Type.Optional(Type.String()),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
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
@@ -75,7 +75,7 @@ const USAGE = `Usage:
75
75
  papyrus tasks scope [project|all|graph <root-id>] [--json]
76
76
  papyrus tasks assign-project <id> [project-root] [--json]
77
77
  papyrus tasks focus <id> [--json]
78
- papyrus tasks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
78
+ papyrus tasks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--status todo --reason <reason>] [--json]
79
79
  papyrus tasks complete <id> [--json]
80
80
  papyrus tasks start <id> [--json]
81
81
  papyrus tasks submit <id> [--json]
@@ -237,16 +237,21 @@ export async function runNoteCli(args: string[], client: TaskCliClient, projectR
237
237
  export async function runTaskCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
238
238
  const json = args.includes("--json");
239
239
  const positional: string[] = [];
240
- const updateInput: { title?: string; body?: string; labels?: string[] } = {};
240
+ const updateInput: { title?: string; body?: string; labels?: string[]; status?: "todo" } = {};
241
+ let reason: string | undefined;
241
242
  for (let index = 0; index < args.length; index++) {
242
243
  const argument = args[index]!;
243
244
  if (argument === "--json") continue;
244
- if (argument === "--title" || argument === "--body" || argument === "--labels-json") {
245
+ if (argument === "--title" || argument === "--body" || argument === "--labels-json" || argument === "--status" || argument === "--reason") {
245
246
  const value = args[++index];
246
247
  if (value === undefined) throw new Error(`${argument} requires a value`);
247
248
  if (argument === "--title") updateInput.title = value;
248
249
  else if (argument === "--body") updateInput.body = value;
249
- else {
250
+ 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 {
250
255
  const parsed = JSON.parse(value) as unknown;
251
256
  if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) throw new Error("--labels-json requires a JSON string array");
252
257
  updateInput.labels = parsed as string[];
@@ -256,6 +261,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
256
261
  positional.push(argument);
257
262
  }
258
263
  const [action, id, dependencyId] = positional;
264
+ if (reason !== undefined && action !== "update") throw new Error("--reason is only supported by tasks update");
259
265
  let result: unknown;
260
266
  let human: string;
261
267
  switch (action) {
@@ -291,8 +297,12 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
291
297
  }
292
298
  case "update": {
293
299
  if (!id || dependencyId) throw new Error("tasks update requires exactly one task id");
294
- if (Object.keys(updateInput).length === 0) throw new Error("tasks update requires --title, --body, or --labels-json");
295
- const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.update", { id, ...updateInput, actor: "user", source: "cli" });
300
+ if (Object.keys(updateInput).length === 0) throw new Error("tasks update requires --title, --body, --labels-json, or --status todo");
301
+ if (updateInput.status !== undefined && !reason?.trim()) throw new Error("tasks update --status requires --reason");
302
+ if (reason !== undefined && updateInput.status === undefined) throw new Error("tasks update --reason requires --status todo");
303
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.update", {
304
+ id, ...updateInput, ...(reason ? { reason } : {}), actor: "user", source: "cli",
305
+ });
296
306
  result = artifact;
297
307
  human = `Updated: ${artifactLabel(artifact)}`;
298
308
  break;
package/src/constants.ts CHANGED
@@ -142,6 +142,22 @@ export const SEED_STATUSES = [
142
142
  { name: "deprecated", kind: "skill" },
143
143
  ] as const;
144
144
 
145
+ /**
146
+ * The initial status a newly created artifact of a kind gets when no caller-supplied
147
+ * status is given. This must be an explicit, named mapping — never derived from row order
148
+ * in the `statuses` table (SEED_STATUSES' listed order, or a migration's insertion order,
149
+ * is not a semantic guarantee; a migrated database can freely have a different physical
150
+ * row order for the same logical status set). Deriving "the default" from "whichever row
151
+ * happens to be first by rowid" was the root cause of a real production defect where
152
+ * migrated databases created new Tasks as done instead of todo.
153
+ */
154
+ export const DEFAULT_STATUS_BY_KIND: Readonly<Record<string, string>> = {
155
+ doc: "draft",
156
+ task: "todo",
157
+ rule: "active",
158
+ skill: "active",
159
+ };
160
+
145
161
  /**
146
162
  * Universal relation names — any kind can link to any kind.
147
163
  *
@@ -9,6 +9,7 @@ export type TaskLifecycleStatus = "todo" | "in-progress" | "review" | "rejected"
9
9
 
10
10
  export const TASK_EVENT_TYPES = [
11
11
  "created",
12
+ "creation_recovered",
12
13
  "updated",
13
14
  "started",
14
15
  "submitted",
package/src/ops.ts CHANGED
@@ -6,6 +6,7 @@ import { createRequire } from "node:module";
6
6
  import { exec } from "node:child_process";
7
7
  import type { Db } from "./db.ts";
8
8
  import { inTransaction } from "./db.ts";
9
+ import { DEFAULT_STATUS_BY_KIND } from "./constants.ts";
9
10
  import type { Artifact, ArtifactQuery, CreateArtifactInput, UpdateArtifactInput } from "./domain/artifact.ts";
10
11
  import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
11
12
  export type { Artifact } from "./domain/artifact.ts";
@@ -102,9 +103,13 @@ function slugify(s: string): string {
102
103
  }
103
104
 
104
105
  function defaultStatusFor(db: Db, kind: string): string {
105
- // First-inserted status per kind (seed order defines the default)
106
- const row = db.prepare("SELECT name FROM statuses WHERE kind = ? ORDER BY rowid LIMIT 1").get(kind) as { name: string } | null;
107
- return row?.name ?? "draft";
106
+ // Explicit per-kind mapping, never row order -- see DEFAULT_STATUS_BY_KIND's doc comment
107
+ // for the production defect this replaced (row order is not a semantic guarantee).
108
+ const candidate = DEFAULT_STATUS_BY_KIND[kind];
109
+ if (candidate === undefined) throw new Error(`no default status is configured for kind "${kind}"`);
110
+ const exists = db.prepare("SELECT 1 FROM statuses WHERE kind = ? AND name = ?").get(kind, candidate);
111
+ if (!exists) throw new Error(`configured default status "${candidate}" for kind "${kind}" is not a registered status`);
112
+ return candidate;
108
113
  }
109
114
 
110
115
  function rowToArtifact(row: Record<string, unknown>): Artifact {
package/src/service.ts CHANGED
@@ -269,6 +269,7 @@ function handlers(
269
269
  ...(input["title"] !== undefined ? { title: optionalString(input, "title")! } : {}),
270
270
  ...(input["body"] !== undefined ? { body: optionalString(input, "body")! } : {}),
271
271
  ...(input["labels"] !== undefined ? { labels: optionalStringArray(input, "labels")! } : {}),
272
+ ...(input["status"] !== undefined ? { status: string(input, "status") as "todo" } : {}),
272
273
  }, eventContext(input)),
273
274
  "tasks.list": (input) => tasks.list(taskFilter(input)),
274
275
  "tasks.graph": (input) => tasks.graph(taskFilter(input)),
@@ -24,6 +24,7 @@ export interface UpdateTaskInput {
24
24
  title?: string;
25
25
  body?: string;
26
26
  labels?: string[];
27
+ status?: "todo";
27
28
  }
28
29
 
29
30
  export interface TaskFilter {
@@ -148,7 +149,7 @@ export class Tasks {
148
149
  title: input.title,
149
150
  body: input.body,
150
151
  subtype: input.subtype,
151
- status: input.status,
152
+ status: input.status ?? "todo",
152
153
  labels: input.labels,
153
154
  extra,
154
155
  templateId: input.templateId,
@@ -161,9 +162,39 @@ export class Tasks {
161
162
  });
162
163
  }
163
164
 
165
+ private recoverCreation(id: string, context: TaskEventContext): Artifact {
166
+ if (!context.reason?.trim()) throw new Error("creation recovery requires an audit reason");
167
+ return this.events.atomic(() => {
168
+ const task = this.require(id);
169
+ if (task.status !== "done" && task.status !== "canceled") throw new Error(`cannot recover task creation from ${task.status}`);
170
+ const history = this.events.history(id, { direction: "asc", limit: 2 });
171
+ const created = history.events[0];
172
+ if (history.events.length !== 1 || history.nextCursor !== undefined || created?.type !== "created" || created.toStatus !== task.status) {
173
+ throw new Error("task was not terminal at creation");
174
+ }
175
+ const recovered = this.artifacts.setStatus(id, "todo");
176
+ if (!recovered) throw new Error(`task "${id}" not found`);
177
+ this.appendEvent({
178
+ taskId: id,
179
+ type: "creation_recovered",
180
+ fromStatus: task.status as TaskStatus,
181
+ toStatus: "todo",
182
+ evidence: { result: "terminal-at-creation" },
183
+ }, context);
184
+ return recovered;
185
+ });
186
+ }
187
+
164
188
  update(id: string, input: UpdateTaskInput, context: TaskEventContext = {}): Artifact {
189
+ if (input.status !== undefined) {
190
+ if (input.status !== "todo") throw new Error("task status updates only support recovering creation to todo");
191
+ if (input.title !== undefined || input.body !== undefined || input.labels !== undefined) {
192
+ throw new Error("task creation recovery cannot be combined with content updates");
193
+ }
194
+ return this.recoverCreation(id, context);
195
+ }
165
196
  const fields = (["title", "body", "labels"] as const).filter((field) => input[field] !== undefined);
166
- if (fields.length === 0) throw new Error("task update requires title, body, or labels");
197
+ if (fields.length === 0) throw new Error("task update requires title, body, or labels; status todo is only valid for creation recovery");
167
198
  if (input.title !== undefined && (input.title.trim().length === 0 || input.title.length > TASK_TITLE_MAX_LENGTH)) {
168
199
  throw new Error(`title must be between 1 and ${TASK_TITLE_MAX_LENGTH} characters`);
169
200
  }