@danypops/papyrus 0.32.2 → 0.33.1

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.
@@ -238,7 +238,7 @@ export function registerTasksTool(pi: ExtensionAPI): void {
238
238
  pi.registerTool({
239
239
  name: "tasks",
240
240
  label: "Tasks",
241
- 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, undepend, contain, uncontain, remove, restore, claim, heartbeat_lease, release_lease, lease, event_feed. 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. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). claim/heartbeat_lease/release_lease/lease manage a bounded work-reservation lease -- independent of both lifecycle status and Focus, so multiple sessions can Focus the same task while only one owner holds its lease at a time; claim throws if a DIFFERENT owner already holds a live lease, release/heartbeat require the exact token claim returned. `owner` defaults to this session's own id when omitted. PREFER addressing a task by `name` (its exact title) over `id` for every action -- id is a backend implementation detail, resolved from name automatically, and only needs to appear explicitly when a name is genuinely ambiguous (two tasks share a title; the error will say so and list the real ids to disambiguate with). Task results likewise show name and status, not id, unless two shown tasks share a title. `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the name-based equivalents of `dependency_id`/`parent_id`/`child_id`/`root_task_id`/`depends_on`. Prefer this over low-level papyrus_* tools for task work.",
241
+ description: "Task domain tool. ACTIONS: create, update, list, show, history, context, 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, undepend, contain, uncontain, remove, restore, claim, heartbeat_lease, release_lease, lease, event_feed. context returns the full current/desired/verify reconciliation plan for the active task(s) -- the system prompt is injected with only a one-line pointer to save tokens on turns that don't need it; call this explicitly when you actually need the full plan (e.g. after a compaction, or before reconciling). 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. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). claim/heartbeat_lease/release_lease/lease manage a bounded work-reservation lease -- independent of both lifecycle status and Focus, so multiple sessions can Focus the same task while only one owner holds its lease at a time; claim throws if a DIFFERENT owner already holds a live lease, release/heartbeat require the exact token claim returned. `owner` defaults to this session's own id when omitted. PREFER addressing a task by `name` (its exact title) over `id` for every action -- id is a backend implementation detail, resolved from name automatically, and only needs to appear explicitly when a name is genuinely ambiguous (two tasks share a title; the error will say so and list the real ids to disambiguate with). Task results likewise show name and status, not id, unless two shown tasks share a title. `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the name-based equivalents of `dependency_id`/`parent_id`/`child_id`/`root_task_id`/`depends_on`. Prefer this over low-level papyrus_* tools for task work.",
242
242
  parameters: Type.Object({
243
243
  action: Type.String(),
244
244
  id: Type.Optional(Type.String()),
@@ -390,6 +390,11 @@ export function registerTasksTool(pi: ExtensionAPI): void {
390
390
  const output = lines.join("\n") || "No tasks in execution plan.";
391
391
  return text(output, createPreviewDetails("tasks.plan", "Task execution plan", output));
392
392
  }
393
+ if (action === "context") {
394
+ const summary = await callService<Record<string, unknown>, string | null>("tasks.context", { ...request, verbosity: "full" });
395
+ const output = summary ?? "No open tasks.";
396
+ return text(output, createPreviewDetails("tasks.context", "Task reconciliation context", output));
397
+ }
393
398
  if (action === "set_checklist") {
394
399
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_checklist", params);
395
400
  return text(`Updated checklist: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
@@ -607,7 +607,7 @@ export default async function (pi: ExtensionAPI) {
607
607
  const [rules, playbooks, summary] = await Promise.all([
608
608
  callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
609
609
  callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "extra">>>("playbooks.list", { status: "active", limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS }),
610
- callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId }),
610
+ callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId, verbosity: "summary" }),
611
611
  ]);
612
612
  const injection = buildContextInjection({
613
613
  basePrompt: event.systemPrompt ?? "",
@@ -1,45 +1,29 @@
1
+ import { createRetryingClient, type RetryingClient } from "@danypops/daemon-kit/pi-client";
1
2
  import { connectPapyrusClient, type PapyrusClient } from "../../src/client.ts";
2
3
  import type { OperationName } from "../../src/service.ts";
3
4
 
4
5
  type ClientConnector = () => Promise<PapyrusClient>;
5
6
 
6
7
  let connector: ClientConnector = () => connectPapyrusClient();
7
- let cached: PapyrusClient | undefined;
8
+ const client: RetryingClient<PapyrusClient> = createRetryingClient<PapyrusClient>(() => connector(), { label: "Papyrus" });
8
9
 
9
10
  export async function papyrusClient(): Promise<PapyrusClient> {
10
- if (cached) return cached;
11
- cached = await connector();
12
- return cached;
13
- }
14
-
15
- function staleConnection(error: unknown): boolean {
16
- if (error instanceof TypeError) return true;
17
- if (!(error instanceof Error)) return false;
18
- if (error.name === "AbortError" || error.name === "TimeoutError") return true;
19
- return /fetch failed|network|socket|ECONNRESET|ECONNREFUSED|connection refused/i.test(error.message);
11
+ return client.call(async (resolved) => resolved);
20
12
  }
21
13
 
22
14
  export async function callService<Input extends Record<string, unknown>, Output>(
23
15
  operation: OperationName,
24
16
  input: Input,
25
17
  ): Promise<Output> {
26
- for (let attempt = 0; attempt < 2; attempt += 1) {
27
- try {
28
- return await (await papyrusClient()).call<Input, Output>(operation, input);
29
- } catch (error) {
30
- cached = undefined;
31
- if (attempt === 1 || !staleConnection(error)) throw error;
32
- }
33
- }
34
- throw new Error("Papyrus daemon client retry exhausted");
18
+ return client.call((resolved) => resolved.call<Input, Output>(operation, input));
35
19
  }
36
20
 
37
21
  export function setPapyrusClientConnectorForTests(value: ClientConnector): void {
38
- cached = undefined;
39
22
  connector = value;
23
+ client.reset();
40
24
  }
41
25
 
42
26
  export function resetPapyrusClientForTests(): void {
43
- cached = undefined;
44
27
  connector = () => connectPapyrusClient();
28
+ client.reset();
45
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.32.2",
3
+ "version": "0.33.1",
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"],
@@ -42,6 +42,6 @@
42
42
  "files": ["src", "extension", "README.md"],
43
43
  "dependencies": {
44
44
  "beautiful-mermaid": "1.1.3",
45
- "@danypops/daemon-kit": "^0.2.1"
45
+ "@danypops/daemon-kit": "^0.4.0"
46
46
  }
47
47
  }
@@ -164,6 +164,7 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
164
164
  artifacts,
165
165
  tasks.active(taskFilter(input))?.id,
166
166
  new Set(tasks.list(taskFilter(input)).map((task) => task.id)),
167
+ optionalString(input, "verbosity") === "summary" ? "summary" : "full",
167
168
  )),
168
169
  define("tasks.reject", (input: OperationInput) => tasks.transition(string(input, "id"), "reject", eventContext(input))),
169
170
  define("tasks.retry", (input: OperationInput) => tasks.transition(string(input, "id"), "retry", eventContext(input))),
@@ -35,6 +35,11 @@ function renderCurrent(task: Artifact): string[] {
35
35
  ];
36
36
  }
37
37
 
38
+ /** The unconditional-injection form: enough to know the current task and that fuller detail exists, without repeating its full Desired/Verify prose every turn. */
39
+ function renderCurrentSummary(task: Artifact): string[] {
40
+ return [`Current: ${task.title} [${task.status}] -- call tasks(action="context") for the full current/desired/verify plan`];
41
+ }
42
+
38
43
  /** Same scoping rule taskContext already applies to ordinary tasks, plus the focused task even if scope excludes it. */
39
44
  function inScope(taskId: string, activeTaskId: string | undefined, taskIds: Set<string> | undefined): boolean {
40
45
  return taskIds === undefined || taskIds.has(taskId) || taskId === activeTaskId;
@@ -62,7 +67,16 @@ function deferredBlockingDiscussions(artifacts: ArtifactStore, activeTaskId: str
62
67
  return lines;
63
68
  }
64
69
 
65
- export function taskContext(artifacts: ArtifactStore, activeTaskId?: string, taskIds?: Set<string>): string | null {
70
+ export type TaskContextVerbosity = "summary" | "full";
71
+
72
+ /**
73
+ * verbosity="full" (the default, matching tasks(action="context") called on demand) renders the
74
+ * current task's complete Desired/Verify plan. verbosity="summary" (used for the unconditional
75
+ * system-prompt injection every turn) renders only enough for the agent to know a current task
76
+ * exists and that the full plan is one explicit call away -- avoiding repeating the same
77
+ * unchanged prose every single turn for a task that can persist across dozens of turns.
78
+ */
79
+ export function taskContext(artifacts: ArtifactStore, activeTaskId?: string, taskIds?: Set<string>, verbosity: TaskContextVerbosity = "full"): string | null {
66
80
  const tasks = artifacts.query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE })
67
81
  .filter((task) => taskIds === undefined || taskIds.has(task.id))
68
82
  .sort((left, right) => left.updated_at.localeCompare(right.updated_at));
@@ -76,7 +90,8 @@ export function taskContext(artifacts: ArtifactStore, activeTaskId?: string, tas
76
90
  const next = open.find((task) => task.status === "todo");
77
91
  const rejected = open.filter((task) => task.status === "rejected").slice(0, TASK_CONTEXT_REJECTED_LIMIT);
78
92
  const lines = tasks.length > 0 ? [`Progress: ${done}/${tasks.length} done`] : [];
79
- for (const task of current) lines.push(...renderCurrent(task));
93
+ const renderTask = verbosity === "summary" ? renderCurrentSummary : renderCurrent;
94
+ for (const task of current) lines.push(...renderTask(task));
80
95
  if (next) lines.push(`Next: ${next.title}`);
81
96
  if (rejected.length > 0) lines.push(`Rejected: ${rejected.map((task) => task.title).join(", ")}`);
82
97
  if (deferredDiscussions.length > 0) {