@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -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(): string | undefined {
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?.task_id;
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): void {
16
- inTransaction(this.db, () => {
17
- this.db.prepare(`
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-history [--json]
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-history") {
114
- throw new Error("migrate requires exactly `task-history`");
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 runAutomationCli(args: string[], client: TaskCliClient): Promise<string> {
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 = args.filter((arg) => arg !== "--json");
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, never>, CliArtifact | null>("tasks.active", {});
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<{ id: string }, CliArtifact>("tasks.focus", { id });
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, never>, TaskExecutionPlan>("tasks.plan", {});
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, automate, or depend");
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 = 3;
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
- /** 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;
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 automation = taskAutomationSettings(process.env);
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, automationEnabled: automation.enabled });
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 (goals, steps, checklists)');
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
  }
@@ -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;
@@ -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
- "automation_enabled",
17
- "automation_disabled",
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
+ }