@danypops/papyrus 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -30
- package/extension/src/active-task-continuation.ts +9 -0
- package/extension/src/context-injection-telemetry.ts +80 -0
- package/extension/src/domain-tools.ts +15 -3
- package/extension/src/index.ts +38 -17
- package/extension/src/task-widget.ts +2 -1
- package/extension/src/tasks.ts +28 -20
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +6 -1
- package/src/adapters/sqlite-task-focus-store.ts +27 -14
- package/src/cli.ts +60 -40
- package/src/constants.ts +10 -14
- package/src/daemon.ts +2 -18
- package/src/db.ts +12 -2
- package/src/domain/artifact.ts +6 -0
- package/src/domain/task-event.ts +5 -2
- package/src/ops.ts +17 -1
- package/src/ports/artifact-store.ts +2 -0
- package/src/ports/task-focus-store.ts +30 -8
- package/src/service.ts +33 -21
- package/src/task-service.ts +98 -29
- package/src/task-automation.ts +0 -188
package/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;
|
|
@@ -17,6 +17,10 @@ export const GATE_OUTPUT_LIMIT = 200;
|
|
|
17
17
|
export const GATE_MAX_BUFFER_BYTES = 1_048_576;
|
|
18
18
|
export const GATE_FILE_MAX_BYTES = 1_048_576;
|
|
19
19
|
|
|
20
|
+
export const PAPYRUS_CONTEXT_INJECTION_CHANNEL = "papyrus.context-injection.v1";
|
|
21
|
+
export const PAPYRUS_CONTEXT_INJECTION_SCHEMA = "papyrus.context-injection/v1";
|
|
22
|
+
export const CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN = 4;
|
|
23
|
+
|
|
20
24
|
/** Compact task-context limits keep recurring prompt injection bounded. */
|
|
21
25
|
export const TASK_CONTEXT_CURRENT_LIMIT = 3;
|
|
22
26
|
export const TASK_CONTEXT_REJECTED_LIMIT = 3;
|
|
@@ -43,25 +47,17 @@ export const SKILL_RUN_ID_MAX_LENGTH = 64;
|
|
|
43
47
|
/** Bounded automatic Pi continuations while a focused Papyrus Task remains. */
|
|
44
48
|
export const TASK_DRIVER_MAX_TURNS = 20;
|
|
45
49
|
export const TASK_DRIVER_MAX_UNCHANGED_TURNS = 6;
|
|
50
|
+
/** Mutable Task content bounds. */
|
|
51
|
+
export const TASK_TITLE_MAX_LENGTH = 500;
|
|
52
|
+
export const TASK_BODY_MAX_LENGTH = 100_000;
|
|
53
|
+
export const TASK_LABEL_MAX_COUNT = 64;
|
|
54
|
+
export const TASK_LABEL_MAX_LENGTH = 128;
|
|
46
55
|
/** Append-only Task chronology query and evidence bounds. */
|
|
47
56
|
export const TASK_HISTORY_DEFAULT_LIMIT = 25;
|
|
48
57
|
export const TASK_HISTORY_MAX_LIMIT = 100;
|
|
49
58
|
export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
|
|
50
59
|
export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
|
|
51
60
|
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
61
|
/** Persisted project and focused-graph Task view bounds. */
|
|
66
62
|
export const TASK_SCOPE_MAX_TASKS = 1_000;
|
|
67
63
|
export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;
|
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 (
|
|
@@ -140,7 +142,7 @@ CREATE TABLE IF NOT EXISTS task_views (
|
|
|
140
142
|
|
|
141
143
|
const SEED_SQL = `
|
|
142
144
|
INSERT OR IGNORE INTO kinds VALUES ('doc','Knowledge — what we know (specs, decisions, research, designs)');
|
|
143
|
-
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)');
|
|
144
146
|
INSERT OR IGNORE INTO kinds VALUES ('rule','Governance — when doing X, follow Y');
|
|
145
147
|
INSERT OR IGNORE INTO kinds VALUES ('skill','Parameterized workflow bundle — inputs and templates load tasks, rules, and docs');
|
|
146
148
|
INSERT OR IGNORE INTO statuses VALUES ('draft','doc');
|
|
@@ -199,7 +201,7 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
199
201
|
throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
200
202
|
}
|
|
201
203
|
if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
|
|
202
|
-
if (from !== 1 && from !== 2 && from !== 3) 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}`);
|
|
203
205
|
const applied: string[] = [];
|
|
204
206
|
|
|
205
207
|
inTransaction(db, () => {
|
|
@@ -280,6 +282,14 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
280
282
|
`);
|
|
281
283
|
applied.push("task-project-scope");
|
|
282
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
|
+
}
|
|
283
293
|
});
|
|
284
294
|
return { from, to: schemaVersion(db), applied };
|
|
285
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,15 @@ 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",
|
|
18
21
|
"project_assigned",
|
|
19
22
|
"review_rejected",
|
|
20
23
|
"retried",
|
package/src/ops.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createRequire } from "node:module";
|
|
|
6
6
|
import { exec } from "node:child_process";
|
|
7
7
|
import type { Db } from "./db.ts";
|
|
8
8
|
import { inTransaction } from "./db.ts";
|
|
9
|
-
import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
|
|
9
|
+
import type { Artifact, CreateArtifactInput, UpdateArtifactInput } from "./domain/artifact.ts";
|
|
10
10
|
import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
|
|
11
11
|
export type { Artifact } from "./domain/artifact.ts";
|
|
12
12
|
export type { Gate, GateResult } from "./domain/gate.ts";
|
|
@@ -204,6 +204,22 @@ export function linkArtifacts(db: Db, fromId: string, relation: string, toId: st
|
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
export function updateArtifactContent(db: Db, id: string, input: UpdateArtifactInput): Artifact | null {
|
|
208
|
+
const artifact = getArtifact(db, id);
|
|
209
|
+
if (!artifact) return null;
|
|
210
|
+
const now = new Date().toISOString();
|
|
211
|
+
inTransaction(db, () => {
|
|
212
|
+
db.prepare("UPDATE artifacts SET title = ?, body = ?, labels = ?, updated_at = ? WHERE id = ?").run(
|
|
213
|
+
input.title ?? artifact.title,
|
|
214
|
+
input.body ?? artifact.body,
|
|
215
|
+
JSON.stringify(input.labels ?? artifact.labels),
|
|
216
|
+
now,
|
|
217
|
+
id,
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
return getArtifact(db, id);
|
|
221
|
+
}
|
|
222
|
+
|
|
207
223
|
export function updateStatus(db: Db, id: string, status: string): Artifact | null {
|
|
208
224
|
const art = getArtifact(db, id);
|
|
209
225
|
if (!art) return null;
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ArtifactQuery,
|
|
7
7
|
CreateArtifactInput,
|
|
8
8
|
RelationshipQuery,
|
|
9
|
+
UpdateArtifactInput,
|
|
9
10
|
} from "../domain/artifact.ts";
|
|
10
11
|
|
|
11
12
|
export interface ArtifactStore {
|
|
@@ -15,5 +16,6 @@ export interface ArtifactStore {
|
|
|
15
16
|
link(link: ArtifactLink): void;
|
|
16
17
|
setStatus(id: string, status: string): Artifact | null;
|
|
17
18
|
setExtra(id: string, extra: Record<string, unknown>): Artifact | null;
|
|
19
|
+
updateContent(id: string, input: UpdateArtifactInput): Artifact | null;
|
|
18
20
|
relationships(filter?: RelationshipQuery): ArtifactEdge[];
|
|
19
21
|
}
|
|
@@ -1,21 +1,43 @@
|
|
|
1
|
+
export type TaskFocusStatus = "active" | "paused";
|
|
2
|
+
|
|
3
|
+
export interface TaskFocusState {
|
|
4
|
+
taskId: string;
|
|
5
|
+
status: TaskFocusStatus;
|
|
6
|
+
updatedAt: string;
|
|
7
|
+
pauseReason?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
1
10
|
export interface TaskFocusStore {
|
|
2
|
-
get():
|
|
3
|
-
set(taskId: string):
|
|
11
|
+
get(): TaskFocusState | undefined;
|
|
12
|
+
set(taskId: string): TaskFocusState;
|
|
13
|
+
pause(taskId: string, reason?: string): TaskFocusState;
|
|
14
|
+
unpause(taskId: string): TaskFocusState;
|
|
4
15
|
clear(taskId?: string): void;
|
|
5
16
|
}
|
|
6
17
|
|
|
7
18
|
export class InMemoryTaskFocusStore implements TaskFocusStore {
|
|
8
|
-
private
|
|
19
|
+
private state: TaskFocusState | undefined;
|
|
20
|
+
|
|
21
|
+
get(): TaskFocusState | undefined { return this.state; }
|
|
22
|
+
|
|
23
|
+
set(taskId: string): TaskFocusState {
|
|
24
|
+
this.state = { taskId, status: "active", updatedAt: new Date().toISOString() };
|
|
25
|
+
return this.state;
|
|
26
|
+
}
|
|
9
27
|
|
|
10
|
-
|
|
11
|
-
|
|
28
|
+
pause(taskId: string, reason?: string): TaskFocusState {
|
|
29
|
+
if (this.state?.taskId !== taskId) throw new Error(`task "${taskId}" is not focused`);
|
|
30
|
+
this.state = { ...this.state, status: "paused", updatedAt: new Date().toISOString(), ...(reason ? { pauseReason: reason } : {}) };
|
|
31
|
+
return this.state;
|
|
12
32
|
}
|
|
13
33
|
|
|
14
|
-
|
|
15
|
-
this.taskId
|
|
34
|
+
unpause(taskId: string): TaskFocusState {
|
|
35
|
+
if (this.state?.taskId !== taskId) throw new Error(`task "${taskId}" is not focused`);
|
|
36
|
+
this.state = { taskId, status: "active", updatedAt: new Date().toISOString() };
|
|
37
|
+
return this.state;
|
|
16
38
|
}
|
|
17
39
|
|
|
18
40
|
clear(taskId?: string): void {
|
|
19
|
-
if (taskId === undefined || taskId ===
|
|
41
|
+
if (taskId === undefined || this.state?.taskId === taskId) this.state = undefined;
|
|
20
42
|
}
|
|
21
43
|
}
|
package/src/service.ts
CHANGED
|
@@ -16,7 +16,6 @@ import type { TaskEventStore } from "./ports/task-event-store.ts";
|
|
|
16
16
|
import type { TaskScopeStore } from "./ports/task-scope-store.ts";
|
|
17
17
|
import { projectTaskExecution } from "./task-execution.ts";
|
|
18
18
|
import { Tasks, type TaskStatus } from "./task-service.ts";
|
|
19
|
-
import { TaskAutomationReconciler, taskAutomationSettings, type TaskAutomationSettings } from "./task-automation.ts";
|
|
20
19
|
import {
|
|
21
20
|
createArtifactTemplate,
|
|
22
21
|
createDocument,
|
|
@@ -44,8 +43,6 @@ import { instantiateSkillWorkflow } from "./skill-execution.ts";
|
|
|
44
43
|
|
|
45
44
|
export const EXPECTED_OPERATION_NAMES = [
|
|
46
45
|
"system.migrate",
|
|
47
|
-
"automation.status",
|
|
48
|
-
"automation.reconcile",
|
|
49
46
|
"artifact.create",
|
|
50
47
|
"artifact.query",
|
|
51
48
|
"artifact.show",
|
|
@@ -55,6 +52,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
55
52
|
"gates.run",
|
|
56
53
|
"rules.injectable",
|
|
57
54
|
"tasks.create",
|
|
55
|
+
"tasks.update",
|
|
58
56
|
"tasks.list",
|
|
59
57
|
"tasks.graph",
|
|
60
58
|
"tasks.plan",
|
|
@@ -64,13 +62,16 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
64
62
|
"tasks.set_scope",
|
|
65
63
|
"tasks.assign_project",
|
|
66
64
|
"tasks.active",
|
|
65
|
+
"tasks.focused",
|
|
67
66
|
"tasks.focus",
|
|
67
|
+
"tasks.pause",
|
|
68
|
+
"tasks.unpause",
|
|
69
|
+
"tasks.clear_focus",
|
|
68
70
|
"tasks.start",
|
|
69
71
|
"tasks.submit",
|
|
70
72
|
"tasks.complete",
|
|
71
73
|
"tasks.run_gates",
|
|
72
74
|
"tasks.set_checklist",
|
|
73
|
-
"tasks.set_automation",
|
|
74
75
|
"tasks.context",
|
|
75
76
|
"tasks.reject",
|
|
76
77
|
"tasks.retry",
|
|
@@ -123,6 +124,13 @@ function optionalString(input: OperationInput, key: string): string | undefined
|
|
|
123
124
|
return value;
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
function optionalStringArray(input: OperationInput, key: string): string[] | undefined {
|
|
128
|
+
const value = input[key];
|
|
129
|
+
if (value === undefined) return undefined;
|
|
130
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) throw new Error(`${key} must be an array of strings`);
|
|
131
|
+
return value as string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
126
134
|
function optionalNumber(input: OperationInput, key: string): number | undefined {
|
|
127
135
|
const value = input[key];
|
|
128
136
|
if (value === undefined) return undefined;
|
|
@@ -154,7 +162,6 @@ function handlers(
|
|
|
154
162
|
artifacts: ArtifactStore,
|
|
155
163
|
gates: GateRunner,
|
|
156
164
|
tasks: Tasks,
|
|
157
|
-
automation: TaskAutomationReconciler,
|
|
158
165
|
events: TaskEventStore,
|
|
159
166
|
scopes: TaskScopeStore,
|
|
160
167
|
migrate: () => unknown,
|
|
@@ -169,18 +176,19 @@ function handlers(
|
|
|
169
176
|
const context = eventContext(input);
|
|
170
177
|
return { ...context, source: context.source ?? source };
|
|
171
178
|
};
|
|
172
|
-
const
|
|
179
|
+
const artifactFilter = (input: OperationInput) => ({
|
|
173
180
|
status: optionalString(input, "status"),
|
|
174
181
|
text: optionalString(input, "text"),
|
|
175
182
|
limit: optionalNumber(input, "limit"),
|
|
183
|
+
});
|
|
184
|
+
const taskFilter = (input: OperationInput) => ({
|
|
185
|
+
...artifactFilter(input),
|
|
176
186
|
projectRoot: string(input, "project_root"),
|
|
177
187
|
scope: optionalString(input, "scope") as TaskViewMode | undefined,
|
|
178
188
|
rootTaskId: optionalString(input, "root_task_id"),
|
|
179
189
|
});
|
|
180
190
|
return {
|
|
181
191
|
"system.migrate": () => migrate(),
|
|
182
|
-
"automation.status": () => automation.status(),
|
|
183
|
-
"automation.reconcile": () => automation.reconcile(),
|
|
184
192
|
"artifact.create": (input) => {
|
|
185
193
|
const normalized = normalizeCreateInput(input);
|
|
186
194
|
if (normalized.kind !== "task") return artifacts.create(normalized);
|
|
@@ -246,6 +254,11 @@ function handlers(
|
|
|
246
254
|
projectRoot: string(input, "project_root"),
|
|
247
255
|
projectSource: "cwd",
|
|
248
256
|
}, eventContext(input)),
|
|
257
|
+
"tasks.update": (input) => tasks.update(string(input, "id"), {
|
|
258
|
+
...(input["title"] !== undefined ? { title: optionalString(input, "title")! } : {}),
|
|
259
|
+
...(input["body"] !== undefined ? { body: optionalString(input, "body")! } : {}),
|
|
260
|
+
...(input["labels"] !== undefined ? { labels: optionalStringArray(input, "labels")! } : {}),
|
|
261
|
+
}, eventContext(input)),
|
|
249
262
|
"tasks.list": (input) => tasks.list(taskFilter(input)),
|
|
250
263
|
"tasks.graph": (input) => tasks.graph(taskFilter(input)),
|
|
251
264
|
"tasks.plan": (input) => projectTaskExecution(tasks.graph(taskFilter(input))),
|
|
@@ -267,16 +280,16 @@ function handlers(
|
|
|
267
280
|
eventContext(input),
|
|
268
281
|
),
|
|
269
282
|
"tasks.active": (input) => tasks.active(taskFilter(input)),
|
|
270
|
-
"tasks.
|
|
283
|
+
"tasks.focused": (input) => tasks.focused(taskFilter(input)),
|
|
284
|
+
"tasks.focus": (input) => tasks.focus(string(input, "id"), eventContext(input)),
|
|
285
|
+
"tasks.pause": (input) => tasks.pauseFocus(eventContext(input)),
|
|
286
|
+
"tasks.unpause": (input) => tasks.unpauseFocus(eventContext(input)),
|
|
287
|
+
"tasks.clear_focus": (input) => tasks.clearFocus(eventContext(input)),
|
|
271
288
|
"tasks.start": (input) => tasks.transition(string(input, "id"), "start", eventContext(input)),
|
|
272
289
|
"tasks.submit": (input) => tasks.transition(string(input, "id"), "submit", eventContext(input)),
|
|
273
290
|
"tasks.complete": (input) => tasks.completeAsync(string(input, "id"), eventContext(input)),
|
|
274
291
|
"tasks.run_gates": (input) => tasks.runGates(string(input, "id"), eventContext(input)),
|
|
275
292
|
"tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
|
|
276
|
-
"tasks.set_automation": (input) => {
|
|
277
|
-
if (typeof input["enabled"] !== "boolean") throw new Error("enabled must be a boolean");
|
|
278
|
-
return tasks.setAutomation(string(input, "id"), input["enabled"], eventContext(input));
|
|
279
|
-
},
|
|
280
293
|
"tasks.context": (input) => taskContext(artifacts, tasks.active()?.id, new Set(tasks.list(taskFilter(input)).map((task) => task.id))),
|
|
281
294
|
"tasks.reject": (input) => tasks.transition(string(input, "id"), "reject", eventContext(input)),
|
|
282
295
|
"tasks.retry": (input) => tasks.transition(string(input, "id"), "retry", eventContext(input)),
|
|
@@ -288,7 +301,7 @@ function handlers(
|
|
|
288
301
|
labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
|
|
289
302
|
templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
|
|
290
303
|
}),
|
|
291
|
-
"docs.list": (input) => listDocuments(artifacts,
|
|
304
|
+
"docs.list": (input) => listDocuments(artifacts, artifactFilter(input)),
|
|
292
305
|
"docs.show": (input) => showDocument(artifacts, string(input, "id")),
|
|
293
306
|
"docs.activate": (input) => transitionDocument(artifacts, string(input, "id"), "activate"),
|
|
294
307
|
"docs.archive": (input) => transitionDocument(artifacts, string(input, "id"), "archive"),
|
|
@@ -300,7 +313,7 @@ function handlers(
|
|
|
300
313
|
severity: optionalString(input, "severity") as "block" | "warn" | "info" | undefined,
|
|
301
314
|
labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
|
|
302
315
|
}),
|
|
303
|
-
"rules.list": (input) => listRules(artifacts,
|
|
316
|
+
"rules.list": (input) => listRules(artifacts, artifactFilter(input)),
|
|
304
317
|
"rules.show": (input) => showRule(artifacts, string(input, "id")),
|
|
305
318
|
"rules.preview": (input) => previewRule(artifacts, string(input, "id")),
|
|
306
319
|
"rules.enable": (input) => transitionRule(artifacts, string(input, "id"), "enable"),
|
|
@@ -316,7 +329,7 @@ function handlers(
|
|
|
316
329
|
title: string(input, "title"), targetKind: string(input, "target_kind"), defaults: input["defaults"] as Record<string, unknown> | undefined,
|
|
317
330
|
required: input["required"] as string[] | undefined, body: optionalString(input, "body"), labels: input["labels"] as string[] | undefined,
|
|
318
331
|
}),
|
|
319
|
-
"skills.list": (input) => listSkills(artifacts,
|
|
332
|
+
"skills.list": (input) => listSkills(artifacts, artifactFilter(input)),
|
|
320
333
|
"skills.show": (input) => showSkill(artifacts, string(input, "id")),
|
|
321
334
|
"skills.invoke": (input) => skillInvocation(artifacts, string(input, "id")),
|
|
322
335
|
"skills.run": (input) => instantiateSkillWorkflow(artifacts, string(input, "id"), {
|
|
@@ -343,7 +356,7 @@ function handlers(
|
|
|
343
356
|
};
|
|
344
357
|
}
|
|
345
358
|
|
|
346
|
-
export function createPapyrusService(path: string
|
|
359
|
+
export function createPapyrusService(path: string): PapyrusService {
|
|
347
360
|
const db = openDb(path);
|
|
348
361
|
const artifacts = new SQLiteArtifactStore(db);
|
|
349
362
|
const gates = new SQLiteGateRunner(db);
|
|
@@ -351,8 +364,7 @@ export function createPapyrusService(path: string, options: { automation?: TaskA
|
|
|
351
364
|
const events = new SQLiteTaskEventStore(db);
|
|
352
365
|
const scopes = new SQLiteTaskScopeStore(db);
|
|
353
366
|
const tasks = new Tasks(artifacts, gates, focus, events, scopes);
|
|
354
|
-
const
|
|
355
|
-
const registry = handlers(artifacts, gates, tasks, automation, events, scopes, () => migrateDb(db));
|
|
367
|
+
const registry = handlers(artifacts, gates, tasks, events, scopes, () => migrateDb(db));
|
|
356
368
|
const state = (): SchemaState => {
|
|
357
369
|
const current = schemaVersion(db);
|
|
358
370
|
return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
|
|
@@ -363,8 +375,8 @@ export function createPapyrusService(path: string, options: { automation?: TaskA
|
|
|
363
375
|
async execute(operation, input = {}) {
|
|
364
376
|
const handler = registry[operation as OperationName];
|
|
365
377
|
if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
|
|
366
|
-
if (operation !== "system.migrate" &&
|
|
367
|
-
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-
|
|
378
|
+
if (operation !== "system.migrate" && state().migrationRequired) {
|
|
379
|
+
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-focus`");
|
|
368
380
|
}
|
|
369
381
|
return handler(input);
|
|
370
382
|
},
|