@danypops/papyrus 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 (
@@ -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 (goals, steps, checklists)');
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
  }
@@ -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,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
- "automation_enabled",
17
- "automation_disabled",
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(): string | undefined;
3
- set(taskId: string): void;
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 taskId: string | undefined;
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
- get(): string | undefined {
11
- return this.taskId;
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
- set(taskId: string): void {
15
- this.taskId = 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 === this.taskId) this.taskId = undefined;
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 taskFilter = (input: OperationInput) => ({
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.focus": (input) => tasks.focus(string(input, "id")),
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, taskFilter(input)),
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, taskFilter(input)),
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, taskFilter(input)),
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, options: { automation?: TaskAutomationSettings } = {}): PapyrusService {
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 automation = new TaskAutomationReconciler(tasks, options.automation ?? taskAutomationSettings({}));
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" && operation !== "automation.status" && state().migrationRequired) {
367
- throw new MigrationRequiredError("database migration required; run `papyrus migrate task-scope`");
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
  },
@@ -1,4 +1,13 @@
1
- import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES, TASK_SCOPE_MAX_TASKS } from "./constants.ts";
1
+ import {
2
+ TASK_BODY_MAX_LENGTH,
3
+ TASK_EXECUTION_MAX_DEGREE,
4
+ TASK_EXECUTION_MAX_EDGES,
5
+ TASK_EXECUTION_MAX_NODES,
6
+ TASK_LABEL_MAX_COUNT,
7
+ TASK_LABEL_MAX_LENGTH,
8
+ TASK_SCOPE_MAX_TASKS,
9
+ TASK_TITLE_MAX_LENGTH,
10
+ } from "./constants.ts";
2
11
  import type { Artifact } from "./domain/artifact.ts";
3
12
  import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
4
13
  import type { Gate, GateResult } from "./domain/gate.ts";
@@ -6,11 +15,17 @@ import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQue
6
15
  import { normalizeProjectRoot, taskScopeLabel, type TaskScopeSource, type TaskViewMode, type TaskViewSelection } from "./domain/task-scope.ts";
7
16
  import type { ArtifactStore } from "./ports/artifact-store.ts";
8
17
  import type { GateRunner } from "./ports/gate-runner.ts";
9
- import { InMemoryTaskFocusStore, type TaskFocusStore } from "./ports/task-focus-store.ts";
18
+ import { InMemoryTaskFocusStore, type TaskFocusStatus, type TaskFocusStore } from "./ports/task-focus-store.ts";
10
19
  import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-store.ts";
11
20
  import { InMemoryTaskScopeStore, type TaskScopeStore } from "./ports/task-scope-store.ts";
12
21
  import { assertDependencyEdgeAllowed } from "./task-execution.ts";
13
22
 
23
+ export interface UpdateTaskInput {
24
+ title?: string;
25
+ body?: string;
26
+ labels?: string[];
27
+ }
28
+
14
29
  export interface TaskFilter {
15
30
  status?: string;
16
31
  text?: string;
@@ -53,6 +68,13 @@ export interface ChecklistReview {
53
68
  reason?: string;
54
69
  }
55
70
 
71
+ export interface TaskFocus {
72
+ artifact: Artifact;
73
+ status: TaskFocusStatus;
74
+ updatedAt: string;
75
+ pauseReason?: string;
76
+ }
77
+
56
78
  export interface TaskCompletionOptions {
57
79
  focusSuccessor?: boolean;
58
80
  gateDeadlineMs?: number;
@@ -70,6 +92,7 @@ export interface TaskCompletion {
70
92
  export interface TaskNode {
71
93
  task: Artifact;
72
94
  active?: boolean;
95
+ focusStatus?: TaskFocusStatus;
73
96
  parentIds: string[];
74
97
  childIds: string[];
75
98
  dependencyIds: string[];
@@ -138,6 +161,28 @@ export class Tasks {
138
161
  });
139
162
  }
140
163
 
164
+ update(id: string, input: UpdateTaskInput, context: TaskEventContext = {}): Artifact {
165
+ const fields = (["title", "body", "labels"] as const).filter((field) => input[field] !== undefined);
166
+ if (fields.length === 0) throw new Error("task update requires title, body, or labels");
167
+ if (input.title !== undefined && (input.title.trim().length === 0 || input.title.length > TASK_TITLE_MAX_LENGTH)) {
168
+ throw new Error(`title must be between 1 and ${TASK_TITLE_MAX_LENGTH} characters`);
169
+ }
170
+ if (input.body !== undefined && input.body.length > TASK_BODY_MAX_LENGTH) throw new Error(`body cannot exceed ${TASK_BODY_MAX_LENGTH} characters`);
171
+ if (input.labels !== undefined) {
172
+ if (input.labels.length > TASK_LABEL_MAX_COUNT) throw new Error(`labels cannot exceed ${TASK_LABEL_MAX_COUNT} entries`);
173
+ if (input.labels.some((label) => label.length === 0 || label.length > TASK_LABEL_MAX_LENGTH)) {
174
+ throw new Error(`each label must be between 1 and ${TASK_LABEL_MAX_LENGTH} characters`);
175
+ }
176
+ }
177
+ return this.events.atomic(() => {
178
+ this.require(id);
179
+ const updated = this.artifacts.updateContent(id, input);
180
+ if (!updated) throw new Error(`task "${id}" not found`);
181
+ this.appendEvent({ taskId: id, type: "updated", evidence: { result: `fields:${fields.sort().join(",")}` } }, context);
182
+ return updated;
183
+ });
184
+ }
185
+
141
186
  list(filter: TaskFilter = {}): Artifact[] {
142
187
  const selection = this.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
143
188
  const limit = filter.limit ?? TASK_SCOPE_MAX_TASKS;
@@ -204,10 +249,12 @@ export class Tasks {
204
249
  throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
205
250
  }
206
251
  const byId = new Map(tasks.map((task) => [task.id, task]));
207
- const focusedId = this.focusStore.get();
252
+ const focus = this.focusStore.get();
253
+ const focusedId = focus?.taskId;
208
254
  const nodes = new Map(tasks.map((task) => [task.id, {
209
255
  task,
210
256
  active: task.id === focusedId,
257
+ ...(task.id === focusedId ? { focusStatus: focus!.status } : {}),
211
258
  parentIds: [] as string[],
212
259
  childIds: [] as string[],
213
260
  dependencyIds: [] as string[],
@@ -247,25 +294,60 @@ export class Tasks {
247
294
  return this.artifacts.get(id, { tree: true })!;
248
295
  }
249
296
 
250
- active(filter?: TaskFilter): Artifact | null {
251
- const id = this.focusStore.get();
252
- if (!id) return null;
253
- const task = this.artifacts.get(id);
297
+ focused(filter?: TaskFilter): TaskFocus | null {
298
+ const focus = this.focusStore.get();
299
+ if (!focus) return null;
300
+ const task = this.artifacts.get(focus.taskId);
254
301
  if (!task || task.kind !== "task" || task.status === "done" || task.status === "canceled") {
255
- this.focusStore.clear(id);
302
+ this.focusStore.clear(focus.taskId);
256
303
  return null;
257
304
  }
258
305
  if (filter?.projectRoot && !this.list(filter).some((candidate) => candidate.id === task.id)) return null;
259
- return task;
306
+ return { artifact: task, status: focus.status, updatedAt: focus.updatedAt, ...(focus.pauseReason ? { pauseReason: focus.pauseReason } : {}) };
260
307
  }
261
308
 
262
- focus(id: string): Artifact {
263
- const task = this.require(id);
264
- if (task.status === "done" || task.status === "canceled") {
265
- throw new Error(`cannot focus task from ${task.status}`);
266
- }
267
- this.focusStore.set(id);
268
- return task;
309
+ active(filter?: TaskFilter): Artifact | null {
310
+ const focus = this.focused(filter);
311
+ return focus?.status === "active" ? focus.artifact : null;
312
+ }
313
+
314
+ focus(id: string, context: TaskEventContext = {}): Artifact {
315
+ return this.events.atomic(() => {
316
+ const task = this.require(id);
317
+ if (task.status === "done" || task.status === "canceled") throw new Error(`cannot focus task from ${task.status}`);
318
+ this.focusStore.set(id);
319
+ this.appendEvent({ taskId: id, type: "focus_set" }, context);
320
+ return task;
321
+ });
322
+ }
323
+
324
+ pauseFocus(context: TaskEventContext = {}): TaskFocus {
325
+ return this.events.atomic(() => {
326
+ const focus = this.focused();
327
+ if (!focus) throw new Error("no focused task");
328
+ const state = this.focusStore.pause(focus.artifact.id, context.reason);
329
+ this.appendEvent({ taskId: focus.artifact.id, type: "focus_paused" }, context);
330
+ return { artifact: focus.artifact, status: state.status, updatedAt: state.updatedAt, ...(state.pauseReason ? { pauseReason: state.pauseReason } : {}) };
331
+ });
332
+ }
333
+
334
+ unpauseFocus(context: TaskEventContext = {}): TaskFocus {
335
+ return this.events.atomic(() => {
336
+ const focus = this.focused();
337
+ if (!focus) throw new Error("no focused task");
338
+ const state = this.focusStore.unpause(focus.artifact.id);
339
+ this.appendEvent({ taskId: focus.artifact.id, type: "focus_unpaused" }, context);
340
+ return { artifact: focus.artifact, status: state.status, updatedAt: state.updatedAt };
341
+ });
342
+ }
343
+
344
+ clearFocus(context: TaskEventContext = {}): { cleared: boolean } {
345
+ return this.events.atomic(() => {
346
+ const focus = this.focusStore.get();
347
+ if (focus) this.appendEvent({ taskId: focus.taskId, type: "focus_cleared" }, context);
348
+ this.focusStore.clear();
349
+ return { cleared: focus !== undefined };
350
+ });
269
351
  }
270
352
 
271
353
  transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
@@ -324,19 +406,6 @@ export class Tasks {
324
406
  return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
325
407
  }
326
408
 
327
- setAutomation(id: string, enabled: boolean, context: TaskEventContext = {}): Artifact {
328
- return this.events.atomic(() => {
329
- const task = this.require(id);
330
- const current = task.extra["automation"];
331
- const automation = typeof current === "object" && current !== null && !Array.isArray(current)
332
- ? current as Record<string, unknown>
333
- : {};
334
- const updated = this.artifacts.setExtra(id, { ...task.extra, automation: { ...automation, enabled } })!;
335
- this.appendEvent({ taskId: id, type: enabled ? "automation_enabled" : "automation_disabled" }, context);
336
- return updated;
337
- });
338
- }
339
-
340
409
  depend(id: string, dependencyId: string): Artifact {
341
410
  this.require(id);
342
411
  this.require(dependencyId);