@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 +18 -29
- package/extension/src/active-task-continuation.ts +9 -0
- package/extension/src/domain-tools.ts +15 -3
- package/extension/src/index.ts +20 -6
- 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 +6 -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
|
|
|
@@ -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
|
-
|
|
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
|
|
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-
|
|
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,
|
|
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
|
@@ -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"
|
|
153
|
-
|
|
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
|
|
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
|
};
|
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
|
}
|
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-
|
|
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-
|
|
116
|
-
throw new Error("migrate requires exactly `task-
|
|
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 =
|
|
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<
|
|
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,
|
|
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 =
|
|
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;
|