@danypops/papyrus 0.21.4 → 0.21.6

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.
@@ -25,6 +25,29 @@ import { callService } from "./service-client.ts";
25
25
  const SOURCE = "discuss-tui";
26
26
  const ACTOR = "human";
27
27
 
28
+ /** Tasks a human could plausibly want to block on -- excludes terminal ones, since blocking already-finished or canceled work is meaningless. */
29
+ export async function openTaskChoices(cwd: string): Promise<Artifact[]> {
30
+ const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", { project_root: cwd });
31
+ return rows.filter((task) => task.status !== "done" && task.status !== "canceled");
32
+ }
33
+
34
+ /** Resolves the task ids a Discussion currently has a `blocks` edge to, by title -- so "Unblock" only ever offers tasks actually blocked by this one, never the whole task list. */
35
+ export async function blockedTaskChoices(discussionId: string): Promise<Artifact[]> {
36
+ const tree = await callService<Record<string, unknown>, Artifact>("graph.tree", { id: discussionId, depth: 1 });
37
+ const blockedIds = (tree.edges ?? []).filter((edge) => edge.relation === "blocks" && edge.from === discussionId).map((edge) => edge.to);
38
+ const tasks = await Promise.all(blockedIds.map((id) => callService<Record<string, unknown>, Artifact | null>("tasks.show", { id }).catch(() => null)));
39
+ return tasks.filter((task): task is Artifact => task !== null);
40
+ }
41
+
42
+ /** Picking a task by title, the same ui.select pattern used for Discuss's own single-choice options -- no ecosystem extension (Pi's own docs/examples, pi-tasks) builds a bespoke fuzzy picker for a plain "choose one named thing" list. */
43
+ export async function pickTaskByName(ctx: ExtensionCommandContext, title: string, tasks: Artifact[]): Promise<Artifact | undefined> {
44
+ if (tasks.length === 0) { ctx.ui.notify("No open tasks to choose from.", "info"); return undefined; }
45
+ const label = await ctx.ui.select(title, tasks.map((task) => `${task.title} [${task.status}]`));
46
+ if (!label) return undefined;
47
+ const index = tasks.map((task) => `${task.title} [${task.status}]`).indexOf(label);
48
+ return index === -1 ? undefined : tasks[index];
49
+ }
50
+
28
51
  export function discussionRowMeta(discussion: Artifact, theme: Theme): string {
29
52
  const state = discussionStateOf(discussion);
30
53
  const presentation = DISCUSSION_STATE_PRESENTATION[state];
@@ -93,17 +116,19 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
93
116
  return;
94
117
  }
95
118
  if (choice === "Block a task") {
96
- const taskId = await commandCtx.ui.input("Task artifact id to block:", "");
97
- if (!taskId) return;
98
- await callService("discuss.block", { id: discussion.id, task_id: taskId, actor: ACTOR, source: SOURCE });
99
- commandCtx.ui.notify(`${discussion.id} now blocks ${taskId}`, "info");
119
+ const target = await pickTaskByName(commandCtx, "Block which task?", await openTaskChoices(commandCtx.cwd));
120
+ if (!target) return;
121
+ await callService("discuss.block", { id: discussion.id, task_id: target.id, actor: ACTOR, source: SOURCE });
122
+ commandCtx.ui.notify(`"${discussion.title}" now blocks "${target.title}"`, "info");
100
123
  return;
101
124
  }
102
125
  if (choice === "Unblock a task") {
103
- const taskId = await commandCtx.ui.input("Task artifact id to unblock:", "");
104
- if (!taskId) return;
105
- const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", { id: discussion.id, task_id: taskId, actor: ACTOR, source: SOURCE });
106
- commandCtx.ui.notify(result.unblocked ? `${discussion.id} no longer blocks ${taskId}` : "No such blocking relationship.", "info");
126
+ const blocked = await blockedTaskChoices(discussion.id);
127
+ if (blocked.length === 0) { commandCtx.ui.notify("This discussion isn't blocking any task.", "info"); return; }
128
+ const target = await pickTaskByName(commandCtx, "Unblock which task?", blocked);
129
+ if (!target) return;
130
+ const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", { id: discussion.id, task_id: target.id, actor: ACTOR, source: SOURCE });
131
+ commandCtx.ui.notify(result.unblocked ? `"${discussion.title}" no longer blocks "${target.title}"` : "No such blocking relationship.", "info");
107
132
  }
108
133
  },
109
134
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.21.4",
3
+ "version": "0.21.6",
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/constants.ts CHANGED
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
7
7
  export const DAEMON_UNIT_NAME = "papyrus.service";
8
8
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
9
9
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
10
- export const SQLITE_SCHEMA_VERSION = 16;
10
+ export const SQLITE_SCHEMA_VERSION = 17;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
 
13
13
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
package/src/db.ts CHANGED
@@ -490,6 +490,20 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
490
490
  }
491
491
  },
492
492
  },
493
+ {
494
+ version: 17,
495
+ name: "discussion-task-kind",
496
+ // See domain/discussion.ts for why. Status remaps the same loosely-followed way the
497
+ // doc column always worked: active/deferred -> in-progress, archived -> done.
498
+ up: (db) => {
499
+ db.exec(`
500
+ UPDATE artifacts SET kind = 'task', status = CASE status
501
+ WHEN 'archived' THEN 'done'
502
+ ELSE 'in-progress' END
503
+ WHERE kind = 'doc' AND subtype = 'discussion';
504
+ `);
505
+ },
506
+ },
493
507
  ];
494
508
 
495
509
  /**
@@ -80,11 +80,11 @@ export class Discussions {
80
80
  const posed = this.validatePosedOptions(input.options, input.optionsMode);
81
81
  return this.artifacts.atomic(() => {
82
82
  const discussion = this.artifacts.create({
83
- kind: "doc",
83
+ kind: "task",
84
84
  subtype: DISCUSSION_SUBTYPE,
85
85
  title: input.title,
86
86
  body: input.body ?? "",
87
- status: "active",
87
+ status: "in-progress",
88
88
  labels: input.labels,
89
89
  extra: {
90
90
  discussion: {
@@ -163,7 +163,7 @@ export class Discussions {
163
163
  ...discussion.extra,
164
164
  discussion: { ...state, state: "settled", settlement: validSettlement, settledAt: new Date().toISOString() },
165
165
  }, context)!;
166
- return this.artifacts.setStatus(discussionId, "archived", context) ?? updated;
166
+ return this.artifacts.setStatus(discussionId, "done", context) ?? updated;
167
167
  });
168
168
  }
169
169
 
@@ -173,7 +173,7 @@ export class Discussions {
173
173
  if (this.extra(discussion).state === "settled") throw new DiscussionError(`discussion "${discussionId}" is settled; it can no longer block anything`);
174
174
  const task = this.artifacts.get(taskId);
175
175
  if (!task) throw new DiscussionError(`task "${taskId}" not found`);
176
- if (task.kind !== "task") throw new DiscussionError(`artifact "${taskId}" is not a task`);
176
+ if (task.kind !== "task" || isDiscussionArtifact(task)) throw new DiscussionError(`artifact "${taskId}" is not a task`);
177
177
  this.artifacts.link({ from: discussionId, relation: "blocks", to: taskId }, context);
178
178
  }
179
179
 
@@ -197,7 +197,7 @@ export class Discussions {
197
197
  // (limit omitted) can never fall through to queryArtifacts' own unbounded default -- the same
198
198
  // class of gap notes.ts's noteListInput comment documents fixing for Notes.
199
199
  const limit = Math.min(DISCUSSION_LIST_MAX_LIMIT, Math.max(1, Math.floor(filter.limit ?? DISCUSSION_LIST_DEFAULT_LIMIT)));
200
- const rows = this.artifacts.query({ kind: "doc", subtype: DISCUSSION_SUBTYPE, limit });
200
+ const rows = this.artifacts.query({ kind: "task", subtype: DISCUSSION_SUBTYPE, limit });
201
201
  if (!filter.state) return rows;
202
202
  return rows.filter((row) => {
203
203
  try { return this.extra(row).state === filter.state; } catch { return false; }
@@ -2,12 +2,15 @@
2
2
  * Discuss: a native Papyrus deliberation with a real lifecycle, distinct from a one-shot
3
3
  * "ask" (see the design discussion this implements) and from Discourse's forum (kept fully
4
4
  * standalone by design -- no dependency here, Discuss reuses none of its storage or wire
5
- * shape). A Discussion is a `doc` artifact with subtype "discussion": real graph citizenship
6
- * (edges, show/list) without a fifth enforced artifact kind. Its fine-grained lifecycle
7
- * lives in extra.discussion rather than the shared doc status vocabulary, since Papyrus
8
- * enforces status per-kind, not per-subtype -- "deferred" has no equivalent among a plain
9
- * doc's draft/active/archived. The doc's own status column follows loosely: "active" while
10
- * extra.discussion.state is active or deferred, "archived" once settled.
5
+ * shape). A Discussion is a `task` artifact with subtype "discussion": not a passive
6
+ * record (see the blocking behavior below), so it takes the kind whose lifecycle, focus,
7
+ * and dependency-graph machinery are already built for unresolved work -- not a fifth
8
+ * enforced artifact kind. Its fine-grained lifecycle lives in
9
+ * extra.discussion rather than the shared task status vocabulary, since Papyrus enforces
10
+ * status per-kind, not per-subtype -- "deferred" has no equivalent among a plain task's
11
+ * todo/in-progress/review/done/rejected/canceled. The task's own status column follows
12
+ * loosely: "in-progress" while extra.discussion.state is active or deferred, "done" once
13
+ * settled.
11
14
  *
12
15
  * Blocking is the forcing, load-bearing behavior a Discussion adds over a passive record:
13
16
  * an "active" Discussion that `blocks` a Task refuses that Task's completion (see
@@ -126,7 +129,7 @@ export function validateSelectedOptions(selected: string[], pendingOptions: stri
126
129
 
127
130
  /** True for any artifact (already fetched) that is a Discussion, regardless of its current lifecycle state. */
128
131
  export function isDiscussionArtifact(artifact: { kind: string; subtype: string }): boolean {
129
- return artifact.kind === "doc" && artifact.subtype === DISCUSSION_SUBTYPE;
132
+ return artifact.kind === "task" && artifact.subtype === DISCUSSION_SUBTYPE;
130
133
  }
131
134
 
132
135
  /** Reads and defensively validates the extra.discussion shape; throws on a corrupt/foreign shape rather than silently treating it as some default state. */
@@ -1,4 +1,5 @@
1
1
  import type { Artifact } from "./domain/artifact.ts";
2
+ import { DISCUSSION_SUBTYPE, readDiscussionExtra } from "./domain/discussion.ts";
2
3
  import type { ArtifactStore } from "./ports/artifact-store.ts";
3
4
  import {
4
5
  TASK_CONTEXT_CURRENT_LIMIT,
@@ -34,22 +35,54 @@ function renderCurrent(task: Artifact): string[] {
34
35
  ];
35
36
  }
36
37
 
38
+ /** Same scoping rule taskContext already applies to ordinary tasks, plus the focused task even if scope excludes it. */
39
+ function inScope(taskId: string, activeTaskId: string | undefined, taskIds: Set<string> | undefined): boolean {
40
+ return taskIds === undefined || taskIds.has(taskId) || taskId === activeTaskId;
41
+ }
42
+
43
+ function deferredBlockingDiscussions(artifacts: ArtifactStore, activeTaskId: string | undefined, taskIds: Set<string> | undefined): string[] {
44
+ const discussions = artifacts.query({ kind: "task", subtype: DISCUSSION_SUBTYPE })
45
+ .filter((discussion) => {
46
+ try { return readDiscussionExtra(discussion.extra).state === "deferred"; } catch { return false; }
47
+ });
48
+ if (discussions.length === 0) return [];
49
+
50
+ const blocks = artifacts.relationships({ artifactIds: discussions.map((discussion) => discussion.id) })
51
+ .filter((edge) => edge.relation === "blocks");
52
+ const lines: string[] = [];
53
+ for (const discussion of discussions) {
54
+ for (const edge of blocks) {
55
+ if (edge.from !== discussion.id) continue;
56
+ if (!inScope(edge.to, activeTaskId, taskIds)) continue;
57
+ const blockedTask = artifacts.get(edge.to);
58
+ if (!blockedTask || blockedTask.status === "done" || blockedTask.status === "canceled") continue;
59
+ lines.push(`${discussion.title} (${discussion.id}) -- blocks "${blockedTask.title}"`);
60
+ }
61
+ }
62
+ return lines;
63
+ }
64
+
37
65
  export function taskContext(artifacts: ArtifactStore, activeTaskId?: string, taskIds?: Set<string>): string | null {
38
- const tasks = artifacts.query({ kind: "task" })
66
+ const tasks = artifacts.query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE })
39
67
  .filter((task) => taskIds === undefined || taskIds.has(task.id))
40
68
  .sort((left, right) => left.updated_at.localeCompare(right.updated_at));
41
69
  const open = tasks.filter((task) => task.status !== "done" && task.status !== "canceled");
42
- if (open.length === 0) return null;
70
+ const deferredDiscussions = deferredBlockingDiscussions(artifacts, activeTaskId, taskIds);
71
+ if (open.length === 0 && deferredDiscussions.length === 0) return null;
43
72
 
44
73
  const done = tasks.length - open.length;
45
74
  const active = activeTaskId ? open.find((task) => task.id === activeTaskId) : undefined;
46
75
  const current = active ? [active] : open.filter((task) => task.status === "in-progress" || task.status === "review").slice(0, TASK_CONTEXT_CURRENT_LIMIT);
47
76
  const next = open.find((task) => task.status === "todo");
48
77
  const rejected = open.filter((task) => task.status === "rejected").slice(0, TASK_CONTEXT_REJECTED_LIMIT);
49
- const lines = [`Progress: ${done}/${tasks.length} done`];
78
+ const lines = tasks.length > 0 ? [`Progress: ${done}/${tasks.length} done`] : [];
50
79
  for (const task of current) lines.push(...renderCurrent(task));
51
80
  if (next) lines.push(`Next: ${next.title} (${next.id})`);
52
81
  if (rejected.length > 0) lines.push(`Rejected: ${rejected.map((task) => `${task.title} (${task.id})`).join(", ")}`);
82
+ if (deferredDiscussions.length > 0) {
83
+ lines.push("", "Deferred discussions blocking this scope -- resume and re-surface these, do not leave them dormant:");
84
+ for (const line of deferredDiscussions) lines.push(`• ${line}`);
85
+ }
53
86
  lines.push("", TASK_RECONCILIATION_INSTRUCTION);
54
87
  return lines.join("\n");
55
88
  }
@@ -11,7 +11,7 @@ import {
11
11
  } from "./constants.ts";
12
12
  import type { Artifact } from "./domain/artifact.ts";
13
13
  import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
14
- import { isDiscussionArtifact, readDiscussionExtra } from "./domain/discussion.ts";
14
+ import { DISCUSSION_SUBTYPE, isDiscussionArtifact, readDiscussionExtra } from "./domain/discussion.ts";
15
15
  import { validateGates, type Gate, type GateResult } from "./domain/gate.ts";
16
16
  import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
17
17
  import { normalizeProjectRoot, taskScopeLabel, type TaskScopeSource, type TaskViewMode, type TaskViewSelection } from "./domain/task-scope.ts";
@@ -225,7 +225,7 @@ export class Tasks {
225
225
  throw new Error(`task list limit must be between 1 and ${TASK_SCOPE_MAX_TASKS + 1}`);
226
226
  }
227
227
  if (selection.mode === "all") {
228
- return this.artifacts.query({ kind: "task", status: filter.status, text: filter.text, limit });
228
+ return this.artifacts.query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE, status: filter.status, text: filter.text, limit });
229
229
  }
230
230
  const ids = this.scopes.taskIds(selection.projectRoot, TASK_SCOPE_MAX_TASKS + 1);
231
231
  if (ids.length > TASK_SCOPE_MAX_TASKS) throw new Error(`task project scope exceeds ${TASK_SCOPE_MAX_TASKS} tasks`);
@@ -233,7 +233,7 @@ export class Tasks {
233
233
  const text = filter.text?.toLowerCase();
234
234
  return [...selectedIds]
235
235
  .map((id) => this.artifacts.get(id))
236
- .filter((task): task is Artifact => task?.kind === "task")
236
+ .filter((task): task is Artifact => task?.kind === "task" && !isDiscussionArtifact(task))
237
237
  .filter((task) => filter.status === undefined || task.status === filter.status)
238
238
  .filter((task) => text === undefined || task.title.toLowerCase().includes(text) || task.body.toLowerCase().includes(text))
239
239
  .sort((left, right) => right.updated_at.localeCompare(left.updated_at) || left.id.localeCompare(right.id))
@@ -404,8 +404,8 @@ export class Tasks {
404
404
  const transition = TASK_TRANSITIONS[action];
405
405
  if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
406
406
  if (action === "start") {
407
- const blocking = this.dependencyIds(id).filter((dependencyId) => this.require(dependencyId).status !== "done");
408
- if (blocking.length > 0) throw new Error(`task "${id}" is blocked by dependencies: ${blocking.join(", ")}`);
407
+ const blocking = this.dependencyIds(id).map((dependencyId) => this.require(dependencyId)).filter((dependency) => dependency.status !== "done");
408
+ if (blocking.length > 0) throw new Error(`task "${task.title}" is blocked by dependencies: ${blocking.map((dependency) => `"${dependency.title}"`).join(", ")}`);
409
409
  this.focusStore.set(id, context.sessionId);
410
410
  }
411
411
  const updated = this.artifacts.setStatus(id, transition.to)!;
@@ -420,7 +420,7 @@ export class Tasks {
420
420
 
421
421
  complete(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): TaskCompletion {
422
422
  const task = this.requireReview(id);
423
- this.requireNotBlocked(id);
423
+ this.requireNotBlocked(task);
424
424
  const attemptId = crypto.randomUUID();
425
425
  this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
426
426
  const checklist = this.reviewChecklist(task);
@@ -430,7 +430,7 @@ export class Tasks {
430
430
 
431
431
  async completeAsync(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): Promise<TaskCompletion> {
432
432
  const task = this.requireReview(id);
433
- this.requireNotBlocked(id);
433
+ this.requireNotBlocked(task);
434
434
  const attemptId = crypto.randomUUID();
435
435
  this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
436
436
  const checklist = this.reviewChecklist(task);
@@ -712,10 +712,10 @@ export class Tasks {
712
712
  });
713
713
  }
714
714
 
715
- private requireNotBlocked(id: string): void {
716
- const blockers = this.blockingDiscussions(id);
715
+ private requireNotBlocked(task: Artifact): void {
716
+ const blockers = this.blockingDiscussions(task.id);
717
717
  if (blockers.length > 0) {
718
- throw new Error(`task "${id}" is blocked by ${blockers.length} active Discussion(s): ${blockers.map((discussion) => discussion.id).join(", ")}`);
718
+ throw new Error(`task "${task.title}" is blocked by ${blockers.length} active Discussion(s): ${blockers.map((discussion) => `"${discussion.title}"`).join(", ")}`);
719
719
  }
720
720
  }
721
721
  }