@danypops/papyrus 0.2.0 → 0.3.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.
@@ -15,23 +15,16 @@ export { showTaskDetails } from "./task-detail-view.ts";
15
15
  import type { Artifact } from "../../src/domain/artifact.ts";
16
16
  import type { GateResult } from "../../src/domain/gate.ts";
17
17
  import { projectTaskExecution } from "../../src/task-execution.ts";
18
- import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
19
-
20
- const GLYPHS: Record<string, string> = {
21
- pending: "○",
22
- ready: "◇",
23
- blocked: "○",
24
- active: "●",
25
- done: "■",
26
- failed: "▲",
27
- invalid: "!",
28
- };
18
+ import type { TaskCompletion, TaskGraph, TaskStatus } from "../../src/task-service.ts";
19
+ import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
29
20
 
30
21
  const STATUS_ACTIONS: Record<string, string[]> = {
31
- pending: ["Start", "Fail"],
32
- active: ["Complete (run gates)", "Fail"],
22
+ todo: ["Start", "Cancel"],
23
+ "in-progress": ["Submit for review", "Cancel"],
24
+ review: ["Complete review", "Reject", "Cancel"],
25
+ rejected: ["Retry", "Cancel"],
33
26
  done: [],
34
- failed: ["Retry"],
27
+ canceled: [],
35
28
  };
36
29
 
37
30
  type TaskRow = Artifact;
@@ -41,6 +34,7 @@ export interface TaskHierarchyRow {
41
34
  depth: number;
42
35
  childCount: number;
43
36
  dependencies: string[];
37
+ active: boolean;
44
38
  }
45
39
 
46
40
  export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
@@ -53,7 +47,7 @@ export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
53
47
  if (!node) return;
54
48
  visited.add(id);
55
49
  const children = node.childIds.filter((childId) => byId.has(childId));
56
- result.push({ task: node.task, depth, childCount: children.length, dependencies: [...node.dependencyIds] });
50
+ result.push({ task: node.task, depth, childCount: children.length, dependencies: [...node.dependencyIds], active: node.active === true });
57
51
  for (const childId of children) visit(childId, depth + 1);
58
52
  };
59
53
  for (const rootId of graph.rootIds) visit(rootId, 0);
@@ -90,7 +84,13 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
90
84
  if (action.type === "graph") { await showTaskGraph(ctx, graph); continue; }
91
85
  if (action.type !== "action" || !action.row) continue;
92
86
 
93
- const choices = ["Show details", "Run gates", ...(STATUS_ACTIONS[action.row.status] ?? [])];
87
+ const active = graph.nodes.find((node) => node.task.id === action.row!.id)?.active === true;
88
+ const choices = [
89
+ "Show details",
90
+ ...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
91
+ ...(action.row.status === "review" ? ["Run gates"] : []),
92
+ ...(STATUS_ACTIONS[action.row.status] ?? []),
93
+ ];
94
94
  const choice = await ctx.ui.select(action.row.title, choices);
95
95
  if (!choice) continue;
96
96
 
@@ -98,6 +98,13 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
98
98
  const art = await callService<Record<string, unknown>, Artifact | null>("tasks.show", { id: action.row.id });
99
99
  if (!art) { ctx.ui.notify("Not found", "error"); continue; }
100
100
  await showTaskDetails(ctx, art, graph);
101
+ } else if (choice === "Make active") {
102
+ try {
103
+ await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id });
104
+ ctx.ui.notify(`Active: ${action.row.title}`, "info");
105
+ } catch (error) {
106
+ ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
107
+ }
101
108
  } else if (choice === "Run gates") {
102
109
  try {
103
110
  const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id });
@@ -107,19 +114,30 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
107
114
  }
108
115
  } else {
109
116
  try {
110
- const operation = choice === "Start" ? "tasks.start" : choice === "Fail" ? "tasks.fail" : choice === "Retry" ? "tasks.retry" : "tasks.complete";
117
+ const operation = choice === "Start"
118
+ ? "tasks.start"
119
+ : choice === "Submit for review"
120
+ ? "tasks.submit"
121
+ : choice === "Reject"
122
+ ? "tasks.reject"
123
+ : choice === "Retry"
124
+ ? "tasks.retry"
125
+ : choice === "Cancel"
126
+ ? "tasks.cancel"
127
+ : "tasks.complete";
111
128
  if (operation === "tasks.complete") {
112
129
  const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id });
113
130
  action.row.status = result.artifact.status;
114
131
  const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target}`).join("\n");
115
- const started = result.started.length > 0 ? `\nStarted: ${result.started.map((task) => task.title).join(", ")}` : "";
132
+ const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}`).join("\n");
133
+ const focused = result.focused ? `\nActive: ${result.focused.title}` : "";
116
134
  const blocked = result.blocked.length > 0
117
135
  ? `\nWaiting: ${result.blocked.map((entry) => `${entry.artifact.title} needs ${entry.dependencyIds.join(", ")}`).join("; ")}`
118
136
  : "";
119
137
  ctx.ui.notify(
120
138
  result.completed
121
- ? `Completed ${result.artifact.id}${started}${blocked}${gates ? `\n${gates}` : ""}`
122
- : `Not complete; gates failed\n${gates}`,
139
+ ? `Completed ${result.artifact.id}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`
140
+ : `Review rejected${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`,
123
141
  result.completed ? "info" : "warning",
124
142
  );
125
143
  } else {
@@ -162,11 +180,15 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
162
180
 
163
181
  function statusLine(): string {
164
182
  const counts: Record<string, number> = {};
165
- for (const node of executionById.values()) counts[node.state] = (counts[node.state] ?? 0) + 1;
166
- return ["ready", "active", "blocked", "done", "failed", "invalid"]
167
- .filter((state) => (counts[state] ?? 0) > 0)
168
- .map((state) => `${GLYPHS[state] ?? state} ${counts[state]} ${state}`)
169
- .join(", ");
183
+ for (const entry of hierarchy) counts[entry.task.status] = (counts[entry.task.status] ?? 0) + 1;
184
+ const parts = hierarchy.some((entry) => entry.active) ? [" 1 active"] : [];
185
+ for (const status of ["todo", "in-progress", "review", "rejected", "done", "canceled"] as TaskStatus[]) {
186
+ if ((counts[status] ?? 0) > 0) {
187
+ const presentation = TASK_STATUS_PRESENTATION[status];
188
+ parts.push(`${presentation.glyph} ${counts[status]} ${presentation.label}`);
189
+ }
190
+ }
191
+ return parts.join(", ");
170
192
  }
171
193
 
172
194
  const header = {
@@ -210,14 +232,29 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
210
232
  const row = entry.task;
211
233
  const selected = i === selectedIndex;
212
234
  const cursor = selected ? theme.fg("accent", "❯") : " ";
235
+ const focus = entry.active ? theme.fg("accent", "▶") : " ";
213
236
  const execution = executionById.get(row.id);
214
237
  const state = execution?.state ?? row.status;
215
- const glyph = GLYPHS[state] ?? "?";
216
- const statusColor = state === "active" || state === "ready" ? "accent" : state === "done" ? "dim" : state === "failed" || state === "invalid" ? "warning" : "muted";
217
- const glyphStyled = theme.fg(statusColor, glyph);
238
+ const presentation = TASK_STATUS_PRESENTATION[row.status as TaskStatus];
239
+ const glyphStyled = state === "invalid"
240
+ ? theme.fg("error", "!")
241
+ : presentation
242
+ ? theme.fg(presentation.color, presentation.glyph)
243
+ : theme.fg("muted", "?");
218
244
  const title = selected ? theme.bold(row.title) : row.title;
219
- const indent = " ".repeat(entry.depth);
220
- const node = entry.childCount > 0 ? theme.fg("accent", "▾") : theme.fg("dim", "·");
245
+ let laterSibling = false;
246
+ for (let candidate = i + 1; candidate < filtered.length; candidate++) {
247
+ if (filtered[candidate]!.depth < entry.depth) break;
248
+ if (filtered[candidate]!.depth === entry.depth) { laterSibling = true; break; }
249
+ }
250
+ const connector = taskTreeConnector({
251
+ depth: entry.depth,
252
+ hasChildren: entry.childCount > 0,
253
+ hasLaterSibling: laterSibling,
254
+ });
255
+ const node = entry.depth === 0 && entry.childCount > 0
256
+ ? theme.fg("accent", connector)
257
+ : theme.fg("dim", connector);
221
258
  const gates = (row.extra?.["gates"] as any[])?.length;
222
259
  const relationParts: string[] = [];
223
260
  if (execution) relationParts.push(execution.layer === null ? state : `layer ${execution.layer + 1} · ${state}`);
@@ -228,7 +265,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
228
265
  }
229
266
  if (gates) relationParts.push(`${gates} gate${gates === 1 ? "" : "s"}`);
230
267
  const relationText = relationParts.length > 0 ? theme.fg("dim", ` · ${relationParts.join(" · ")}`) : "";
231
- lines.push(truncateToWidth(`${cursor} ${indent}${node} ${glyphStyled} ${title}${relationText}`, width, ""));
268
+ lines.push(truncateToWidth(`${cursor}${focus} ${node} ${glyphStyled} ${title}${relationText}`, width, ""));
232
269
  }
233
270
  const hasScroll = start > 0 || end < filtered.length;
234
271
  lines.push(theme.fg("muted", ` ${hasScroll ? `${selectedIndex + 1}/${filtered.length} · ` : ""}↑/↓ navigate · Enter actions`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.2.0",
3
+ "version": "0.3.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"],
@@ -0,0 +1,31 @@
1
+ import type { Db } from "../db.ts";
2
+ import { inTransaction } from "../db.ts";
3
+ import type { TaskFocusStore } from "../ports/task-focus-store.ts";
4
+
5
+ export class SQLiteTaskFocusStore implements TaskFocusStore {
6
+ constructor(private readonly db: Db) {}
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 }
11
+ | null;
12
+ return row?.task_id;
13
+ }
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
+ }
24
+
25
+ clear(taskId?: string): void {
26
+ inTransaction(this.db, () => {
27
+ if (taskId === undefined) this.db.prepare("DELETE FROM task_focus WHERE scope = 'global'").run();
28
+ else this.db.prepare("DELETE FROM task_focus WHERE scope = 'global' AND task_id = ?").run(taskId);
29
+ });
30
+ }
31
+ }
package/src/cli.ts CHANGED
@@ -56,9 +56,16 @@ function installService(): void {
56
56
  const USAGE = `Usage:
57
57
  papyrus serve
58
58
  papyrus service <install|start|stop|restart|status>
59
+ papyrus migrate task-lifecycle [--json]
59
60
  papyrus tasks plan [--json]
61
+ papyrus tasks active [--json]
62
+ papyrus tasks focus <id> [--json]
60
63
  papyrus tasks complete <id> [--json]
61
64
  papyrus tasks start <id> [--json]
65
+ papyrus tasks submit <id> [--json]
66
+ papyrus tasks reject <id> [--json]
67
+ papyrus tasks retry <id> [--json]
68
+ papyrus tasks cancel <id> [--json]
62
69
  papyrus tasks depend <id> <prerequisite-id> [--json]`;
63
70
 
64
71
  function usage(): never {
@@ -67,10 +74,10 @@ function usage(): never {
67
74
  }
68
75
 
69
76
  type TaskCliClient = Pick<PapyrusClient, "call">;
77
+ type MigrationResult = { from: number; to: number; applied: string[] };
70
78
  type CliArtifact = { id: string; title: string; status: string };
71
- type CliCompletion = Omit<TaskCompletion, "artifact" | "started" | "blocked"> & {
79
+ type CliCompletion = Omit<TaskCompletion, "artifact" | "blocked"> & {
72
80
  artifact: CliArtifact;
73
- started: CliArtifact[];
74
81
  blocked: Array<Omit<TaskBlockage, "artifact"> & { artifact: CliArtifact }>;
75
82
  gates: GateResult[];
76
83
  };
@@ -94,6 +101,18 @@ function planText(plan: TaskExecutionPlan): string {
94
101
  return lines.join("\n");
95
102
  }
96
103
 
104
+ export async function runMigrationCli(args: string[], client: TaskCliClient): Promise<string> {
105
+ const json = args.includes("--json");
106
+ const positional = args.filter((arg) => arg !== "--json");
107
+ if (positional.length !== 1 || positional[0] !== "task-lifecycle") {
108
+ throw new Error("migrate requires exactly `task-lifecycle`");
109
+ }
110
+ const result = await client.call<Record<string, never>, MigrationResult>("system.migrate", {});
111
+ if (json) return JSON.stringify(result);
112
+ if (result.applied.length === 0) return `Schema already current at version ${result.to}.`;
113
+ return `Migrated schema ${result.from} → ${result.to}: ${result.applied.join(", ")}`;
114
+ }
115
+
97
116
  export async function runTaskCli(args: string[], client: TaskCliClient): Promise<string> {
98
117
  const json = args.includes("--json");
99
118
  const positional = args.filter((arg) => arg !== "--json");
@@ -101,6 +120,20 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
101
120
  let result: unknown;
102
121
  let human: string;
103
122
  switch (action) {
123
+ case "active": {
124
+ if (id) throw new Error("tasks active accepts no positional arguments");
125
+ const active = await client.call<Record<string, never>, CliArtifact | null>("tasks.active", {});
126
+ result = active;
127
+ human = active ? `Active: ${artifactLabel(active)}` : "No active task.";
128
+ break;
129
+ }
130
+ case "focus": {
131
+ if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
132
+ const active = await client.call<{ id: string }, CliArtifact>("tasks.focus", { id });
133
+ result = active;
134
+ human = `Active: ${artifactLabel(active)}`;
135
+ break;
136
+ }
104
137
  case "plan": {
105
138
  if (id) throw new Error("tasks plan accepts no positional arguments");
106
139
  const plan = await client.call<Record<string, never>, TaskExecutionPlan>("tasks.plan", {});
@@ -112,8 +145,8 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
112
145
  if (!id || dependencyId) throw new Error("tasks complete requires exactly one task id");
113
146
  const completion = await client.call<{ id: string }, CliCompletion>("tasks.complete", { id });
114
147
  result = completion;
115
- const lines = [`${completion.completed ? "Completed" : "Not completed"}: ${artifactLabel(completion.artifact)}`];
116
- if (completion.started.length > 0) lines.push(`Started: ${completion.started.map(artifactLabel).join(", ")}`);
148
+ const lines = [`${completion.completed ? "Completed" : "Rejected"}: ${artifactLabel(completion.artifact)}`];
149
+ if (completion.focused) lines.push(`Active: ${artifactLabel(completion.focused)}`);
117
150
  if (completion.blocked.length > 0) {
118
151
  lines.push(`Blocked: ${completion.blocked.map((entry) => `${artifactLabel(entry.artifact)} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`);
119
152
  }
@@ -128,6 +161,17 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
128
161
  human = `Started: ${artifactLabel(artifact)}`;
129
162
  break;
130
163
  }
164
+ case "submit":
165
+ case "reject":
166
+ case "retry":
167
+ case "cancel": {
168
+ if (!id || dependencyId) throw new Error(`tasks ${action} requires exactly one task id`);
169
+ const operation = `tasks.${action}` as "tasks.submit" | "tasks.reject" | "tasks.retry" | "tasks.cancel";
170
+ const artifact = await client.call<{ id: string }, CliArtifact>(operation, { id });
171
+ result = artifact;
172
+ human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
173
+ break;
174
+ }
131
175
  case "depend": {
132
176
  if (!id || !dependencyId || positional.length !== 3) throw new Error("tasks depend requires a task id and prerequisite id");
133
177
  const artifact = await client.call<{ id: string; dependency_id: string }, CliArtifact>("tasks.depend", {
@@ -139,7 +183,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
139
183
  break;
140
184
  }
141
185
  default:
142
- throw new Error("tasks action must be plan, complete, start, or depend");
186
+ throw new Error("tasks action must be active, focus, plan, complete, start, submit, reject, retry, cancel, or depend");
143
187
  }
144
188
  return json ? JSON.stringify(result) : human;
145
189
  }
@@ -152,6 +196,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
152
196
  console.log(await runTaskCli(args.slice(1), client));
153
197
  return;
154
198
  }
199
+ if (command === "migrate") {
200
+ const client = await connectPapyrusClient();
201
+ console.log(await runMigrationCli(args.slice(1), client));
202
+ return;
203
+ }
155
204
  if (command !== "service") usage();
156
205
  switch (action) {
157
206
  case "install": installService(); break;
package/src/client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
2
2
  import { daemonStateDir, readDaemonHandle } from "./daemon-state.ts";
3
- import type { OperationName } from "./service.ts";
3
+ import type { OperationName, SchemaState } from "./service.ts";
4
4
 
5
5
  export type FetchAdapter = (request: Request) => Promise<Response>;
6
6
 
@@ -28,7 +28,7 @@ export class PapyrusClient {
28
28
  return body;
29
29
  }
30
30
 
31
- health(): Promise<{ ok: true; version: string }> {
31
+ health(): Promise<{ ok: true; version: string; schema: SchemaState }> {
32
32
  return this.request("/health");
33
33
  }
34
34
 
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 = 1;
10
+ export const SQLITE_SCHEMA_VERSION = 2;
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,9 +17,9 @@ export const GATE_OUTPUT_LIMIT = 200;
17
17
  export const GATE_MAX_BUFFER_BYTES = 1_048_576;
18
18
 
19
19
  /** Compact task-context limits keep recurring prompt injection bounded. */
20
- export const TASK_CONTEXT_ACTIVE_LIMIT = 3;
21
- export const TASK_CONTEXT_FAILED_LIMIT = 3;
22
- export const TASK_WIDGET_ACTIVE_LIMIT = 3;
20
+ export const TASK_CONTEXT_CURRENT_LIMIT = 3;
21
+ export const TASK_CONTEXT_REJECTED_LIMIT = 3;
22
+ export const TASK_WIDGET_OPEN_LIMIT = 3;
23
23
  export const TASK_DETAIL_MIN_VISIBLE_LINES = 8;
24
24
  export const TASK_DETAIL_MAX_VISIBLE_LINES = 24;
25
25
  export const TASK_DETAIL_RESERVED_ROWS = 8;
@@ -32,8 +32,7 @@ export const TASK_GRAPH_HORIZONTAL_PAN_COLUMNS = 4;
32
32
  export const TASK_EXECUTION_MAX_NODES = 1_000;
33
33
  export const TASK_EXECUTION_MAX_EDGES = 10_000;
34
34
  export const TASK_EXECUTION_MAX_DEGREE = 100;
35
- /** Bounded automatic Pi continuations while active Papyrus Tasks remain. */
36
- export const TASK_DRIVER_ACTIVE_LIMIT = 4;
35
+ /** Bounded automatic Pi continuations while a focused Papyrus Task remains. */
37
36
  export const TASK_DRIVER_MAX_TURNS = 20;
38
37
  export const TASK_DRIVER_MAX_UNCHANGED_TURNS = 6;
39
38
  export const GRAPH_RENDER_PADDING_X = 2;
@@ -58,7 +57,7 @@ export const TASK_RECONCILIATION_INSTRUCTION = [
58
57
  '• For each current task, ask: "Did we accomplish this task?"',
59
58
  "• If yes, run its gates before marking it done; a claim is not verification.",
60
59
  "• If no, continue with the next concrete action toward its desired state.",
61
- "• Address blocked work or explicitly leave it failed with the reason.",
60
+ "• Address blocked work or explicitly move failed review to rejected with the reason.",
62
61
  ].join("\n");
63
62
 
64
63
  /** $XDG_DATA_HOME/papyrus/papyrus.db */
@@ -88,10 +87,12 @@ export const SEED_STATUSES = [
88
87
  { name: "draft", kind: "doc" },
89
88
  { name: "active", kind: "doc" },
90
89
  { name: "archived", kind: "doc" },
91
- { name: "pending", kind: "task" },
92
- { name: "active", kind: "task" },
90
+ { name: "todo", kind: "task" },
91
+ { name: "in-progress", kind: "task" },
92
+ { name: "review", kind: "task" },
93
+ { name: "rejected", kind: "task" },
93
94
  { name: "done", kind: "task" },
94
- { name: "failed", kind: "task" },
95
+ { name: "canceled", kind: "task" },
95
96
  { name: "active", kind: "rule" },
96
97
  { name: "deprecated", kind: "rule" },
97
98
  { name: "active", kind: "skill" },
package/src/db.ts CHANGED
@@ -74,6 +74,11 @@ CREATE TABLE IF NOT EXISTS relation_names (
74
74
  name TEXT PRIMARY KEY,
75
75
  description TEXT
76
76
  );
77
+ CREATE TABLE IF NOT EXISTS task_focus (
78
+ scope TEXT PRIMARY KEY CHECK (scope = 'global'),
79
+ task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
80
+ updated_at TEXT NOT NULL
81
+ );
77
82
  `;
78
83
 
79
84
  const SEED_SQL = `
@@ -84,10 +89,12 @@ INSERT OR IGNORE INTO kinds VALUES ('skill','Parameterized workflow bundle — i
84
89
  INSERT OR IGNORE INTO statuses VALUES ('draft','doc');
85
90
  INSERT OR IGNORE INTO statuses VALUES ('active','doc');
86
91
  INSERT OR IGNORE INTO statuses VALUES ('archived','doc');
87
- INSERT OR IGNORE INTO statuses VALUES ('pending','task');
88
- INSERT OR IGNORE INTO statuses VALUES ('active','task');
92
+ INSERT OR IGNORE INTO statuses VALUES ('todo','task');
93
+ INSERT OR IGNORE INTO statuses VALUES ('in-progress','task');
94
+ INSERT OR IGNORE INTO statuses VALUES ('review','task');
95
+ INSERT OR IGNORE INTO statuses VALUES ('rejected','task');
89
96
  INSERT OR IGNORE INTO statuses VALUES ('done','task');
90
- INSERT OR IGNORE INTO statuses VALUES ('failed','task');
97
+ INSERT OR IGNORE INTO statuses VALUES ('canceled','task');
91
98
  INSERT OR IGNORE INTO statuses VALUES ('active','rule');
92
99
  INSERT OR IGNORE INTO statuses VALUES ('deprecated','rule');
93
100
  INSERT OR IGNORE INTO statuses VALUES ('active','skill');
@@ -107,23 +114,64 @@ INSERT OR IGNORE INTO relation_names VALUES ('part_of','Artifact belongs to a pa
107
114
  CREATE INDEX IF NOT EXISTS edges_to_id_idx ON edges(to_id);
108
115
  `;
109
116
 
110
- function migrate(db: Db): void {
111
- const row = db.prepare("PRAGMA user_version").get() as { user_version: number };
112
- let version = row.user_version;
113
- if (version > SQLITE_SCHEMA_VERSION) {
114
- throw new Error(`database schema ${version} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
115
- }
116
- if (version < 1) {
117
- inTransaction(db, () => {
118
- db.exec(SCHEMA);
119
- db.exec(SEED_SQL);
120
- db.exec("PRAGMA user_version = 1");
121
- });
122
- version = 1;
123
- }
124
- if (version !== SQLITE_SCHEMA_VERSION) {
125
- throw new Error(`missing migration from schema ${version} to ${SQLITE_SCHEMA_VERSION}`);
117
+ export interface MigrationResult {
118
+ from: number;
119
+ to: number;
120
+ applied: string[];
121
+ }
122
+
123
+ export function schemaVersion(db: Db): number {
124
+ return (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
125
+ }
126
+
127
+ function bootstrapEmptyDatabase(db: Db): void {
128
+ const existing = db
129
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1")
130
+ .get();
131
+ if (existing) throw new Error("database schema is unversioned; refusing to migrate existing data during boot");
132
+ inTransaction(db, () => {
133
+ db.exec(SCHEMA);
134
+ db.exec(SEED_SQL);
135
+ db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
136
+ });
137
+ }
138
+
139
+ export function migrateDb(db: Db): MigrationResult {
140
+ const from = schemaVersion(db);
141
+ if (from > SQLITE_SCHEMA_VERSION) {
142
+ throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
126
143
  }
144
+ if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
145
+ if (from !== 1) throw new Error(`no explicit migration path from database schema ${from}`);
146
+
147
+ inTransaction(db, () => {
148
+ db.exec(`
149
+ INSERT OR IGNORE INTO statuses VALUES ('todo','task');
150
+ INSERT OR IGNORE INTO statuses VALUES ('in-progress','task');
151
+ INSERT OR IGNORE INTO statuses VALUES ('review','task');
152
+ INSERT OR IGNORE INTO statuses VALUES ('rejected','task');
153
+ INSERT OR IGNORE INTO statuses VALUES ('done','task');
154
+ INSERT OR IGNORE INTO statuses VALUES ('canceled','task');
155
+ CREATE TABLE task_focus (
156
+ scope TEXT PRIMARY KEY CHECK (scope = 'global'),
157
+ task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
158
+ updated_at TEXT NOT NULL
159
+ );
160
+ INSERT INTO task_focus (scope, task_id, updated_at)
161
+ SELECT 'global', id, strftime('%Y-%m-%dT%H:%M:%fZ','now')
162
+ FROM artifacts WHERE kind = 'task' AND status = 'active'
163
+ ORDER BY updated_at DESC, id ASC LIMIT 1;
164
+ UPDATE artifacts SET status = CASE status
165
+ WHEN 'pending' THEN 'todo'
166
+ WHEN 'active' THEN 'in-progress'
167
+ WHEN 'failed' THEN 'rejected'
168
+ ELSE status END
169
+ WHERE kind = 'task';
170
+ DELETE FROM statuses WHERE kind = 'task' AND name IN ('pending', 'active', 'failed');
171
+ PRAGMA user_version = 2;
172
+ `);
173
+ });
174
+ return { from, to: SQLITE_SCHEMA_VERSION, applied: ["task-lifecycle-and-focus"] };
127
175
  }
128
176
 
129
177
  export function openDb(path: string): Db {
@@ -132,7 +180,12 @@ export function openDb(path: string): Db {
132
180
  db.exec("PRAGMA foreign_keys = ON");
133
181
  db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
134
182
  if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL");
135
- migrate(db);
183
+ const current = schemaVersion(db);
184
+ if (current > SQLITE_SCHEMA_VERSION) {
185
+ db.close();
186
+ throw new Error(`database schema ${current} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
187
+ }
188
+ if (current === 0) bootstrapEmptyDatabase(db);
136
189
  db.exec("PRAGMA optimize=0x10002");
137
190
  return db;
138
191
  }
@@ -0,0 +1,21 @@
1
+ export interface TaskFocusStore {
2
+ get(): string | undefined;
3
+ set(taskId: string): void;
4
+ clear(taskId?: string): void;
5
+ }
6
+
7
+ export class InMemoryTaskFocusStore implements TaskFocusStore {
8
+ private taskId: string | undefined;
9
+
10
+ get(): string | undefined {
11
+ return this.taskId;
12
+ }
13
+
14
+ set(taskId: string): void {
15
+ this.taskId = taskId;
16
+ }
17
+
18
+ clear(taskId?: string): void {
19
+ if (taskId === undefined || taskId === this.taskId) this.taskId = undefined;
20
+ }
21
+ }