@danypops/papyrus 0.7.0 → 0.8.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
 
@@ -187,7 +176,7 @@ Proof types are `file`, `symbol`, `code`, `test`, `command`, `artifact`, and `ur
187
176
 
188
177
  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.
189
178
 
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.
179
+ 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
180
 
192
181
  ## Why
193
182
 
@@ -202,10 +191,10 @@ packed install npm:@danypops/papyrus
202
191
  ~/.pi/agent/npm/node_modules/.bin/papyrus service install
203
192
  ```
204
193
 
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]`:
194
+ 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
195
 
207
196
  ```bash
208
- ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-scope
197
+ ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-focus
209
198
  ```
210
199
 
211
200
  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}`;
@@ -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;
@@ -20,7 +20,7 @@ import { formatMetadata } from "./artifact-format.ts";
20
20
  import { callService } from "./service-client.ts";
21
21
  import { registerDomainTools } from "./domain-tools.ts";
22
22
  import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
23
- import { ActiveTaskContinuation, type ActiveTaskMarker } from "./active-task-continuation.ts";
23
+ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
24
24
  import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
25
25
  import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
26
26
 
@@ -41,7 +41,7 @@ export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjec
41
41
  const row = projection.rows[index]!;
42
42
  const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
43
43
  const hierarchy = taskTreeConnector({ depth: row.depth, hasChildren: row.hasOpenChildren, hasLaterSibling: laterSibling });
44
- const focus = row.active ? theme.fg("accent", "▶") : " ";
44
+ const focus = row.active ? theme.fg("accent", row.focusStatus === "paused" ? "Ⅱ" : "▶") : " ";
45
45
  const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
46
46
  const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
47
47
  lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}`, width, "…"));
@@ -149,8 +149,13 @@ export default async function (pi: ExtensionAPI) {
149
149
  content: decision.prompt,
150
150
  display: false,
151
151
  }, { 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");
152
+ } else if (decision.action === "pause") {
153
+ await callService("tasks.pause", {
154
+ actor: "system",
155
+ source: "task-continuation",
156
+ reason: automaticPauseReason(decision.reason),
157
+ });
158
+ if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
154
159
  }
155
160
  } catch {
156
161
  // The daemon may be unavailable during startup, reload, or shutdown.
@@ -361,8 +366,17 @@ export default async function (pi: ExtensionAPI) {
361
366
  // agent_settled is intentionally later than agent_end: Pi guarantees that
362
367
  // retry, compaction retry, and queued follow-up processing have finished.
363
368
 
364
- pi.on("input", (event) => {
365
- if (event.source !== "extension") taskContinuation.onHumanInput();
369
+ pi.on("input", async (event) => {
370
+ if (event.source === "extension") return;
371
+ taskContinuation.onHumanInput();
372
+ try {
373
+ const focus = await callService<Record<string, never>, { status: string; pauseReason?: string } | null>("tasks.focused", {});
374
+ if (focus && shouldResumeFocusOnHumanInput(focus.status, focus.pauseReason)) {
375
+ await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation" });
376
+ }
377
+ } catch {
378
+ // The daemon may be unavailable during startup, reload, or shutdown.
379
+ }
366
380
  });
367
381
  pi.on("agent_start", () => { taskContinuation.onAgentStart(); });
368
382
  pi.on("agent_settled", async (_event, ctx) => { await driveActiveTasks(ctx); });
@@ -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.8.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
  }
package/src/cli.ts CHANGED
@@ -10,7 +10,6 @@ import { serveMain } from "./daemon.ts";
10
10
  import type { GateResult } from "./domain/gate.ts";
11
11
  import type { TaskExecutionPlan } from "./task-execution.ts";
12
12
  import type { TaskBlockage, TaskCompletion } from "./task-service.ts";
13
- import type { TaskAutomationResult, TaskAutomationSettings } from "./task-automation.ts";
14
13
 
15
14
  export interface SystemdUnitOptions {
16
15
  bunBin: string;
@@ -57,23 +56,26 @@ function installService(): void {
57
56
  const USAGE = `Usage:
58
57
  papyrus serve
59
58
  papyrus service <install|start|stop|restart|status>
60
- papyrus migrate task-scope [--json]
61
- papyrus automation <status|run> [--json]
59
+ papyrus migrate task-focus [--json]
62
60
  papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
63
61
  papyrus tasks plan [--json]
64
62
  papyrus tasks graph [--json]
65
63
  papyrus tasks active [--json]
64
+ papyrus tasks focused [--json]
65
+ papyrus tasks pause [--json]
66
+ papyrus tasks unpause [--json]
67
+ papyrus tasks clear-focus [--json]
66
68
  papyrus tasks history <id> [--json]
67
69
  papyrus tasks scope [project|all|graph <root-id>] [--json]
68
70
  papyrus tasks assign-project <id> [project-root] [--json]
69
71
  papyrus tasks focus <id> [--json]
72
+ papyrus tasks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
70
73
  papyrus tasks complete <id> [--json]
71
74
  papyrus tasks start <id> [--json]
72
75
  papyrus tasks submit <id> [--json]
73
76
  papyrus tasks reject <id> [--json]
74
77
  papyrus tasks retry <id> [--json]
75
78
  papyrus tasks cancel <id> [--json]
76
- papyrus tasks automate <id> <on|off> [--json]
77
79
  papyrus tasks depend <id> <prerequisite-id> [--json]`;
78
80
 
79
81
  function usage(): never {
@@ -112,8 +114,8 @@ function planText(plan: TaskExecutionPlan): string {
112
114
  export async function runMigrationCli(args: string[], client: TaskCliClient): Promise<string> {
113
115
  const json = args.includes("--json");
114
116
  const positional = args.filter((arg) => arg !== "--json");
115
- if (positional.length !== 1 || positional[0] !== "task-scope") {
116
- throw new Error("migrate requires exactly `task-scope`");
117
+ if (positional.length !== 1 || positional[0] !== "task-focus") {
118
+ throw new Error("migrate requires exactly `task-focus`");
117
119
  }
118
120
  const result = await client.call<Record<string, never>, MigrationResult>("system.migrate", {});
119
121
  if (json) return JSON.stringify(result);
@@ -121,20 +123,6 @@ export async function runMigrationCli(args: string[], client: TaskCliClient): Pr
121
123
  return `Migrated schema ${result.from} → ${result.to}: ${result.applied.join(", ")}`;
122
124
  }
123
125
 
124
- export async function runAutomationCli(args: string[], client: TaskCliClient): Promise<string> {
125
- const json = args.includes("--json");
126
- const positional = args.filter((argument) => argument !== "--json");
127
- if (positional.length !== 1 || (positional[0] !== "status" && positional[0] !== "run")) {
128
- throw new Error("automation requires exactly `status` or `run`");
129
- }
130
- if (positional[0] === "status") {
131
- const status = await client.call<Record<string, never>, TaskAutomationSettings & { inFlight: boolean }>("automation.status", {});
132
- return json ? JSON.stringify(status) : `Automation: ${status.enabled ? "enabled" : "disabled"} · interval ${status.intervalMs}ms · max ${status.maxTasksPerSweep} tasks · concurrency ${status.gateConcurrency}`;
133
- }
134
- const result = await client.call<Record<string, never>, TaskAutomationResult>("automation.reconcile", {});
135
- return json ? JSON.stringify(result) : `Automation sweep: ${result.examined} examined · ${result.completed} completed · ${result.rejected} rejected · ${result.started} started · ${result.errors.length} errors${result.skipped ? ` · skipped ${result.skipped}` : ""}`;
136
- }
137
-
138
126
  export async function runSkillCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
139
127
  const json = args.includes("--json");
140
128
  const positional: string[] = [];
@@ -182,7 +170,25 @@ export async function runSkillCli(args: string[], client: TaskCliClient, project
182
170
 
183
171
  export async function runTaskCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
184
172
  const json = args.includes("--json");
185
- const positional = args.filter((arg) => arg !== "--json");
173
+ const positional: string[] = [];
174
+ const updateInput: { title?: string; body?: string; labels?: string[] } = {};
175
+ for (let index = 0; index < args.length; index++) {
176
+ const argument = args[index]!;
177
+ if (argument === "--json") continue;
178
+ if (argument === "--title" || argument === "--body" || argument === "--labels-json") {
179
+ const value = args[++index];
180
+ if (value === undefined) throw new Error(`${argument} requires a value`);
181
+ if (argument === "--title") updateInput.title = value;
182
+ else if (argument === "--body") updateInput.body = value;
183
+ else {
184
+ const parsed = JSON.parse(value) as unknown;
185
+ if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) throw new Error("--labels-json requires a JSON string array");
186
+ updateInput.labels = parsed as string[];
187
+ }
188
+ continue;
189
+ }
190
+ positional.push(argument);
191
+ }
186
192
  const [action, id, dependencyId] = positional;
187
193
  let result: unknown;
188
194
  let human: string;
@@ -194,6 +200,37 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
194
200
  human = active ? `Active: ${artifactLabel(active)}` : "No active task.";
195
201
  break;
196
202
  }
203
+ case "focused": {
204
+ if (id) throw new Error("tasks focused accepts no positional arguments");
205
+ const focus = await client.call<Record<string, string>, { artifact: CliArtifact; status: "active" | "paused"; updatedAt: string } | null>("tasks.focused", { project_root: projectRoot });
206
+ result = focus;
207
+ human = focus ? `Focused (${focus.status}): ${artifactLabel(focus.artifact)}` : "No focused task.";
208
+ break;
209
+ }
210
+ case "pause":
211
+ case "unpause": {
212
+ if (id) throw new Error(`tasks ${action} accepts no positional arguments`);
213
+ const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
214
+ const focus = await client.call<Record<string, string>, { artifact: CliArtifact; status: string }>(operation, { actor: "user", source: "cli" });
215
+ result = focus;
216
+ human = `Focused (${focus.status}): ${artifactLabel(focus.artifact)}`;
217
+ break;
218
+ }
219
+ case "clear-focus": {
220
+ if (id) throw new Error("tasks clear-focus accepts no positional arguments");
221
+ const cleared = await client.call<Record<string, string>, { cleared: boolean }>("tasks.clear_focus", { actor: "user", source: "cli" });
222
+ result = cleared;
223
+ human = cleared.cleared ? "Task focus cleared." : "No focused task.";
224
+ break;
225
+ }
226
+ case "update": {
227
+ if (!id || dependencyId) throw new Error("tasks update requires exactly one task id");
228
+ if (Object.keys(updateInput).length === 0) throw new Error("tasks update requires --title, --body, or --labels-json");
229
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.update", { id, ...updateInput, actor: "user", source: "cli" });
230
+ result = artifact;
231
+ human = `Updated: ${artifactLabel(artifact)}`;
232
+ break;
233
+ }
197
234
  case "history": {
198
235
  if (!id || dependencyId) throw new Error("tasks history requires exactly one task id");
199
236
  const page = await client.call<{ id: string; direction: "desc" }, import("./domain/task-event.ts").TaskHistoryPage>("tasks.history", { id, direction: "desc" });
@@ -236,7 +273,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
236
273
  }
237
274
  case "focus": {
238
275
  if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
239
- const active = await client.call<{ id: string }, CliArtifact>("tasks.focus", { id });
276
+ const active = await client.call<Record<string, string>, CliArtifact>("tasks.focus", { id, actor: "user", source: "cli" });
240
277
  result = active;
241
278
  human = `Active: ${artifactLabel(active)}`;
242
279
  break;
@@ -291,18 +328,6 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
291
328
  human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
292
329
  break;
293
330
  }
294
- case "automate": {
295
- if (!id || (dependencyId !== "on" && dependencyId !== "off") || positional.length !== 3) throw new Error("tasks automate requires a task id and on or off");
296
- const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.set_automation", {
297
- id,
298
- enabled: dependencyId === "on",
299
- actor: "user",
300
- source: "cli",
301
- });
302
- result = artifact;
303
- human = `Automation ${dependencyId}: ${artifactLabel(artifact)}`;
304
- break;
305
- }
306
331
  case "depend": {
307
332
  if (!id || !dependencyId || positional.length !== 3) throw new Error("tasks depend requires a task id and prerequisite id");
308
333
  const artifact = await client.call<{ id: string; dependency_id: string }, CliArtifact>("tasks.depend", {
@@ -314,7 +339,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
314
339
  break;
315
340
  }
316
341
  default:
317
- throw new Error("tasks action must be active, focus, graph, plan, history, scope, assign-project, complete, start, submit, reject, retry, cancel, automate, or depend");
342
+ throw new Error("tasks action must be active, focused, focus, pause, unpause, clear-focus, update, graph, plan, history, scope, assign-project, complete, start, submit, reject, retry, cancel, or depend");
318
343
  }
319
344
  return json ? JSON.stringify(result) : human;
320
345
  }
@@ -327,11 +352,6 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
327
352
  console.log(await runTaskCli(args.slice(1), client));
328
353
  return;
329
354
  }
330
- if (command === "automation") {
331
- const client = await connectPapyrusClient();
332
- console.log(await runAutomationCli(args.slice(1), client));
333
- return;
334
- }
335
355
  if (command === "skills") {
336
356
  const client = await connectPapyrusClient();
337
357
  console.log(await runSkillCli(args.slice(1), client));
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 = 4;
10
+ export const SQLITE_SCHEMA_VERSION = 5;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
13
13
  export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
@@ -43,25 +43,17 @@ export const SKILL_RUN_ID_MAX_LENGTH = 64;
43
43
  /** Bounded automatic Pi continuations while a focused Papyrus Task remains. */
44
44
  export const TASK_DRIVER_MAX_TURNS = 20;
45
45
  export const TASK_DRIVER_MAX_UNCHANGED_TURNS = 6;
46
+ /** Mutable Task content bounds. */
47
+ export const TASK_TITLE_MAX_LENGTH = 500;
48
+ export const TASK_BODY_MAX_LENGTH = 100_000;
49
+ export const TASK_LABEL_MAX_COUNT = 64;
50
+ export const TASK_LABEL_MAX_LENGTH = 128;
46
51
  /** Append-only Task chronology query and evidence bounds. */
47
52
  export const TASK_HISTORY_DEFAULT_LIMIT = 25;
48
53
  export const TASK_HISTORY_MAX_LIMIT = 100;
49
54
  export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
50
55
  export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
51
56
  export const TASK_EVENT_REASON_MAX_LENGTH = 2_000;
52
- /** Explicitly opt-in supervised Task graph reconciliation bounds. */
53
- export const TASK_AUTOMATION_INTERVAL_MS = 60_000;
54
- export const TASK_AUTOMATION_MIN_INTERVAL_MS = 10_000;
55
- export const TASK_AUTOMATION_MAX_INTERVAL_MS = 3_600_000;
56
- export const TASK_AUTOMATION_MAX_TASKS_PER_SWEEP = 10;
57
- export const TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP = 100;
58
- export const TASK_AUTOMATION_GATE_CONCURRENCY = 1;
59
- export const TASK_AUTOMATION_MAX_GATE_CONCURRENCY = 4;
60
- export const TASK_AUTOMATION_MAX_RUNTIME_MS = 120_000;
61
- export const TASK_AUTOMATION_HARD_MAX_RUNTIME_MS = 600_000;
62
- export const TASK_AUTOMATION_MAX_CANDIDATE_SCAN = 1_000;
63
- export const TASK_AUTOMATION_ERROR_ID_MAX_LENGTH = 128;
64
- export const TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH = 500;
65
57
  /** Persisted project and focused-graph Task view bounds. */
66
58
  export const TASK_SCOPE_MAX_TASKS = 1_000;
67
59
  export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;