@danypops/papyrus 0.44.12 → 0.45.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.44.12",
3
+ "version": "0.45.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"],
@@ -747,7 +747,7 @@ const startCommand = buildCommand({
747
747
  docs: { brief: "Lifecycle transition: todo -> in-progress" },
748
748
  });
749
749
 
750
- function buildSimpleTransitionCommand(action: "submit" | "reject" | "retry" | "cancel", brief: string) {
750
+ function buildSimpleTransitionCommand(action: "submit" | "reject" | "retry" | "cancel" | "reopen", brief: string) {
751
751
  return buildCommand({
752
752
  func: async function (this: TaskContext, flags: { sessionId?: string }, id: string) {
753
753
  const artifact = await this.client.call<Record<string, unknown>, CliArtifact>(`tasks.${action}` as OperationName, {
@@ -868,6 +868,7 @@ const app = buildApplication(
868
868
  reject: buildSimpleTransitionCommand("reject", "Lifecycle transition: review -> rejected"),
869
869
  retry: buildSimpleTransitionCommand("retry", "Lifecycle transition: rejected -> in-progress"),
870
870
  cancel: buildSimpleTransitionCommand("cancel", "Lifecycle transition to canceled"),
871
+ reopen: buildSimpleTransitionCommand("reopen", "Lifecycle transition: canceled -> todo"),
871
872
  "cancel-subtree": cancelSubtreeCommand,
872
873
  depend: buildDependencyCommand(
873
874
  "tasks.depend",
@@ -26,6 +26,7 @@ export const TASK_EVENT_TYPES = [
26
26
  "retried",
27
27
  "completed",
28
28
  "canceled",
29
+ "reopened",
29
30
  "dependency_added",
30
31
  "dependency_removed",
31
32
  "containment_added",
@@ -17,6 +17,7 @@ import type { VehicleContentBlock } from "@danypops/vehicle-core";
17
17
  import type { VehicleRegistry } from "@danypops/vehicle-server";
18
18
  import type { Artifact } from "../artifact/artifact.ts";
19
19
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
20
+ import { DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH, DISCUSSION_OPTION_MAX_LENGTH, DISCUSSION_OPTIONS_MAX_COUNT } from "../constants.ts";
20
21
  import type { DiscussionAndRounds, Discussions } from "../discussion/discussion-service.ts";
21
22
  import { DISCUSSION_SUBTYPE, type DiscussionRound } from "../domain/discussion.ts";
22
23
  import { discussOperations } from "../modules/discuss.ts";
@@ -118,6 +119,16 @@ function defaultActorToAgent(input: Record<string, unknown>): void {
118
119
 
119
120
  const optionsUnionSchema = { type: "array" } as const;
120
121
 
122
+ /**
123
+ * Shared suffix for open/reply's own description -- interpolates the real, enforced bounds
124
+ * (domain/discussion.ts's validateDiscussionOptions) rather than a hand-typed number that can
125
+ * silently drift out of sync with the actual limit. A prior version of this description stated
126
+ * the option count bound but not the per-option/per-description character bound at all --
127
+ * confirmed live: a caller had no way to know a 250-character description would be rejected
128
+ * until it already had been (see discuss-vehicle.test.ts's oversized-description regression).
129
+ */
130
+ const OPTION_BOUNDS_TEXT = `Each option is at most ${DISCUSSION_OPTION_MAX_LENGTH} characters (up to ${DISCUSSION_OPTIONS_MAX_COUNT} total); each description is at most ${DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH} characters.`;
131
+
121
132
  export function registerDiscussVehicleOperations(registry: VehicleRegistry, discussions: Discussions, artifacts: ArtifactStore): void {
122
133
  const moduleOperations = new Map(discussOperations(discussions).map((op) => [op.name, op]));
123
134
  const call = <Output>(name: string, input: Record<string, unknown>): Output => moduleOperations.get(name)!.execute(input) as Output;
@@ -152,7 +163,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
152
163
 
153
164
  define(
154
165
  "open",
155
- "Opens a new Discussion and starts round 1. Optionally poses a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several) -- each option a bare string (self-evident) or {title, description} (a real tradeoff worth spelling out; description REQUIRED once there are 3+ options). Optionally blocks one or more Tasks immediately via blocks_task_ids/blocks_task_names. Pass live:true to get a human's answer synchronously in this same call, via an interactive prompt -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.",
166
+ `Opens a new Discussion and starts round 1. Optionally poses a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several) -- each option a bare string (self-evident) or {title, description} (a real tradeoff worth spelling out; description REQUIRED once there are 3+ options). ${OPTION_BOUNDS_TEXT} Optionally blocks one or more Tasks immediately via blocks_task_ids/blocks_task_names. Pass live:true to get a human's answer synchronously in this same call, via an interactive prompt -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.`,
156
167
  "local-write",
157
168
  {
158
169
  title: stringProp,
@@ -182,7 +193,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
182
193
 
183
194
  define(
184
195
  "reply",
185
- "Adds a round to an existing Discussion. Refused once deferred or settled -- resume first. Answers a currently pending posed choice via `selected` (validated against it), or poses a new choice via options/options_mode. Prefer `name` over `id`. Pass live:true to get a human's answer synchronously in this same call, via the pending choice's picker if one was posed, otherwise a freeform question -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.",
196
+ `Adds a round to an existing Discussion. Refused once deferred or settled -- resume first. Answers a currently pending posed choice via \`selected\` (validated against it), or poses a new choice via options/options_mode. ${OPTION_BOUNDS_TEXT} Prefer \`name\` over \`id\`. Pass live:true to get a human's answer synchronously in this same call, via the pending choice's picker if one was posed, otherwise a freeform question -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.`,
186
197
  "local-write",
187
198
  {
188
199
  id: stringProp,
@@ -244,7 +244,7 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
244
244
 
245
245
  define(
246
246
  "update",
247
- "Recovers an accidentally-terminal task via status=todo + reason, or changes title/body/labels, without rewriting real history. Never touches gates -- use set_gates.",
247
+ "Recovers an accidentally-terminal task via status=todo + reason (only a task whose status was terminal at its own creation -- not one that reached canceled/rejected through a real, later transition; use tasks.reopen for that), or changes title/body/labels, without rewriting real history. Never touches gates -- use set_gates.",
248
248
  "local-write",
249
249
  {
250
250
  id: stringProp,
@@ -453,7 +453,16 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
453
453
  );
454
454
  define(
455
455
  "cancel",
456
- "Lifecycle transition to canceled (terminal) from todo/in-progress/review/rejected.",
456
+ "Lifecycle transition to canceled (terminal) from todo/in-progress/review/rejected. Reversible via tasks.reopen if this turns out to be premature.",
457
+ "local-write",
458
+ { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
459
+ [],
460
+ resolveIdAndScope,
461
+ );
462
+
463
+ define(
464
+ "reopen",
465
+ "Lifecycle transition: canceled -> todo. For a task legitimately canceled through a normal transition (e.g. a deliberate pause/park) that should resume -- distinct from tasks.update's status:todo path, which only recovers a task that was terminal at its own creation (a caller mistake), never one canceled/rejected later.",
457
466
  "local-write",
458
467
  { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
459
468
  [],
@@ -86,6 +86,7 @@ export const TASKS_OPERATION_NAMES = [
86
86
  "tasks.reject",
87
87
  "tasks.retry",
88
88
  "tasks.cancel",
89
+ "tasks.reopen",
89
90
  "tasks.cancel_subtree",
90
91
  "tasks.depend",
91
92
  "tasks.undepend",
@@ -201,6 +202,7 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
201
202
  define("tasks.reject", (input: OperationInput) => tasks.transition(string(input, "id"), "reject", eventContext(input))),
202
203
  define("tasks.retry", (input: OperationInput) => tasks.transition(string(input, "id"), "retry", eventContext(input))),
203
204
  define("tasks.cancel", (input: OperationInput) => tasks.transition(string(input, "id"), "cancel", eventContext(input))),
205
+ define("tasks.reopen", (input: OperationInput) => tasks.transition(string(input, "id"), "reopen", eventContext(input))),
204
206
  define("tasks.cancel_subtree", (input: OperationInput) => tasks.cancelSubtree(string(input, "id"), eventContext(input))),
205
207
  define("tasks.depend", (input: OperationInput) =>
206
208
  tasks.depend(string(input, "id"), string(input, "dependency_id"), eventContext(input)),
package/src/service.ts CHANGED
@@ -371,6 +371,7 @@ function handlers(
371
371
  "tasks.reject": forwardToModule("tasks.reject"),
372
372
  "tasks.retry": forwardToModule("tasks.retry"),
373
373
  "tasks.cancel": forwardToModule("tasks.cancel"),
374
+ "tasks.reopen": forwardToModule("tasks.reopen"),
374
375
  "tasks.cancel_subtree": forwardToModule("tasks.cancel_subtree"),
375
376
  "tasks.depend": forwardToModule("tasks.depend"),
376
377
  "tasks.undepend": forwardToModule("tasks.undepend"),
@@ -78,7 +78,7 @@ export interface CreateTaskInput {
78
78
  projectSource?: TaskScopeSource;
79
79
  }
80
80
 
81
- export type TaskTransition = "start" | "submit" | "reject" | "retry" | "cancel";
81
+ export type TaskTransition = "start" | "submit" | "reject" | "retry" | "cancel" | "reopen";
82
82
 
83
83
  export interface TaskBlockage {
84
84
  artifact: Artifact;
@@ -134,6 +134,17 @@ const TASK_TRANSITIONS: Record<TaskTransition, { from: TaskStatus[]; to: TaskSta
134
134
  reject: { from: ["review"], to: "rejected" },
135
135
  retry: { from: ["rejected"], to: "in-progress" },
136
136
  cancel: { from: ["todo", "in-progress", "review", "rejected"], to: "canceled" },
137
+ /**
138
+ * Distinct from tasks.update's status:todo path (recoverCreation, below): that path only ever
139
+ * recovers a task whose entire history is a single "created" event already at a terminal status
140
+ * (a creation-time mistake) -- it deliberately refuses a task that reached canceled through a
141
+ * real, later transition. reopen is the missing counterpart for exactly that case: a task
142
+ * legitimately canceled (e.g. a deliberate "pause/park", since there is no direct
143
+ * in-progress -> todo transition) can be brought back to todo and driven through the normal
144
+ * lifecycle again, without rewriting its real history the way recoverCreation's own
145
+ * terminal-at-creation check exists to prevent.
146
+ */
147
+ reopen: { from: ["canceled"], to: "todo" },
137
148
  };
138
149
 
139
150
  export class Tasks {
@@ -511,9 +522,14 @@ export class Tasks {
511
522
  this.focusStore.set(id, context.sessionId);
512
523
  }
513
524
  const updated = this.artifacts.setStatus(id, transition.to)!;
514
- const eventType = { start: "started", submit: "submitted", reject: "review_rejected", retry: "retried", cancel: "canceled" }[
515
- action
516
- ] as AppendTaskEvent["type"];
525
+ const eventType = {
526
+ start: "started",
527
+ submit: "submitted",
528
+ reject: "review_rejected",
529
+ retry: "retried",
530
+ cancel: "canceled",
531
+ reopen: "reopened",
532
+ }[action] as AppendTaskEvent["type"];
517
533
  this.appendEvent({ taskId: id, type: eventType, fromStatus: task.status as TaskStatus, toStatus: transition.to }, context);
518
534
  if (action === "start" || action === "retry") this.propagateProgressToAncestors(id, context);
519
535
  if (action === "retry") this.focusStore.set(id, context.sessionId);