@danypops/papyrus 0.5.0 → 0.7.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 +35 -5
- package/extension/src/domain-tools.ts +23 -10
- package/extension/src/index.ts +19 -8
- package/extension/src/skills.ts +1 -0
- package/extension/src/task-widget.ts +2 -1
- package/extension/src/tasks.ts +56 -9
- package/package.json +1 -1
- package/src/adapters/sqlite-gate-runner.ts +3 -3
- package/src/adapters/sqlite-task-scope-store.ts +59 -0
- package/src/cli.ts +78 -11
- package/src/constants.ts +18 -1
- package/src/daemon.ts +20 -4
- package/src/db.ts +38 -1
- package/src/domain/gate.ts +5 -0
- package/src/domain/task-event.ts +3 -0
- package/src/domain/task-scope.ts +39 -0
- package/src/log.ts +6 -0
- package/src/ops.ts +18 -7
- package/src/ports/gate-runner.ts +2 -2
- package/src/ports/task-scope-store.ts +40 -0
- package/src/service.ts +62 -10
- package/src/skill-execution.ts +16 -10
- package/src/task-automation.ts +188 -0
- package/src/task-context.ts +4 -2
- package/src/task-service.ts +128 -11
package/README.md
CHANGED
|
@@ -37,6 +37,8 @@ papyrus tasks plan
|
|
|
37
37
|
papyrus tasks depend <task-id> <prerequisite-id>
|
|
38
38
|
papyrus tasks start <task-id>
|
|
39
39
|
papyrus tasks complete <task-id>
|
|
40
|
+
papyrus tasks automate <task-id> <on|off>
|
|
41
|
+
papyrus automation status
|
|
40
42
|
```
|
|
41
43
|
|
|
42
44
|
For repository work, install the versioned ownership guard once:
|
|
@@ -101,7 +103,7 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
|
|
|
101
103
|
|
|
102
104
|
## Interactive frontends
|
|
103
105
|
|
|
104
|
-
- `/tasks` — task lifecycle, append-only history, gates, dependencies, and nested metadata
|
|
106
|
+
- `/tasks` — project/focused-graph scope, task lifecycle, append-only history, gates, dependencies, and nested metadata
|
|
105
107
|
- `/docs` — searchable documents, lifecycle, details, and graph links
|
|
106
108
|
- `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
|
|
107
109
|
- `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
|
|
@@ -112,7 +114,7 @@ All four use daemon-backed domain operations; none opens SQLite from the Pi proc
|
|
|
112
114
|
|
|
113
115
|
Run `/tasks` for the interactive task panel:
|
|
114
116
|
|
|
115
|
-
- `/` filters; arrow keys navigate; Enter opens task actions
|
|
117
|
+
- `/` filters; arrow keys navigate; Enter opens task actions; `s` switches among the persisted current-project, focused-root graph, and explicit all-projects views
|
|
116
118
|
- `g` opens the programmatic Unicode graph; Tab switches dependency/composition views and arrow keys pan
|
|
117
119
|
- 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
|
|
118
120
|
- advance the `todo → in-progress → review → done` lifecycle; failed review becomes `rejected`, retry returns to `in-progress`, and `canceled` is terminal
|
|
@@ -123,12 +125,17 @@ Run `/tasks` for the interactive task panel:
|
|
|
123
125
|
- inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
|
|
124
126
|
- lifecycle colors are semantic and redundant with text/glyphs: To-Do grey, in-progress yellow, review blue, rejected orange, done green, and canceled red; `▶` marks active focus
|
|
125
127
|
- Show details keeps Checklist and Validation gates separate from incidental Metadata, renders bounded post-migration lifecycle history with actor/source/reason and gate evidence, then renders relationships as a Unicode box-drawing graph footer; `↑/↓` scrolls and `←/→` pans wide graphs
|
|
126
|
-
- the compact persistent widget shows bounded open work in containment order and always retains
|
|
128
|
+
- the compact persistent widget shows the current scope label plus bounded open work in containment order and always retains active focus when it belongs to that scope
|
|
127
129
|
|
|
128
130
|
Authenticated CLI parity covers the changed lifecycle and focus operations:
|
|
129
131
|
|
|
130
132
|
```bash
|
|
131
133
|
papyrus tasks graph --json
|
|
134
|
+
papyrus tasks scope --json
|
|
135
|
+
papyrus tasks scope project --json
|
|
136
|
+
papyrus tasks scope graph <root-id> --json
|
|
137
|
+
papyrus tasks scope all --json
|
|
138
|
+
papyrus tasks assign-project <task-id> [project-root] --json
|
|
132
139
|
papyrus tasks active --json
|
|
133
140
|
papyrus tasks history <id> --json
|
|
134
141
|
papyrus tasks focus <id> --json
|
|
@@ -138,8 +145,31 @@ papyrus tasks complete <id> --json
|
|
|
138
145
|
papyrus tasks reject <id> --json
|
|
139
146
|
papyrus tasks retry <id> --json
|
|
140
147
|
papyrus tasks cancel <id> --json
|
|
148
|
+
papyrus tasks automate <id> <on|off> --json
|
|
149
|
+
papyrus automation status --json
|
|
150
|
+
papyrus automation run --json
|
|
141
151
|
```
|
|
142
152
|
|
|
153
|
+
### Opt-in supervised automation
|
|
154
|
+
|
|
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.
|
|
156
|
+
|
|
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.
|
|
172
|
+
|
|
143
173
|
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:
|
|
144
174
|
|
|
145
175
|
```ts
|
|
@@ -172,10 +202,10 @@ packed install npm:@danypops/papyrus
|
|
|
172
202
|
~/.pi/agent/npm/node_modules/.bin/papyrus service install
|
|
173
203
|
```
|
|
174
204
|
|
|
175
|
-
Existing databases are never migrated on daemon boot. After upgrading to
|
|
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]`:
|
|
176
206
|
|
|
177
207
|
```bash
|
|
178
|
-
~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-
|
|
208
|
+
~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-scope
|
|
179
209
|
```
|
|
180
210
|
|
|
181
211
|
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.
|
|
@@ -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, graph, plan, active, 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.",
|
|
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.",
|
|
35
35
|
parameters: Type.Object({
|
|
36
36
|
action: Type.String(),
|
|
37
37
|
id: Type.Optional(Type.String()),
|
|
@@ -44,6 +44,7 @@ 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()),
|
|
47
48
|
labels: Type.Optional(Type.Array(Type.String())),
|
|
48
49
|
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
49
50
|
gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
|
|
@@ -53,17 +54,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
53
54
|
child_id: Type.Optional(Type.String()),
|
|
54
55
|
dependency_id: Type.Optional(Type.String()),
|
|
55
56
|
depends_on: Type.Optional(Type.Array(Type.String())),
|
|
57
|
+
project_root: Type.Optional(Type.String()),
|
|
58
|
+
scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
|
|
59
|
+
root_task_id: Type.Optional(Type.String()),
|
|
56
60
|
}),
|
|
57
|
-
async execute(_id, params) {
|
|
61
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
58
62
|
try {
|
|
59
63
|
const action = params.action;
|
|
60
|
-
const request = { ...params, actor: "agent", source: "pi-tool" };
|
|
64
|
+
const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool" };
|
|
61
65
|
if (action === "create") {
|
|
62
66
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
|
|
63
67
|
return text(`Created task ${artifactLine(artifact)}`, { artifact });
|
|
64
68
|
}
|
|
65
69
|
if (action === "list") {
|
|
66
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list",
|
|
70
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
|
|
67
71
|
return text(rows.length ? rows.map(artifactLine).join("\n") : "No tasks found.", { rows });
|
|
68
72
|
}
|
|
69
73
|
if (action === "show") {
|
|
@@ -75,18 +79,22 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
75
79
|
const lines = page.events.map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`);
|
|
76
80
|
return text(lines.join("\n") || "No recorded history for this task.", { page });
|
|
77
81
|
}
|
|
82
|
+
if (action === "scope") {
|
|
83
|
+
const selection = await callService<Record<string, unknown>, import("../../src/domain/task-scope.ts").TaskViewSelection>("tasks.scope", request);
|
|
84
|
+
return text(`Task scope: ${selection.label}`, { selection });
|
|
85
|
+
}
|
|
78
86
|
if (action === "active") {
|
|
79
|
-
const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active",
|
|
87
|
+
const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
|
|
80
88
|
return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
|
|
81
89
|
}
|
|
82
90
|
if (action === "graph") {
|
|
83
|
-
const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph",
|
|
91
|
+
const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", request);
|
|
84
92
|
const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
|
|
85
93
|
const containment = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
|
|
86
94
|
return text(`Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${containment} containment edges.`, { graph });
|
|
87
95
|
}
|
|
88
96
|
if (action === "plan") {
|
|
89
|
-
const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan",
|
|
97
|
+
const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", request);
|
|
90
98
|
const byId = new Map(plan.nodes.map((node) => [node.id, node]));
|
|
91
99
|
const lines = plan.layers.flatMap((layer, index) => [
|
|
92
100
|
`Layer ${index + 1}`,
|
|
@@ -123,6 +131,9 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
123
131
|
reject: "tasks.reject",
|
|
124
132
|
retry: "tasks.retry",
|
|
125
133
|
cancel: "tasks.cancel",
|
|
134
|
+
set_scope: "tasks.set_scope",
|
|
135
|
+
assign_project: "tasks.assign_project",
|
|
136
|
+
set_automation: "tasks.set_automation",
|
|
126
137
|
depend: "tasks.depend",
|
|
127
138
|
contain: "tasks.contain",
|
|
128
139
|
} as const;
|
|
@@ -232,10 +243,12 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
232
243
|
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
|
|
233
244
|
target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
234
245
|
required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
|
|
246
|
+
project_root: Type.Optional(Type.String()),
|
|
235
247
|
}),
|
|
236
|
-
async execute(_id, params) {
|
|
248
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
237
249
|
try {
|
|
238
250
|
const action = params.action;
|
|
251
|
+
const request = { ...params, project_root: params.project_root ?? ctx.cwd };
|
|
239
252
|
if (action === "create" || action === "create_template") {
|
|
240
253
|
const operation = action === "create" ? "skills.create" : "skills.create_template";
|
|
241
254
|
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
@@ -250,7 +263,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
250
263
|
return text(invocation, { invocation });
|
|
251
264
|
}
|
|
252
265
|
if (action === "run") {
|
|
253
|
-
const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run",
|
|
266
|
+
const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", request);
|
|
254
267
|
const execution = run.execution.nodes.map((node) => ` [${node.state}] ${node.id} ${node.title}`).join("\n");
|
|
255
268
|
return text([
|
|
256
269
|
`Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`,
|
|
@@ -263,7 +276,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
263
276
|
const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate" } as const;
|
|
264
277
|
const operation = operations[action as keyof typeof operations];
|
|
265
278
|
if (!operation) return text(`Unknown skills action: ${action}`);
|
|
266
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
279
|
+
const artifact = await callService<Record<string, unknown>, Artifact>(operation, action === "instantiate" ? request : params);
|
|
267
280
|
return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, { artifact });
|
|
268
281
|
} catch (error) {
|
|
269
282
|
return text(`skills failed: ${error instanceof Error ? error.message : error}`);
|
package/extension/src/index.ts
CHANGED
|
@@ -36,7 +36,7 @@ const WIDGET_KEY = "pi-papyrus";
|
|
|
36
36
|
|
|
37
37
|
export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjection, width: number): string[] {
|
|
38
38
|
if (projection.openTotal === 0) return [];
|
|
39
|
-
const lines: string[] = [];
|
|
39
|
+
const lines: string[] = [theme.fg("muted", `Tasks · ${projection.scopeLabel}`)];
|
|
40
40
|
for (let index = 0; index < projection.rows.length; index++) {
|
|
41
41
|
const row = projection.rows[index]!;
|
|
42
42
|
const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
|
|
@@ -54,6 +54,7 @@ class TaskOverlay {
|
|
|
54
54
|
private registered = false;
|
|
55
55
|
private tui: any | undefined;
|
|
56
56
|
private snapshot: TaskGraph = { nodes: [], rootIds: [] };
|
|
57
|
+
private projectRoot: string | undefined;
|
|
57
58
|
|
|
58
59
|
setUI(ctx: ExtensionUIContext): void {
|
|
59
60
|
if (ctx !== this.uiCtx) {
|
|
@@ -63,9 +64,12 @@ class TaskOverlay {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
setProjectRoot(projectRoot: string): void { this.projectRoot = projectRoot; }
|
|
68
|
+
|
|
66
69
|
async refresh(): Promise<void> {
|
|
70
|
+
if (!this.projectRoot) return;
|
|
67
71
|
try {
|
|
68
|
-
this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500 });
|
|
72
|
+
this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500, project_root: this.projectRoot });
|
|
69
73
|
} catch {
|
|
70
74
|
this.snapshot = { nodes: [], rootIds: [] };
|
|
71
75
|
}
|
|
@@ -116,6 +120,7 @@ class TaskOverlay {
|
|
|
116
120
|
this.registered = false;
|
|
117
121
|
this.tui = undefined;
|
|
118
122
|
this.uiCtx = undefined;
|
|
123
|
+
this.projectRoot = undefined;
|
|
119
124
|
}
|
|
120
125
|
}
|
|
121
126
|
|
|
@@ -133,7 +138,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
133
138
|
const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
|
|
134
139
|
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
|
|
135
140
|
try {
|
|
136
|
-
const active = await callService<Record<string,
|
|
141
|
+
const active = await callService<Record<string, unknown>, ActiveTaskMarker | null>("tasks.active", { project_root: ctx.cwd });
|
|
137
142
|
const decision = taskContinuation.evaluate(active, {
|
|
138
143
|
idle: ctx.isIdle(),
|
|
139
144
|
pendingMessages: ctx.hasPendingMessages(),
|
|
@@ -174,10 +179,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
174
179
|
labels: Type.Optional(Type.Array(Type.String())),
|
|
175
180
|
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
176
181
|
template_id: Type.Optional(Type.String({ description: "skill/artifact-template id whose defaults and requirements apply" })),
|
|
182
|
+
project_root: Type.Optional(Type.String({ description: "required for Tasks; defaults to Pi cwd" })),
|
|
177
183
|
}),
|
|
178
|
-
async execute(_id, params, _signal, _onUpdate,
|
|
184
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
179
185
|
try {
|
|
180
|
-
const a = await callService<Record<string, unknown>, Artifact>("artifact.create",
|
|
186
|
+
const a = await callService<Record<string, unknown>, Artifact>("artifact.create", {
|
|
187
|
+
...params,
|
|
188
|
+
...(params.kind === "task" ? { project_root: params.project_root ?? ctx.cwd } : {}),
|
|
189
|
+
});
|
|
181
190
|
return text(`Created ${a.id} [${a.kind}|${a.status}] ${a.title}`, { id: a.id });
|
|
182
191
|
} catch (e) {
|
|
183
192
|
return text(`papyrus_create failed: ${e instanceof Error ? e.message : e}`);
|
|
@@ -309,6 +318,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
309
318
|
pi.registerCommand("tasks", {
|
|
310
319
|
description: "Browse and manage Papyrus tasks (interactive)",
|
|
311
320
|
handler: async (_args, ctx) => {
|
|
321
|
+
overlay?.setProjectRoot(ctx.cwd);
|
|
312
322
|
await tasksModule.showTasks(ctx);
|
|
313
323
|
await overlay?.refresh();
|
|
314
324
|
},
|
|
@@ -332,6 +342,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
332
342
|
if (!ctx.hasUI) return;
|
|
333
343
|
overlay ??= new TaskOverlay();
|
|
334
344
|
overlay.setUI(ctx.ui);
|
|
345
|
+
overlay.setProjectRoot(ctx.cwd);
|
|
335
346
|
await overlay.refresh();
|
|
336
347
|
});
|
|
337
348
|
|
|
@@ -360,11 +371,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
360
371
|
// The agent sees its open work items every turn. If there are rejected
|
|
361
372
|
// tasks, they're explicitly called out — the agent should address them.
|
|
362
373
|
|
|
363
|
-
pi.on("before_agent_start", async (event,
|
|
374
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
364
375
|
try {
|
|
365
376
|
const [rules, summary] = await Promise.all([
|
|
366
|
-
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", {}),
|
|
367
|
-
callService<Record<string, unknown>, string | null>("tasks.context", {}),
|
|
377
|
+
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd }),
|
|
378
|
+
callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd }),
|
|
368
379
|
]);
|
|
369
380
|
let prompt = event.systemPrompt ?? "";
|
|
370
381
|
if (rules.length > 0) {
|
package/extension/src/skills.ts
CHANGED
|
@@ -93,6 +93,7 @@ export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
93
93
|
const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", {
|
|
94
94
|
id: skill.id,
|
|
95
95
|
arguments: arguments_ as Record<string, unknown>,
|
|
96
|
+
project_root: commandCtx.cwd,
|
|
96
97
|
});
|
|
97
98
|
commandCtx.ui.notify([
|
|
98
99
|
`Created ${run.runId} · ${run.created.tasks.length} tasks · ${run.rootTaskIds.length} ready roots`,
|
|
@@ -13,6 +13,7 @@ export interface TaskWidgetProjection {
|
|
|
13
13
|
rows: TaskWidgetRow[];
|
|
14
14
|
openTotal: number;
|
|
15
15
|
total: number;
|
|
16
|
+
scopeLabel: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
function isOpen(task: Artifact): boolean {
|
|
@@ -52,5 +53,5 @@ export function buildTaskWidgetProjection(
|
|
|
52
53
|
rows = [...rows.slice(0, Math.max(0, limit - 1)), active]
|
|
53
54
|
.sort((left, right) => ordered.indexOf(left) - ordered.indexOf(right));
|
|
54
55
|
}
|
|
55
|
-
return { rows, openTotal: ordered.length, total: graph.nodes.length };
|
|
56
|
+
return { rows, openTotal: ordered.length, total: graph.nodes.length, scopeLabel: graph.scope?.label ?? "All projects" };
|
|
56
57
|
}
|
package/extension/src/tasks.ts
CHANGED
|
@@ -30,6 +30,12 @@ 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
|
+
|
|
33
39
|
export interface TaskHierarchyRow {
|
|
34
40
|
task: TaskRow;
|
|
35
41
|
depth: number;
|
|
@@ -56,8 +62,13 @@ export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
|
|
|
56
62
|
return result;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
async function loadTaskGraph(): Promise<TaskGraph> {
|
|
60
|
-
return callService<Record<string, unknown>, TaskGraph>("tasks.graph", {
|
|
65
|
+
async function loadTaskGraph(projectRoot: string, scope?: "project" | "graph" | "all", rootTaskId?: string): Promise<TaskGraph> {
|
|
66
|
+
return callService<Record<string, unknown>, TaskGraph>("tasks.graph", {
|
|
67
|
+
limit: 200,
|
|
68
|
+
project_root: projectRoot,
|
|
69
|
+
...(scope ? { scope } : {}),
|
|
70
|
+
...(rootTaskId ? { root_task_id: rootTaskId } : {}),
|
|
71
|
+
});
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
@@ -65,14 +76,14 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
65
76
|
ctx.ui.notify("/tasks requires interactive mode", "warning");
|
|
66
77
|
return;
|
|
67
78
|
}
|
|
68
|
-
let graph = await loadTaskGraph();
|
|
79
|
+
let graph = await loadTaskGraph(ctx.cwd);
|
|
69
80
|
if (graph.nodes.length === 0) {
|
|
70
81
|
const create = await ctx.ui.select("No tasks yet", ["Create a task", "Cancel"]);
|
|
71
82
|
if (create === "Create a task") {
|
|
72
83
|
const title = await ctx.ui.input("Task title:", "");
|
|
73
84
|
if (title) {
|
|
74
|
-
await callService("tasks.create", { title, actor: "user", source: "tasks-tui" });
|
|
75
|
-
graph = await loadTaskGraph();
|
|
85
|
+
await callService("tasks.create", { title, project_root: ctx.cwd, actor: "user", source: "tasks-tui" });
|
|
86
|
+
graph = await loadTaskGraph(ctx.cwd);
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
89
|
if (graph.nodes.length === 0) return;
|
|
@@ -81,14 +92,33 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
81
92
|
for (;;) {
|
|
82
93
|
const action = await renderPanel(ctx, graph);
|
|
83
94
|
if (!action) return;
|
|
84
|
-
if (action.type === "refresh") { graph = await loadTaskGraph(); continue; }
|
|
95
|
+
if (action.type === "refresh") { graph = await loadTaskGraph(ctx.cwd); continue; }
|
|
96
|
+
if (action.type === "scope") {
|
|
97
|
+
const choice = await ctx.ui.select("Task scope", ["Current project", "Focused graph", "All projects"]);
|
|
98
|
+
if (!choice) continue;
|
|
99
|
+
const scope: "project" | "graph" | "all" = choice === "Current project" ? "project" : choice === "All projects" ? "all" : "graph";
|
|
100
|
+
let rootTaskId: string | undefined;
|
|
101
|
+
if (scope === "graph") {
|
|
102
|
+
const projectGraph = await loadTaskGraph(ctx.cwd, "project");
|
|
103
|
+
const roots = projectGraph.rootIds.map((id) => projectGraph.nodes.find((node) => node.task.id === id)?.task).filter((task): task is Artifact => task !== undefined);
|
|
104
|
+
const selected = await ctx.ui.select("Focused root or epic", roots.map((task) => `${task.title} · ${task.id}`));
|
|
105
|
+
if (!selected) continue;
|
|
106
|
+
rootTaskId = roots.find((task) => `${task.title} · ${task.id}` === selected)?.id;
|
|
107
|
+
if (!rootTaskId) continue;
|
|
108
|
+
}
|
|
109
|
+
await callService("tasks.set_scope", { project_root: ctx.cwd, scope, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) });
|
|
110
|
+
graph = await loadTaskGraph(ctx.cwd);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
85
113
|
if (action.type === "graph") { await showTaskGraph(ctx, graph); continue; }
|
|
86
114
|
if (action.type !== "action" || !action.row) continue;
|
|
87
115
|
|
|
88
116
|
const active = graph.nodes.find((node) => node.task.id === action.row!.id)?.active === true;
|
|
117
|
+
const automationEnabled = taskAutomationEnabled(action.row);
|
|
89
118
|
const choices = [
|
|
90
119
|
"Show details",
|
|
91
120
|
...(!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"] : []),
|
|
92
122
|
...(action.row.status === "review" ? ["Run gates"] : []),
|
|
93
123
|
...(STATUS_ACTIONS[action.row.status] ?? []),
|
|
94
124
|
];
|
|
@@ -107,6 +137,20 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
107
137
|
} catch (error) {
|
|
108
138
|
ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
109
139
|
}
|
|
140
|
+
} else if (choice === "Enable automation" || choice === "Disable automation") {
|
|
141
|
+
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");
|
|
151
|
+
} catch (error) {
|
|
152
|
+
ctx.ui.notify(`Automation setting failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
153
|
+
}
|
|
110
154
|
} else if (choice === "Run gates") {
|
|
111
155
|
try {
|
|
112
156
|
const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id, actor: "user", source: "tasks-tui" });
|
|
@@ -151,12 +195,12 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
151
195
|
ctx.ui.notify(`Task action failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
152
196
|
}
|
|
153
197
|
}
|
|
154
|
-
graph = await loadTaskGraph();
|
|
198
|
+
graph = await loadTaskGraph(ctx.cwd);
|
|
155
199
|
}
|
|
156
200
|
}
|
|
157
201
|
|
|
158
202
|
interface PanelAction {
|
|
159
|
-
type: "action" | "refresh" | "graph";
|
|
203
|
+
type: "action" | "refresh" | "graph" | "scope";
|
|
160
204
|
row?: TaskRow;
|
|
161
205
|
}
|
|
162
206
|
|
|
@@ -196,7 +240,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
196
240
|
const header = {
|
|
197
241
|
invalidate() {},
|
|
198
242
|
render(width: number): string[] {
|
|
199
|
-
const title = theme.bold(
|
|
243
|
+
const title = theme.bold(`Tasks · ${graph.scope?.label ?? "scope unavailable"}`);
|
|
200
244
|
const hint = searchActive
|
|
201
245
|
? rawKeyHint("esc", "clear")
|
|
202
246
|
: rawKeyHint("↑/↓", "navigate") +
|
|
@@ -207,6 +251,8 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
207
251
|
theme.fg("muted", " · ") +
|
|
208
252
|
rawKeyHint("g", "graph") +
|
|
209
253
|
theme.fg("muted", " · ") +
|
|
254
|
+
rawKeyHint("s", "scope") +
|
|
255
|
+
theme.fg("muted", " · ") +
|
|
210
256
|
rawKeyHint("r", "refresh") +
|
|
211
257
|
theme.fg("muted", " · ") +
|
|
212
258
|
rawKeyHint("esc", "close");
|
|
@@ -300,6 +346,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
300
346
|
else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
|
|
301
347
|
else if (data === "/") searchActive = true;
|
|
302
348
|
else if (data === "g") { done({ type: "graph" }); return; }
|
|
349
|
+
else if (data === "s") { done({ type: "scope" }); return; }
|
|
303
350
|
else if (data === "r") { done({ type: "refresh" }); return; }
|
|
304
351
|
else if (matchesKey(data, "enter")) {
|
|
305
352
|
const entry = filtered[selectedIndex];
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Db } from "../db.ts";
|
|
2
|
-
import type { GateResult } from "../domain/gate.ts";
|
|
2
|
+
import type { GateResult, GateRunOptions } from "../domain/gate.ts";
|
|
3
3
|
import type { GateRunner } from "../ports/gate-runner.ts";
|
|
4
4
|
import { runGates, runGatesAsync } from "../ops.ts";
|
|
5
5
|
|
|
@@ -10,7 +10,7 @@ export class SQLiteGateRunner implements GateRunner {
|
|
|
10
10
|
return runGates(this.db, artifactId);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
runAsync(artifactId: string): Promise<GateResult[]> {
|
|
14
|
-
return runGatesAsync(this.db, artifactId);
|
|
13
|
+
runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]> {
|
|
14
|
+
return runGatesAsync(this.db, artifactId, options);
|
|
15
15
|
}
|
|
16
16
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Db } from "../db.ts";
|
|
2
|
+
import { inTransaction } from "../db.ts";
|
|
3
|
+
import type { TaskProjectScope, TaskScopeSource, TaskViewMode, TaskViewPreference } from "../domain/task-scope.ts";
|
|
4
|
+
import type { TaskScopeStore } from "../ports/task-scope-store.ts";
|
|
5
|
+
|
|
6
|
+
export class SQLiteTaskScopeStore implements TaskScopeStore {
|
|
7
|
+
constructor(private readonly db: Db) {}
|
|
8
|
+
|
|
9
|
+
assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope {
|
|
10
|
+
inTransaction(this.db, () => {
|
|
11
|
+
this.db.prepare(`
|
|
12
|
+
INSERT INTO task_scopes (task_id, project_root, source, assigned_at)
|
|
13
|
+
VALUES (?, ?, ?, ?)
|
|
14
|
+
ON CONFLICT(task_id) DO UPDATE SET
|
|
15
|
+
project_root = excluded.project_root,
|
|
16
|
+
source = excluded.source,
|
|
17
|
+
assigned_at = excluded.assigned_at
|
|
18
|
+
`).run(taskId, projectRoot ?? null, source, new Date().toISOString());
|
|
19
|
+
});
|
|
20
|
+
return { taskId, ...(projectRoot === undefined ? {} : { projectRoot }), source };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get(taskId: string): TaskProjectScope | undefined {
|
|
24
|
+
const row = this.db.prepare("SELECT task_id, project_root, source FROM task_scopes WHERE task_id = ?").get(taskId) as
|
|
25
|
+
| { task_id: string; project_root: string | null; source: TaskScopeSource }
|
|
26
|
+
| null;
|
|
27
|
+
return row ? { taskId: row.task_id, ...(row.project_root === null ? {} : { projectRoot: row.project_root }), source: row.source } : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
taskIds(projectRoot: string | undefined, limit: number): string[] {
|
|
31
|
+
const rows = projectRoot === undefined
|
|
32
|
+
? this.db.prepare("SELECT task_id FROM task_scopes WHERE project_root IS NULL ORDER BY task_id LIMIT ?").all(limit)
|
|
33
|
+
: this.db.prepare("SELECT task_id FROM task_scopes WHERE project_root = ? ORDER BY task_id LIMIT ?").all(projectRoot, limit);
|
|
34
|
+
return (rows as Array<{ task_id: string }>).map((row) => row.task_id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
view(projectRoot: string): TaskViewPreference {
|
|
38
|
+
const row = this.db.prepare("SELECT project_root, mode, root_task_id FROM task_views WHERE project_root = ?").get(projectRoot) as
|
|
39
|
+
| { project_root: string; mode: TaskViewMode; root_task_id: string | null }
|
|
40
|
+
| null;
|
|
41
|
+
return row
|
|
42
|
+
? { projectRoot: row.project_root, mode: row.mode, ...(row.root_task_id === null ? {} : { rootTaskId: row.root_task_id }) }
|
|
43
|
+
: { projectRoot, mode: "project" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewPreference {
|
|
47
|
+
inTransaction(this.db, () => {
|
|
48
|
+
this.db.prepare(`
|
|
49
|
+
INSERT INTO task_views (project_root, mode, root_task_id, updated_at)
|
|
50
|
+
VALUES (?, ?, ?, ?)
|
|
51
|
+
ON CONFLICT(project_root) DO UPDATE SET
|
|
52
|
+
mode = excluded.mode,
|
|
53
|
+
root_task_id = excluded.root_task_id,
|
|
54
|
+
updated_at = excluded.updated_at
|
|
55
|
+
`).run(projectRoot, mode, rootTaskId ?? null, new Date().toISOString());
|
|
56
|
+
});
|
|
57
|
+
return { projectRoot, mode, ...(rootTaskId === undefined ? {} : { rootTaskId }) };
|
|
58
|
+
}
|
|
59
|
+
}
|