@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 +21 -30
- package/extension/src/active-task-continuation.ts +9 -0
- package/extension/src/context-injection-telemetry.ts +80 -0
- package/extension/src/domain-tools.ts +15 -3
- package/extension/src/index.ts +38 -17
- package/extension/src/task-widget.ts +2 -1
- package/extension/src/tasks.ts +28 -20
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +6 -1
- package/src/adapters/sqlite-task-focus-store.ts +27 -14
- package/src/cli.ts +60 -40
- package/src/constants.ts +10 -14
- package/src/daemon.ts +2 -18
- package/src/db.ts +12 -2
- package/src/domain/artifact.ts +6 -0
- package/src/domain/task-event.ts +5 -2
- package/src/ops.ts +17 -1
- package/src/ports/artifact-store.ts +2 -0
- package/src/ports/task-focus-store.ts +30 -8
- package/src/service.ts +33 -21
- package/src/task-service.ts +98 -29
- package/src/task-automation.ts +0 -188
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
|
|
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
|
|
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 **
|
|
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
|
-
###
|
|
156
|
+
### Focus-driven automatic continuation
|
|
154
157
|
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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-
|
|
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,
|
|
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;
|
package/extension/src/index.ts
CHANGED
|
@@ -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"
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
};
|
package/extension/src/tasks.ts
CHANGED
|
@@ -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
|
|
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
|
-
...(
|
|
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 === "
|
|
154
|
+
} else if (choice === "Pause focus" || choice === "Resume focus" || choice === "Clear focus") {
|
|
141
155
|
try {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
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(`
|
|
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
|
@@ -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():
|
|
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
|
|
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):
|
|
16
|
-
|
|
17
|
-
|
|
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
|
}
|