@danypops/papyrus 0.7.0 → 0.9.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/README.md CHANGED
@@ -32,13 +32,14 @@ bun src/cli.ts service install # install, enable, and start user service
32
32
  bun src/cli.ts service status
33
33
  bun src/cli.ts service restart
34
34
 
35
- # Authenticated daemon-backed task automation (add --json for machine output)
35
+ # Authenticated daemon-backed Task operations (add --json for machine output)
36
36
  papyrus tasks plan
37
37
  papyrus tasks depend <task-id> <prerequisite-id>
38
- papyrus tasks start <task-id>
38
+ papyrus tasks update <task-id> --title "Revised task"
39
+ papyrus tasks focus <task-id>
40
+ papyrus tasks pause
41
+ papyrus tasks unpause
39
42
  papyrus tasks complete <task-id>
40
- papyrus tasks automate <task-id> <on|off>
41
- papyrus automation status
42
43
  ```
43
44
 
44
45
  For repository work, install the versioned ownership guard once:
@@ -92,7 +93,7 @@ The `papyrus_*` tools are the low-level graph-store API:
92
93
 
93
94
  Agent-facing domain tools own lifecycle invariants and sit above this store API:
94
95
 
95
- - **`tasks`** — create/list/show/plan, manage the singleton active focus, replace evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort
96
+ - **`tasks`** — create/update/list/show/plan, manage the singleton active focus, replace evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort
96
97
  - **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links
97
98
  - **`rules`** — create/list/show/preview, enable/disable, and attach governance gates to tasks
98
99
  - **`skills`** — create/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
@@ -118,7 +119,7 @@ Run `/tasks` for the interactive task panel:
118
119
  - `g` opens the programmatic Unicode graph; Tab switches dependency/composition views and arrow keys pan
119
120
  - routed graph layouts are bounded to 48 nodes/96 edges; larger graphs use a deterministic, box-drawn line fallback, and renderer failures are contained inside the viewport rather than escaping Pi
120
121
  - advance the `todo → in-progress → review → done` lifecycle; failed review becomes `rejected`, retry returns to `in-progress`, and `canceled` is terminal
121
- - use **active** only as the independent singleton focus that auto-drive continues; focusing a task never changes its lifecycle
122
+ - use **focus** as the independent singleton Task selection that automatic continuation follows; focusing, pausing, or resuming never changes lifecycle
122
123
  - starting nested effort moves todo ancestors to in-progress; submitting enters review; completing review checks both typed checklist proofs and executable gates
123
124
  - passing review marks only that task done and focuses one deterministic ready successor while leaving the successor todo until effort starts
124
125
  - successors are never auto-completed; fan-in, fan-out, diamonds, and disconnected DAGs remain explicit
@@ -139,36 +140,24 @@ papyrus tasks assign-project <task-id> [project-root] --json
139
140
  papyrus tasks active --json
140
141
  papyrus tasks history <id> --json
141
142
  papyrus tasks focus <id> --json
143
+ papyrus tasks focused --json
144
+ papyrus tasks pause --json
145
+ papyrus tasks unpause --json
146
+ papyrus tasks clear-focus --json
147
+ papyrus tasks update <id> --title "Revised title" --body "Revised body" --json
142
148
  papyrus tasks start <id> --json
143
149
  papyrus tasks submit <id> --json
144
150
  papyrus tasks complete <id> --json
145
151
  papyrus tasks reject <id> --json
146
152
  papyrus tasks retry <id> --json
147
153
  papyrus tasks cancel <id> --json
148
- papyrus tasks automate <id> <on|off> --json
149
- papyrus automation status --json
150
- papyrus automation run --json
151
154
  ```
152
155
 
153
- ### Opt-in supervised automation
156
+ ### Focus-driven automatic continuation
154
157
 
155
- Background graph reconciliation is off by default and requires two independent opt-ins: daemon configuration and `automation.enabled` on each Task. Only opted-in Tasks already in `review` are eligible for automatic gate/checklist review; Papyrus never skips the review lifecycle. When one completes, directly dependent opted-in successors that become ready may move from `todo` to `in-progress`. Every completion, rejection, and start is written to append-only history with actor `daemon`, source `automation-reconciler`, reason, and bounded gate evidence.
158
+ Automatic continuation is a property of the singleton Task focus, not a per-Task automation flag. An active focus continues at Pi's public `agent_settled` boundary when Pi is idle and has no queued messages. `tasks pause` preserves the focused Task while stopping continuation; `tasks unpause` resumes it; `tasks clear-focus` removes it. Replacing focus selects an existing Task rather than creating or canceling one.
156
159
 
157
- Enable the daemon with a systemd user-service override and restart it:
158
-
159
- ```ini
160
- [Service]
161
- Environment=PAPYRUS_AUTOMATION_ENABLED=1
162
- ```
163
-
164
- ```bash
165
- systemctl --user edit papyrus.service
166
- systemctl --user restart papyrus.service
167
- papyrus tasks automate <task-id> on
168
- papyrus automation status
169
- ```
170
-
171
- Secure defaults are a 60-second interval, 10 Task transitions per sweep, gate concurrency 1, and a 120-second sweep deadline. Optional environment settings are `PAPYRUS_AUTOMATION_INTERVAL_MS` (10 seconds–1 hour), `PAPYRUS_AUTOMATION_MAX_TASKS` (1–100), `PAPYRUS_AUTOMATION_GATE_CONCURRENCY` (1–4), and `PAPYRUS_AUTOMATION_MAX_RUNTIME_MS` (1 ms–10 minutes). Candidate scans are capped at 1,000 review Tasks, sweeps are single-flight, subprocess gates inherit the sweep deadline, result arrays are bounded by the Task limit, and logs contain counts rather than gate output. `papyrus automation run` uses the same policy and refuses to reconcile while global automation is disabled.
160
+ Continuation is single-flight and bounded to 20 automatic turns or 6 unchanged Task snapshots. Reaching either bound persists a paused focus and records the reason in append-only Task history. Human input resumes only these automatically paused focuses; an explicit user pause remains paused.
172
161
 
173
162
  Checklist criteria are an item-to-proof map. Every new item requires one or more typed references to inspectable evidence; proof presence does not imply that the evidence passed an executable gate:
174
163
 
@@ -185,9 +174,11 @@ checklist: {
185
174
 
186
175
  Proof types are `file`, `symbol`, `code`, `test`, `command`, `artifact`, and `url`. Existing array checklists remain readable as legacy items with `proof: missing`; Papyrus does not invent evidence.
187
176
 
188
- Papyrus also injects an Alef-style reconciliation block on every agent turn while work remains: `Current`, `Desired`, `Verify`, and `Next`. The agent is explicitly instructed to ask **“Did we accomplish this task?”** and run review before marking it done. The injection disappears when every task is done or canceled.
177
+ Papyrus also injects an Alef-style reconciliation block at `before_agent_start` while work remains: `Current`, `Desired`, `Verify`, and `Next`. The agent is explicitly instructed to ask **“Did we accomplish this task?”** and run review before marking it done. The injection disappears when every task is done or canceled.
178
+
179
+ 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.
189
180
 
190
- In TUI and RPC modes, the extension checks the singleton active focus at Pi’s public `agent_settled` lifecycle boundary. If a focused task remains and no continuation is already pending, it queues one hidden next turn. No manual driving command is required. Driving is single-flight and pauses after 20 automatic turns or 6 unchanged task snapshots; human input and task progress reset the bounded counters automatically.
181
+ 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.
191
182
 
192
183
  ## Why
193
184
 
@@ -202,10 +193,10 @@ packed install npm:@danypops/papyrus
202
193
  ~/.pi/agent/npm/node_modules/.bin/papyrus service install
203
194
  ```
204
195
 
205
- Existing databases are never migrated on daemon boot. After upgrading to project-scoped task views, run the authenticated CLI migration explicitly. Older databases receive prerequisite schemas in one transaction. Existing Tasks are deliberately marked **unscoped**: Papyrus does not guess ownership from titles, labels, historical cwd, or repository names. They remain visible in **All projects** until explicitly assigned with `papyrus tasks assign-project <task-id> [project-root]`:
196
+ Existing databases are never migrated on daemon boot. After upgrading to focus-driven Task continuation, run the authenticated CLI migration explicitly. Older databases receive prerequisite schemas in one transaction. Existing Tasks are deliberately marked **unscoped**: Papyrus does not guess ownership from titles, labels, historical cwd, or repository names. They remain visible in **All projects** until explicitly assigned with `papyrus tasks assign-project <task-id> [project-root]`:
206
197
 
207
198
  ```bash
208
- ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-scope
199
+ ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-focus
209
200
  ```
210
201
 
211
202
  Until that command succeeds, health reports `migrationRequired` and normal domain operations are rejected with actionable guidance. Migration is not exposed as a Pi tool or MCP action. New empty databases bootstrap directly at the current schema.
@@ -23,6 +23,15 @@ export interface ActiveTaskContinuationDecision {
23
23
  }
24
24
 
25
25
  const TITLE_LIMIT = 120;
26
+ const AUTOMATIC_PAUSE_PREFIX = "automatic continuation paused:";
27
+
28
+ export function automaticPauseReason(reason: string): string {
29
+ return `${AUTOMATIC_PAUSE_PREFIX} ${reason}`;
30
+ }
31
+
32
+ export function shouldResumeFocusOnHumanInput(status: string, pauseReason?: string): boolean {
33
+ return status === "paused" && pauseReason?.startsWith(AUTOMATIC_PAUSE_PREFIX) === true;
34
+ }
26
35
 
27
36
  function fingerprint(task: ActiveTaskMarker): string {
28
37
  return `${task.id}:${task.updated_at}`;
@@ -0,0 +1,80 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
4
+ PAPYRUS_CONTEXT_INJECTION_SCHEMA,
5
+ } from "../../src/constants.ts";
6
+ import type { Artifact } from "../../src/domain/artifact.ts";
7
+ import { ruleInjectionPreview } from "./rules.ts";
8
+
9
+ export interface ContextPayloadSize {
10
+ characters: number;
11
+ bytes: number;
12
+ }
13
+
14
+ export interface PapyrusContextInjectionObservation {
15
+ schema: typeof PAPYRUS_CONTEXT_INJECTION_SCHEMA;
16
+ observedAt: number;
17
+ sequence: number;
18
+ producerId: string;
19
+ before: ContextPayloadSize;
20
+ rules: ContextPayloadSize & { count: number };
21
+ tasks: ContextPayloadSize;
22
+ injected: ContextPayloadSize;
23
+ after: ContextPayloadSize;
24
+ estimatedTokens: number;
25
+ share: number;
26
+ fingerprint: string;
27
+ unchanged: boolean;
28
+ }
29
+
30
+ export interface BuildContextInjectionInput {
31
+ basePrompt: string;
32
+ rules: Array<Pick<Artifact, "title" | "body" | "extra">>;
33
+ taskSummary: string | null;
34
+ observedAt: number;
35
+ sequence: number;
36
+ producerId: string;
37
+ previousFingerprint?: string;
38
+ }
39
+
40
+ const encoder = new TextEncoder();
41
+
42
+ function size(value: string): ContextPayloadSize {
43
+ return { characters: value.length, bytes: encoder.encode(value).byteLength };
44
+ }
45
+
46
+ export function buildContextInjection(input: BuildContextInjectionInput): {
47
+ prompt: string;
48
+ ruleBlock: string;
49
+ taskBlock: string;
50
+ observation: PapyrusContextInjectionObservation;
51
+ } {
52
+ const ruleContent = input.rules.map(ruleInjectionPreview).join("\n");
53
+ const ruleBlock = ruleContent ? `\n\n## Active rules (Papyrus)\n\n${ruleContent}\n` : "";
54
+ const taskBlock = input.taskSummary ? `\n\n## Open tasks (Papyrus)\n\n${input.taskSummary}\n` : "";
55
+ const injected = `${ruleBlock}${taskBlock}`;
56
+ const prompt = `${input.basePrompt}${injected}`;
57
+ const fingerprint = createHash("sha256").update(injected).digest("hex");
58
+ const injectedSize = size(injected);
59
+ const afterSize = size(prompt);
60
+ return {
61
+ prompt,
62
+ ruleBlock,
63
+ taskBlock,
64
+ observation: {
65
+ schema: PAPYRUS_CONTEXT_INJECTION_SCHEMA,
66
+ observedAt: input.observedAt,
67
+ sequence: input.sequence,
68
+ producerId: input.producerId,
69
+ before: size(input.basePrompt),
70
+ rules: { ...size(ruleBlock), count: input.rules.length },
71
+ tasks: size(taskBlock),
72
+ injected: injectedSize,
73
+ after: afterSize,
74
+ estimatedTokens: Math.ceil(injectedSize.characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN),
75
+ share: afterSize.characters === 0 ? 0 : injectedSize.characters / afterSize.characters,
76
+ fingerprint,
77
+ unchanged: input.previousFingerprint === fingerprint,
78
+ },
79
+ };
80
+ }
@@ -31,7 +31,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
31
31
  pi.registerTool({
32
32
  name: "tasks",
33
33
  label: "Tasks",
34
- description: "Task domain tool. ACTIONS: create, list, show, history, scope, set_scope, assign_project, graph, plan, active, focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, set_automation, 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.",
34
+ 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
35
  parameters: Type.Object({
36
36
  action: Type.String(),
37
37
  id: Type.Optional(Type.String()),
@@ -44,7 +44,6 @@ export function registerDomainTools(pi: ExtensionAPI): void {
44
44
  direction: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")])),
45
45
  reason: Type.Optional(Type.String()),
46
46
  session_id: Type.Optional(Type.String()),
47
- enabled: Type.Optional(Type.Boolean()),
48
47
  labels: Type.Optional(Type.Array(Type.String())),
49
48
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
50
49
  gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
@@ -87,6 +86,19 @@ export function registerDomainTools(pi: ExtensionAPI): void {
87
86
  const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
88
87
  return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
89
88
  }
89
+ if (action === "focused") {
90
+ const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string } | null>("tasks.focused", request);
91
+ return text(focus ? `Focused (${focus.status}): ${artifactLine(focus.artifact)}` : "No focused task.", { focus });
92
+ }
93
+ if (action === "pause" || action === "unpause") {
94
+ const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
95
+ const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, request);
96
+ return text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, { focus });
97
+ }
98
+ if (action === "clear_focus") {
99
+ const result = await callService<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", request);
100
+ return text(result.cleared ? "Task focus cleared." : "No focused task.", result);
101
+ }
90
102
  if (action === "graph") {
91
103
  const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", request);
92
104
  const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
@@ -131,9 +143,9 @@ export function registerDomainTools(pi: ExtensionAPI): void {
131
143
  reject: "tasks.reject",
132
144
  retry: "tasks.retry",
133
145
  cancel: "tasks.cancel",
146
+ update: "tasks.update",
134
147
  set_scope: "tasks.set_scope",
135
148
  assign_project: "tasks.assign_project",
136
- set_automation: "tasks.set_automation",
137
149
  depend: "tasks.depend",
138
150
  contain: "tasks.contain",
139
151
  } as const;
@@ -7,12 +7,14 @@
7
7
  * Injection: active rules + open tasks appended to system prompt every turn.
8
8
  * "Are we there yet?" — the agent sees its open work items.
9
9
  */
10
+ import { randomUUID } from "node:crypto";
10
11
  import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
11
12
  import { Type } from "typebox";
12
13
  import { truncateToWidth } from "@earendil-works/pi-tui";
13
14
  import {
14
15
  TASK_DRIVER_MAX_TURNS,
15
16
  TASK_DRIVER_MAX_UNCHANGED_TURNS,
17
+ PAPYRUS_CONTEXT_INJECTION_CHANNEL,
16
18
  } from "../../src/constants.ts";
17
19
  import type { Artifact } from "../../src/domain/artifact.ts";
18
20
  import type { GateResult } from "../../src/domain/gate.ts";
@@ -20,9 +22,10 @@ import { formatMetadata } from "./artifact-format.ts";
20
22
  import { callService } from "./service-client.ts";
21
23
  import { registerDomainTools } from "./domain-tools.ts";
22
24
  import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
23
- import { ActiveTaskContinuation, type ActiveTaskMarker } from "./active-task-continuation.ts";
25
+ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
24
26
  import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
25
27
  import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
28
+ import { buildContextInjection } from "./context-injection-telemetry.ts";
26
29
 
27
30
  function text(t: string, details: Record<string, unknown> = {}) {
28
31
  return { content: [{ type: "text" as const, text: t }], details };
@@ -41,7 +44,7 @@ export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjec
41
44
  const row = projection.rows[index]!;
42
45
  const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
43
46
  const hierarchy = taskTreeConnector({ depth: row.depth, hasChildren: row.hasOpenChildren, hasLaterSibling: laterSibling });
44
- const focus = row.active ? theme.fg("accent", "▶") : " ";
47
+ const focus = row.active ? theme.fg("accent", row.focusStatus === "paused" ? "Ⅱ" : "▶") : " ";
45
48
  const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
46
49
  const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
47
50
  lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}`, width, "…"));
@@ -130,6 +133,9 @@ class TaskOverlay {
130
133
 
131
134
  export default async function (pi: ExtensionAPI) {
132
135
  registerDomainTools(pi);
136
+ let contextInjectionSequence = 0;
137
+ const contextInjectionProducerId = randomUUID();
138
+ let previousContextInjectionFingerprint: string | undefined;
133
139
  const taskContinuation = new ActiveTaskContinuation({
134
140
  maxTurns: TASK_DRIVER_MAX_TURNS,
135
141
  maxUnchangedTurns: TASK_DRIVER_MAX_UNCHANGED_TURNS,
@@ -149,8 +155,13 @@ export default async function (pi: ExtensionAPI) {
149
155
  content: decision.prompt,
150
156
  display: false,
151
157
  }, { triggerTurn: true, deliverAs: "nextTurn" });
152
- } else if (decision.action === "pause" && ctx.hasUI) {
153
- ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input or task progress resumes it automatically.`, "warning");
158
+ } else if (decision.action === "pause") {
159
+ await callService("tasks.pause", {
160
+ actor: "system",
161
+ source: "task-continuation",
162
+ reason: automaticPauseReason(decision.reason),
163
+ });
164
+ if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
154
165
  }
155
166
  } catch {
156
167
  // The daemon may be unavailable during startup, reload, or shutdown.
@@ -361,8 +372,17 @@ export default async function (pi: ExtensionAPI) {
361
372
  // agent_settled is intentionally later than agent_end: Pi guarantees that
362
373
  // retry, compaction retry, and queued follow-up processing have finished.
363
374
 
364
- pi.on("input", (event) => {
365
- if (event.source !== "extension") taskContinuation.onHumanInput();
375
+ pi.on("input", async (event) => {
376
+ if (event.source === "extension") return;
377
+ taskContinuation.onHumanInput();
378
+ try {
379
+ const focus = await callService<Record<string, never>, { status: string; pauseReason?: string } | null>("tasks.focused", {});
380
+ if (focus && shouldResumeFocusOnHumanInput(focus.status, focus.pauseReason)) {
381
+ await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation" });
382
+ }
383
+ } catch {
384
+ // The daemon may be unavailable during startup, reload, or shutdown.
385
+ }
366
386
  });
367
387
  pi.on("agent_start", () => { taskContinuation.onAgentStart(); });
368
388
  pi.on("agent_settled", async (_event, ctx) => { await driveActiveTasks(ctx); });
@@ -377,17 +397,18 @@ export default async function (pi: ExtensionAPI) {
377
397
  callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd }),
378
398
  callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd }),
379
399
  ]);
380
- let prompt = event.systemPrompt ?? "";
381
- if (rules.length > 0) {
382
- const block = rules.map(rulesModule.ruleInjectionPreview).join("\n");
383
- prompt += `\n\n## Active rules (Papyrus)\n\n${block}\n`;
384
- }
385
- if (summary) {
386
- prompt += `\n\n## Open tasks (Papyrus)\n\n${summary}\n`;
387
- }
388
- if (prompt !== (event.systemPrompt ?? "")) {
389
- return { systemPrompt: prompt };
390
- }
400
+ const injection = buildContextInjection({
401
+ basePrompt: event.systemPrompt ?? "",
402
+ rules,
403
+ taskSummary: summary,
404
+ observedAt: Date.now(),
405
+ sequence: ++contextInjectionSequence,
406
+ producerId: contextInjectionProducerId,
407
+ previousFingerprint: previousContextInjectionFingerprint,
408
+ });
409
+ previousContextInjectionFingerprint = injection.observation.fingerprint;
410
+ pi.events.emit(PAPYRUS_CONTEXT_INJECTION_CHANNEL, injection.observation);
411
+ if (injection.prompt !== (event.systemPrompt ?? "")) return { systemPrompt: injection.prompt };
391
412
  } catch {
392
413
  // DB not ready
393
414
  }
@@ -7,6 +7,7 @@ export interface TaskWidgetRow {
7
7
  depth: number;
8
8
  hasOpenChildren: boolean;
9
9
  active: boolean;
10
+ focusStatus?: "active" | "paused";
10
11
  }
11
12
 
12
13
  export interface TaskWidgetProjection {
@@ -35,7 +36,7 @@ export function buildTaskWidgetProjection(
35
36
  if (!node) return;
36
37
  visited.add(id);
37
38
  const open = isOpen(node.task);
38
- if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true });
39
+ if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true, focusStatus: node.focusStatus });
39
40
  const childDepth = open ? openDepth + 1 : openDepth;
40
41
  for (const childId of node.childIds) visit(childId, childDepth);
41
42
  };
@@ -30,12 +30,6 @@ const STATUS_ACTIONS: Record<string, string[]> = {
30
30
 
31
31
  type TaskRow = Artifact;
32
32
 
33
- function taskAutomationEnabled(task: Artifact): boolean {
34
- const automation = task.extra["automation"];
35
- return typeof automation === "object" && automation !== null && !Array.isArray(automation)
36
- && (automation as Record<string, unknown>)["enabled"] === true;
37
- }
38
-
39
33
  export interface TaskHierarchyRow {
40
34
  task: TaskRow;
41
35
  depth: number;
@@ -114,11 +108,12 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
114
108
  if (action.type !== "action" || !action.row) continue;
115
109
 
116
110
  const active = graph.nodes.find((node) => node.task.id === action.row!.id)?.active === true;
117
- const automationEnabled = taskAutomationEnabled(action.row);
111
+ const focusStatus = graph.nodes.find((node) => node.task.id === action.row!.id)?.focusStatus;
118
112
  const choices = [
119
113
  "Show details",
114
+ "Edit task",
120
115
  ...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
121
- ...(action.row.status !== "done" && action.row.status !== "canceled" ? [automationEnabled ? "Disable automation" : "Enable automation"] : []),
116
+ ...(active ? [focusStatus === "paused" ? "Resume focus" : "Pause focus", "Clear focus"] : []),
122
117
  ...(action.row.status === "review" ? ["Run gates"] : []),
123
118
  ...(STATUS_ACTIONS[action.row.status] ?? []),
124
119
  ];
@@ -130,26 +125,39 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
130
125
  if (!art) { ctx.ui.notify("Not found", "error"); continue; }
131
126
  const history = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", { id: art.id, direction: "desc" });
132
127
  await showTaskDetails(ctx, art, graph, undefined, [...history.events].reverse());
128
+ } else if (choice === "Edit task") {
129
+ const title = await ctx.ui.input("Task title:", action.row.title);
130
+ if (title === undefined) continue;
131
+ const body = await ctx.ui.input("Task body:", action.row.body);
132
+ if (body === undefined) continue;
133
+ try {
134
+ const updated = await callService<Record<string, unknown>, Artifact>("tasks.update", {
135
+ id: action.row.id,
136
+ title,
137
+ body,
138
+ actor: "user",
139
+ source: "tasks-tui",
140
+ });
141
+ action.row.title = updated.title;
142
+ action.row.body = updated.body;
143
+ ctx.ui.notify(`Updated: ${updated.title}`, "info");
144
+ } catch (error) {
145
+ ctx.ui.notify(`Task update failed: ${error instanceof Error ? error.message : error}`, "error");
146
+ }
133
147
  } else if (choice === "Make active") {
134
148
  try {
135
- await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id });
149
+ await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui" });
136
150
  ctx.ui.notify(`Active: ${action.row.title}`, "info");
137
151
  } catch (error) {
138
152
  ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
139
153
  }
140
- } else if (choice === "Enable automation" || choice === "Disable automation") {
154
+ } else if (choice === "Pause focus" || choice === "Resume focus" || choice === "Clear focus") {
141
155
  try {
142
- const enabled = choice === "Enable automation";
143
- const updated = await callService<Record<string, unknown>, Artifact>("tasks.set_automation", {
144
- id: action.row.id,
145
- enabled,
146
- actor: "user",
147
- source: "tasks-tui",
148
- });
149
- action.row.extra = updated.extra;
150
- ctx.ui.notify(`Automation ${enabled ? "enabled" : "disabled"}: ${action.row.title}`, enabled ? "warning" : "info");
156
+ const operation = choice === "Pause focus" ? "tasks.pause" : choice === "Resume focus" ? "tasks.unpause" : "tasks.clear_focus";
157
+ await callService(operation, { actor: "user", source: "tasks-tui" });
158
+ ctx.ui.notify(choice === "Clear focus" ? "Task focus cleared" : choice, "info");
151
159
  } catch (error) {
152
- ctx.ui.notify(`Automation setting failed: ${error instanceof Error ? error.message : error}`, "error");
160
+ ctx.ui.notify(`Focus action failed: ${error instanceof Error ? error.message : error}`, "error");
153
161
  }
154
162
  } else if (choice === "Run gates") {
155
163
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.7.0",
3
+ "version": "0.9.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"],
@@ -9,8 +9,9 @@ import type {
9
9
  ArtifactQuery,
10
10
  CreateArtifactInput,
11
11
  RelationshipQuery,
12
+ UpdateArtifactInput,
12
13
  } from "../domain/artifact.ts";
13
- import { createArtifact, getArtifact, linkArtifacts, queryArtifacts, updateExtra, updateStatus } from "../ops.ts";
14
+ import { createArtifact, getArtifact, linkArtifacts, queryArtifacts, updateArtifactContent, updateExtra, updateStatus } from "../ops.ts";
14
15
 
15
16
  export class SQLiteArtifactStore implements AtomicArtifactStore {
16
17
  constructor(private readonly db: Db) {}
@@ -43,6 +44,10 @@ export class SQLiteArtifactStore implements AtomicArtifactStore {
43
44
  return updateExtra(this.db, id, extra);
44
45
  }
45
46
 
47
+ updateContent(id: string, input: UpdateArtifactInput): Artifact | null {
48
+ return updateArtifactContent(this.db, id, input);
49
+ }
50
+
46
51
  relationships(filter: RelationshipQuery = {}): ArtifactEdge[] {
47
52
  if (filter.artifactIds?.length === 0) return [];
48
53
  const conditions: string[] = [];
@@ -1,26 +1,20 @@
1
1
  import type { Db } from "../db.ts";
2
2
  import { inTransaction } from "../db.ts";
3
- import type { TaskFocusStore } from "../ports/task-focus-store.ts";
3
+ import type { TaskFocusState, TaskFocusStatus, TaskFocusStore } from "../ports/task-focus-store.ts";
4
4
 
5
5
  export class SQLiteTaskFocusStore implements TaskFocusStore {
6
6
  constructor(private readonly db: Db) {}
7
7
 
8
- get(): string | undefined {
9
- const row = this.db.prepare("SELECT task_id FROM task_focus WHERE scope = 'global'").get() as
10
- | { task_id: string }
8
+ get(): TaskFocusState | undefined {
9
+ const row = this.db.prepare("SELECT task_id, status, pause_reason, updated_at FROM task_focus WHERE scope = 'global'").get() as
10
+ | { task_id: string; status: TaskFocusStatus; pause_reason: string | null; updated_at: string }
11
11
  | null;
12
- return row?.task_id;
12
+ return row ? { taskId: row.task_id, status: row.status, updatedAt: row.updated_at, ...(row.pause_reason ? { pauseReason: row.pause_reason } : {}) } : undefined;
13
13
  }
14
14
 
15
- set(taskId: string): void {
16
- inTransaction(this.db, () => {
17
- this.db.prepare(`
18
- INSERT INTO task_focus (scope, task_id, updated_at)
19
- VALUES ('global', ?, ?)
20
- ON CONFLICT(scope) DO UPDATE SET task_id = excluded.task_id, updated_at = excluded.updated_at
21
- `).run(taskId, new Date().toISOString());
22
- });
23
- }
15
+ set(taskId: string): TaskFocusState { return this.write(taskId, "active"); }
16
+ pause(taskId: string, reason?: string): TaskFocusState { return this.transition(taskId, "active", "paused", reason); }
17
+ unpause(taskId: string): TaskFocusState { return this.transition(taskId, "paused", "active"); }
24
18
 
25
19
  clear(taskId?: string): void {
26
20
  inTransaction(this.db, () => {
@@ -28,4 +22,23 @@ export class SQLiteTaskFocusStore implements TaskFocusStore {
28
22
  else this.db.prepare("DELETE FROM task_focus WHERE scope = 'global' AND task_id = ?").run(taskId);
29
23
  });
30
24
  }
25
+
26
+ private transition(taskId: string, expected: TaskFocusStatus, status: TaskFocusStatus, reason?: string): TaskFocusState {
27
+ const current = this.get();
28
+ if (current?.taskId !== taskId) throw new Error(`task "${taskId}" is not focused`);
29
+ if (current.status !== expected) throw new Error(`focus is ${current.status}, expected ${expected}`);
30
+ return this.write(taskId, status, reason);
31
+ }
32
+
33
+ private write(taskId: string, status: TaskFocusStatus, pauseReason?: string): TaskFocusState {
34
+ const updatedAt = new Date().toISOString();
35
+ inTransaction(this.db, () => {
36
+ this.db.prepare(`
37
+ INSERT INTO task_focus (scope, task_id, status, pause_reason, updated_at)
38
+ VALUES ('global', ?, ?, ?, ?)
39
+ ON CONFLICT(scope) DO UPDATE SET task_id = excluded.task_id, status = excluded.status, pause_reason = excluded.pause_reason, updated_at = excluded.updated_at
40
+ `).run(taskId, status, pauseReason ?? null, updatedAt);
41
+ });
42
+ return { taskId, status, updatedAt, ...(pauseReason ? { pauseReason } : {}) };
43
+ }
31
44
  }