@danypops/papyrus 0.6.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 +26 -32
- package/extension/src/active-task-continuation.ts +9 -0
- package/extension/src/domain-tools.ts +35 -12
- package/extension/src/index.ts +39 -14
- package/extension/src/skills.ts +1 -0
- package/extension/src/task-widget.ts +4 -2
- package/extension/src/tasks.ts +62 -29
- 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/adapters/sqlite-task-scope-store.ts +59 -0
- package/src/cli.ts +100 -47
- package/src/constants.ts +9 -14
- package/src/daemon.ts +2 -18
- package/src/db.ts +49 -2
- package/src/domain/artifact.ts +6 -0
- package/src/domain/task-event.ts +6 -2
- package/src/domain/task-scope.ts +39 -0
- 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/ports/task-scope-store.ts +40 -0
- package/src/service.ts +79 -27
- package/src/skill-execution.ts +16 -10
- package/src/task-context.ts +4 -2
- package/src/task-service.ts +197 -30
- package/src/task-automation.ts +0 -188
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
|
}
|
|
@@ -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
|
+
}
|
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,21 +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]
|
|
69
|
+
papyrus tasks scope [project|all|graph <root-id>] [--json]
|
|
70
|
+
papyrus tasks assign-project <id> [project-root] [--json]
|
|
67
71
|
papyrus tasks focus <id> [--json]
|
|
72
|
+
papyrus tasks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
|
|
68
73
|
papyrus tasks complete <id> [--json]
|
|
69
74
|
papyrus tasks start <id> [--json]
|
|
70
75
|
papyrus tasks submit <id> [--json]
|
|
71
76
|
papyrus tasks reject <id> [--json]
|
|
72
77
|
papyrus tasks retry <id> [--json]
|
|
73
78
|
papyrus tasks cancel <id> [--json]
|
|
74
|
-
papyrus tasks automate <id> <on|off> [--json]
|
|
75
79
|
papyrus tasks depend <id> <prerequisite-id> [--json]`;
|
|
76
80
|
|
|
77
81
|
function usage(): never {
|
|
@@ -110,8 +114,8 @@ function planText(plan: TaskExecutionPlan): string {
|
|
|
110
114
|
export async function runMigrationCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
111
115
|
const json = args.includes("--json");
|
|
112
116
|
const positional = args.filter((arg) => arg !== "--json");
|
|
113
|
-
if (positional.length !== 1 || positional[0] !== "task-
|
|
114
|
-
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`");
|
|
115
119
|
}
|
|
116
120
|
const result = await client.call<Record<string, never>, MigrationResult>("system.migrate", {});
|
|
117
121
|
if (json) return JSON.stringify(result);
|
|
@@ -119,21 +123,7 @@ export async function runMigrationCli(args: string[], client: TaskCliClient): Pr
|
|
|
119
123
|
return `Migrated schema ${result.from} → ${result.to}: ${result.applied.join(", ")}`;
|
|
120
124
|
}
|
|
121
125
|
|
|
122
|
-
export async function
|
|
123
|
-
const json = args.includes("--json");
|
|
124
|
-
const positional = args.filter((argument) => argument !== "--json");
|
|
125
|
-
if (positional.length !== 1 || (positional[0] !== "status" && positional[0] !== "run")) {
|
|
126
|
-
throw new Error("automation requires exactly `status` or `run`");
|
|
127
|
-
}
|
|
128
|
-
if (positional[0] === "status") {
|
|
129
|
-
const status = await client.call<Record<string, never>, TaskAutomationSettings & { inFlight: boolean }>("automation.status", {});
|
|
130
|
-
return json ? JSON.stringify(status) : `Automation: ${status.enabled ? "enabled" : "disabled"} · interval ${status.intervalMs}ms · max ${status.maxTasksPerSweep} tasks · concurrency ${status.gateConcurrency}`;
|
|
131
|
-
}
|
|
132
|
-
const result = await client.call<Record<string, never>, TaskAutomationResult>("automation.reconcile", {});
|
|
133
|
-
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}` : ""}`;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export async function runSkillCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
126
|
+
export async function runSkillCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
|
|
137
127
|
const json = args.includes("--json");
|
|
138
128
|
const positional: string[] = [];
|
|
139
129
|
let runId: string | undefined;
|
|
@@ -160,7 +150,7 @@ export async function runSkillCli(args: string[], client: TaskCliClient): Promis
|
|
|
160
150
|
positional.push(argument);
|
|
161
151
|
}
|
|
162
152
|
if (positional.length !== 2 || positional[0] !== "run") throw new Error("skills requires `run <id>`");
|
|
163
|
-
const input: Record<string, unknown> = { id: positional[1], arguments: arguments_ };
|
|
153
|
+
const input: Record<string, unknown> = { id: positional[1], arguments: arguments_, project_root: projectRoot };
|
|
164
154
|
if (runId) input["run_id"] = runId;
|
|
165
155
|
const result = await client.call<Record<string, unknown>, {
|
|
166
156
|
runId: string;
|
|
@@ -178,20 +168,69 @@ export async function runSkillCli(args: string[], client: TaskCliClient): Promis
|
|
|
178
168
|
].join("\n");
|
|
179
169
|
}
|
|
180
170
|
|
|
181
|
-
export async function runTaskCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
171
|
+
export async function runTaskCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
|
|
182
172
|
const json = args.includes("--json");
|
|
183
|
-
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
|
+
}
|
|
184
192
|
const [action, id, dependencyId] = positional;
|
|
185
193
|
let result: unknown;
|
|
186
194
|
let human: string;
|
|
187
195
|
switch (action) {
|
|
188
196
|
case "active": {
|
|
189
197
|
if (id) throw new Error("tasks active accepts no positional arguments");
|
|
190
|
-
const active = await client.call<Record<string,
|
|
198
|
+
const active = await client.call<Record<string, string>, CliArtifact | null>("tasks.active", { project_root: projectRoot });
|
|
191
199
|
result = active;
|
|
192
200
|
human = active ? `Active: ${artifactLabel(active)}` : "No active task.";
|
|
193
201
|
break;
|
|
194
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
|
+
}
|
|
195
234
|
case "history": {
|
|
196
235
|
if (!id || dependencyId) throw new Error("tasks history requires exactly one task id");
|
|
197
236
|
const page = await client.call<{ id: string; direction: "desc" }, import("./domain/task-event.ts").TaskHistoryPage>("tasks.history", { id, direction: "desc" });
|
|
@@ -201,19 +240,50 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
201
240
|
: [...page.events].reverse().map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`).join("\n");
|
|
202
241
|
break;
|
|
203
242
|
}
|
|
243
|
+
case "scope": {
|
|
244
|
+
if (!id) {
|
|
245
|
+
const selection = await client.call<Record<string, string>, import("./domain/task-scope.ts").TaskViewSelection>("tasks.scope", { project_root: projectRoot });
|
|
246
|
+
result = selection;
|
|
247
|
+
human = `Task scope: ${selection.label}`;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
if (id !== "project" && id !== "all" && id !== "graph") throw new Error("tasks scope mode must be project, all, or graph");
|
|
251
|
+
if (id === "graph" && !dependencyId) throw new Error("tasks scope graph requires a root task id");
|
|
252
|
+
if (id !== "graph" && dependencyId) throw new Error(`tasks scope ${id} accepts no root task id`);
|
|
253
|
+
const selection = await client.call<Record<string, unknown>, import("./domain/task-scope.ts").TaskViewSelection>("tasks.set_scope", {
|
|
254
|
+
project_root: projectRoot,
|
|
255
|
+
scope: id,
|
|
256
|
+
...(dependencyId ? { root_task_id: dependencyId } : {}),
|
|
257
|
+
});
|
|
258
|
+
result = selection;
|
|
259
|
+
human = `Task scope: ${selection.label}`;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case "assign-project": {
|
|
263
|
+
if (!id || positional.length > 3) throw new Error("tasks assign-project requires a task id and optional project root");
|
|
264
|
+
const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.assign_project", {
|
|
265
|
+
id,
|
|
266
|
+
project_root: dependencyId ?? projectRoot,
|
|
267
|
+
actor: "user",
|
|
268
|
+
source: "cli",
|
|
269
|
+
});
|
|
270
|
+
result = artifact;
|
|
271
|
+
human = `Project assigned: ${artifactLabel(artifact)}`;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
204
274
|
case "focus": {
|
|
205
275
|
if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
|
|
206
|
-
const active = await client.call<
|
|
276
|
+
const active = await client.call<Record<string, string>, CliArtifact>("tasks.focus", { id, actor: "user", source: "cli" });
|
|
207
277
|
result = active;
|
|
208
278
|
human = `Active: ${artifactLabel(active)}`;
|
|
209
279
|
break;
|
|
210
280
|
}
|
|
211
281
|
case "graph": {
|
|
212
282
|
if (id) throw new Error("tasks graph accepts no positional arguments");
|
|
213
|
-
const graph = await client.call<{ limit: number }, {
|
|
283
|
+
const graph = await client.call<{ limit: number; project_root: string }, {
|
|
214
284
|
nodes: Array<{ dependencyIds: string[]; childIds: string[] }>;
|
|
215
285
|
rootIds: string[];
|
|
216
|
-
}>("tasks.graph", { limit: TASK_EXECUTION_MAX_NODES + 1 });
|
|
286
|
+
}>("tasks.graph", { limit: TASK_EXECUTION_MAX_NODES + 1, project_root: projectRoot });
|
|
217
287
|
result = graph;
|
|
218
288
|
const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
|
|
219
289
|
const children = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
|
|
@@ -222,7 +292,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
222
292
|
}
|
|
223
293
|
case "plan": {
|
|
224
294
|
if (id) throw new Error("tasks plan accepts no positional arguments");
|
|
225
|
-
const plan = await client.call<Record<string,
|
|
295
|
+
const plan = await client.call<Record<string, string>, TaskExecutionPlan>("tasks.plan", { project_root: projectRoot });
|
|
226
296
|
result = plan;
|
|
227
297
|
human = planText(plan);
|
|
228
298
|
break;
|
|
@@ -258,18 +328,6 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
258
328
|
human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
|
|
259
329
|
break;
|
|
260
330
|
}
|
|
261
|
-
case "automate": {
|
|
262
|
-
if (!id || (dependencyId !== "on" && dependencyId !== "off") || positional.length !== 3) throw new Error("tasks automate requires a task id and on or off");
|
|
263
|
-
const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.set_automation", {
|
|
264
|
-
id,
|
|
265
|
-
enabled: dependencyId === "on",
|
|
266
|
-
actor: "user",
|
|
267
|
-
source: "cli",
|
|
268
|
-
});
|
|
269
|
-
result = artifact;
|
|
270
|
-
human = `Automation ${dependencyId}: ${artifactLabel(artifact)}`;
|
|
271
|
-
break;
|
|
272
|
-
}
|
|
273
331
|
case "depend": {
|
|
274
332
|
if (!id || !dependencyId || positional.length !== 3) throw new Error("tasks depend requires a task id and prerequisite id");
|
|
275
333
|
const artifact = await client.call<{ id: string; dependency_id: string }, CliArtifact>("tasks.depend", {
|
|
@@ -281,7 +339,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
281
339
|
break;
|
|
282
340
|
}
|
|
283
341
|
default:
|
|
284
|
-
throw new Error("tasks action must be active, focus, graph, plan, history, 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");
|
|
285
343
|
}
|
|
286
344
|
return json ? JSON.stringify(result) : human;
|
|
287
345
|
}
|
|
@@ -294,11 +352,6 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
294
352
|
console.log(await runTaskCli(args.slice(1), client));
|
|
295
353
|
return;
|
|
296
354
|
}
|
|
297
|
-
if (command === "automation") {
|
|
298
|
-
const client = await connectPapyrusClient();
|
|
299
|
-
console.log(await runAutomationCli(args.slice(1), client));
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
355
|
if (command === "skills") {
|
|
303
356
|
const client = await connectPapyrusClient();
|
|
304
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,20 @@ 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
|
-
/**
|
|
53
|
-
export const
|
|
54
|
-
export const
|
|
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;
|
|
57
|
+
/** Persisted project and focused-graph Task view bounds. */
|
|
58
|
+
export const TASK_SCOPE_MAX_TASKS = 1_000;
|
|
59
|
+
export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;
|
|
65
60
|
export const GRAPH_RENDER_PADDING_X = 2;
|
|
66
61
|
export const GRAPH_RENDER_PADDING_Y = 1;
|
|
67
62
|
export const GRAPH_RENDER_BOX_PADDING = 0;
|
package/src/daemon.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, WAL_CHECKPOINT_INTERVAL_MS, dbPath } from "./constants.ts";
|
|
2
2
|
import { clearDaemonPort, daemonStateDir, loadOrCreateToken, writeDaemonPort } from "./daemon-state.ts";
|
|
3
3
|
import { createApp, createPapyrusService } from "./service.ts";
|
|
4
|
-
import { scheduleTaskAutomation, taskAutomationSettings, type TaskAutomationResult } from "./task-automation.ts";
|
|
5
4
|
import { logEvent } from "./log.ts";
|
|
6
5
|
|
|
7
6
|
/** Start the supervised, long-running Papyrus service. */
|
|
8
7
|
export function serveMain(): void {
|
|
9
8
|
const stateDir = daemonStateDir();
|
|
10
9
|
const token = loadOrCreateToken(stateDir);
|
|
11
|
-
const
|
|
12
|
-
const service = createPapyrusService(dbPath(), { automation });
|
|
10
|
+
const service = createPapyrusService(dbPath());
|
|
13
11
|
const app = createApp({ service, token });
|
|
14
12
|
const server = Bun.serve({
|
|
15
13
|
hostname: DAEMON_HOST,
|
|
@@ -27,31 +25,17 @@ export function serveMain(): void {
|
|
|
27
25
|
const optimizeTimer = setInterval(() => {
|
|
28
26
|
try { service.optimize(); } catch (error) { logEvent("error", "optimize_failed", { message: error instanceof Error ? error.message : String(error) }); }
|
|
29
27
|
}, DB_OPTIMIZE_INTERVAL_MS);
|
|
30
|
-
const stopAutomation = scheduleTaskAutomation(automation, async () => {
|
|
31
|
-
const result = await service.execute("automation.reconcile", {}) as TaskAutomationResult;
|
|
32
|
-
logEvent(result.errors.length > 0 ? "warn" : "info", "automation_sweep", {
|
|
33
|
-
examined: result.examined,
|
|
34
|
-
completed: result.completed,
|
|
35
|
-
rejected: result.rejected,
|
|
36
|
-
started: result.started,
|
|
37
|
-
errors: result.errors.length,
|
|
38
|
-
timedOut: result.timedOut,
|
|
39
|
-
skipped: result.skipped,
|
|
40
|
-
});
|
|
41
|
-
}, (error) => logEvent("error", "automation_sweep_failed", { message: error instanceof Error ? error.message : String(error) }));
|
|
42
|
-
|
|
43
28
|
let stopping = false;
|
|
44
29
|
const shutdown = () => {
|
|
45
30
|
if (stopping) return;
|
|
46
31
|
stopping = true;
|
|
47
32
|
clearInterval(checkpointTimer);
|
|
48
33
|
clearInterval(optimizeTimer);
|
|
49
|
-
stopAutomation();
|
|
50
34
|
clearDaemonPort(stateDir);
|
|
51
35
|
service.close();
|
|
52
36
|
void server.stop(true).finally(() => process.exit(0));
|
|
53
37
|
};
|
|
54
38
|
process.on("SIGINT", shutdown);
|
|
55
39
|
process.on("SIGTERM", shutdown);
|
|
56
|
-
logEvent("info", "listening", { host: DAEMON_HOST, port: server.port
|
|
40
|
+
logEvent("info", "listening", { host: DAEMON_HOST, port: server.port });
|
|
57
41
|
}
|
package/src/db.ts
CHANGED
|
@@ -100,6 +100,8 @@ CREATE TABLE IF NOT EXISTS relation_names (
|
|
|
100
100
|
CREATE TABLE IF NOT EXISTS task_focus (
|
|
101
101
|
scope TEXT PRIMARY KEY CHECK (scope = 'global'),
|
|
102
102
|
task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
|
|
103
|
+
status TEXT NOT NULL CHECK (status IN ('active', 'paused')),
|
|
104
|
+
pause_reason TEXT,
|
|
103
105
|
updated_at TEXT NOT NULL
|
|
104
106
|
);
|
|
105
107
|
CREATE TABLE IF NOT EXISTS task_events (
|
|
@@ -122,11 +124,25 @@ CREATE TRIGGER IF NOT EXISTS task_events_no_update BEFORE UPDATE ON task_events
|
|
|
122
124
|
BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
|
|
123
125
|
CREATE TRIGGER IF NOT EXISTS task_events_no_delete BEFORE DELETE ON task_events
|
|
124
126
|
BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
|
|
127
|
+
CREATE TABLE IF NOT EXISTS task_scopes (
|
|
128
|
+
task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
129
|
+
project_root TEXT,
|
|
130
|
+
source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
|
|
131
|
+
assigned_at TEXT NOT NULL
|
|
132
|
+
);
|
|
133
|
+
CREATE INDEX IF NOT EXISTS task_scopes_project_idx ON task_scopes(project_root, task_id);
|
|
134
|
+
CREATE TABLE IF NOT EXISTS task_views (
|
|
135
|
+
project_root TEXT PRIMARY KEY,
|
|
136
|
+
mode TEXT NOT NULL CHECK (mode IN ('project', 'graph', 'all')),
|
|
137
|
+
root_task_id TEXT REFERENCES artifacts(id),
|
|
138
|
+
updated_at TEXT NOT NULL,
|
|
139
|
+
CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
|
|
140
|
+
);
|
|
125
141
|
`;
|
|
126
142
|
|
|
127
143
|
const SEED_SQL = `
|
|
128
144
|
INSERT OR IGNORE INTO kinds VALUES ('doc','Knowledge — what we know (specs, decisions, research, designs)');
|
|
129
|
-
INSERT OR IGNORE INTO kinds VALUES ('task','Work — what we are doing (
|
|
145
|
+
INSERT OR IGNORE INTO kinds VALUES ('task','Work — what we are doing (objectives, steps, checklists)');
|
|
130
146
|
INSERT OR IGNORE INTO kinds VALUES ('rule','Governance — when doing X, follow Y');
|
|
131
147
|
INSERT OR IGNORE INTO kinds VALUES ('skill','Parameterized workflow bundle — inputs and templates load tasks, rules, and docs');
|
|
132
148
|
INSERT OR IGNORE INTO statuses VALUES ('draft','doc');
|
|
@@ -185,7 +201,7 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
185
201
|
throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
186
202
|
}
|
|
187
203
|
if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
|
|
188
|
-
if (from !== 1 && from !== 2) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
204
|
+
if (from !== 1 && from !== 2 && from !== 3 && from !== 4) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
189
205
|
const applied: string[] = [];
|
|
190
206
|
|
|
191
207
|
inTransaction(db, () => {
|
|
@@ -243,6 +259,37 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
243
259
|
`);
|
|
244
260
|
applied.push("task-history");
|
|
245
261
|
}
|
|
262
|
+
if (schemaVersion(db) === 3) {
|
|
263
|
+
db.exec(`
|
|
264
|
+
CREATE TABLE task_scopes (
|
|
265
|
+
task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
266
|
+
project_root TEXT,
|
|
267
|
+
source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
|
|
268
|
+
assigned_at TEXT NOT NULL
|
|
269
|
+
);
|
|
270
|
+
CREATE INDEX task_scopes_project_idx ON task_scopes(project_root, task_id);
|
|
271
|
+
CREATE TABLE task_views (
|
|
272
|
+
project_root TEXT PRIMARY KEY,
|
|
273
|
+
mode TEXT NOT NULL CHECK (mode IN ('project', 'graph', 'all')),
|
|
274
|
+
root_task_id TEXT REFERENCES artifacts(id),
|
|
275
|
+
updated_at TEXT NOT NULL,
|
|
276
|
+
CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
|
|
277
|
+
);
|
|
278
|
+
INSERT INTO task_scopes (task_id, project_root, source, assigned_at)
|
|
279
|
+
SELECT id, NULL, 'unscoped', strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
280
|
+
FROM artifacts WHERE kind = 'task';
|
|
281
|
+
PRAGMA user_version = 4;
|
|
282
|
+
`);
|
|
283
|
+
applied.push("task-project-scope");
|
|
284
|
+
}
|
|
285
|
+
if (schemaVersion(db) === 4) {
|
|
286
|
+
db.exec(`
|
|
287
|
+
ALTER TABLE task_focus ADD COLUMN status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused'));
|
|
288
|
+
ALTER TABLE task_focus ADD COLUMN pause_reason TEXT;
|
|
289
|
+
PRAGMA user_version = 5;
|
|
290
|
+
`);
|
|
291
|
+
applied.push("task-focus-continuation");
|
|
292
|
+
}
|
|
246
293
|
});
|
|
247
294
|
return { from, to: schemaVersion(db), applied };
|
|
248
295
|
}
|
package/src/domain/artifact.ts
CHANGED
|
@@ -30,6 +30,12 @@ export interface CreateArtifactInput {
|
|
|
30
30
|
templateId?: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface UpdateArtifactInput {
|
|
34
|
+
title?: string;
|
|
35
|
+
body?: string;
|
|
36
|
+
labels?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
export interface ArtifactQuery {
|
|
34
40
|
kind?: string;
|
|
35
41
|
status?: string;
|
package/src/domain/task-event.ts
CHANGED
|
@@ -9,12 +9,16 @@ export type TaskLifecycleStatus = "todo" | "in-progress" | "review" | "rejected"
|
|
|
9
9
|
|
|
10
10
|
export const TASK_EVENT_TYPES = [
|
|
11
11
|
"created",
|
|
12
|
+
"updated",
|
|
12
13
|
"started",
|
|
13
14
|
"submitted",
|
|
14
15
|
"completion_attempted",
|
|
15
16
|
"gates_evaluated",
|
|
16
|
-
"
|
|
17
|
-
"
|
|
17
|
+
"focus_set",
|
|
18
|
+
"focus_paused",
|
|
19
|
+
"focus_unpaused",
|
|
20
|
+
"focus_cleared",
|
|
21
|
+
"project_assigned",
|
|
18
22
|
"review_rejected",
|
|
19
23
|
"retried",
|
|
20
24
|
"completed",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { basename, isAbsolute, normalize } from "node:path";
|
|
2
|
+
import { TASK_PROJECT_ROOT_MAX_LENGTH } from "../constants.ts";
|
|
3
|
+
|
|
4
|
+
export type TaskViewMode = "project" | "graph" | "all";
|
|
5
|
+
export type TaskScopeSource = "cwd" | "explicit" | "unscoped";
|
|
6
|
+
|
|
7
|
+
export interface TaskProjectScope {
|
|
8
|
+
taskId: string;
|
|
9
|
+
projectRoot?: string;
|
|
10
|
+
source: TaskScopeSource;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TaskViewPreference {
|
|
14
|
+
projectRoot: string;
|
|
15
|
+
mode: TaskViewMode;
|
|
16
|
+
rootTaskId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TaskViewSelection {
|
|
20
|
+
mode: TaskViewMode;
|
|
21
|
+
label: string;
|
|
22
|
+
projectRoot?: string;
|
|
23
|
+
rootTaskId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeProjectRoot(value: string): string {
|
|
27
|
+
if (!isAbsolute(value)) throw new Error("project_root must be an absolute path");
|
|
28
|
+
const normalized = normalize(value);
|
|
29
|
+
if (normalized.length > TASK_PROJECT_ROOT_MAX_LENGTH) {
|
|
30
|
+
throw new Error(`project_root cannot exceed ${TASK_PROJECT_ROOT_MAX_LENGTH} characters`);
|
|
31
|
+
}
|
|
32
|
+
return normalized;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function taskScopeLabel(mode: TaskViewMode, projectRoot?: string, rootTitle?: string): string {
|
|
36
|
+
if (mode === "all") return "All projects";
|
|
37
|
+
const project = projectRoot ? basename(projectRoot) || projectRoot : "Unscoped";
|
|
38
|
+
return mode === "graph" ? `${project} · ${rootTitle ?? "focused graph"}` : project;
|
|
39
|
+
}
|