@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.
package/src/service.ts CHANGED
@@ -1,14 +1,15 @@
1
- import { SERVICE_MAX_BODY_BYTES } from "./constants.ts";
1
+ import { SERVICE_MAX_BODY_BYTES, SQLITE_SCHEMA_VERSION } from "./constants.ts";
2
2
  import { VERSION } from "./version.ts";
3
- import { openDb } from "./db.ts";
3
+ import { migrateDb, openDb, schemaVersion } from "./db.ts";
4
4
  import { SQLiteArtifactStore } from "./adapters/sqlite-artifact-store.ts";
5
5
  import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
6
+ import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
6
7
  import type { CreateArtifactInput } from "./domain/artifact.ts";
7
8
  import type { Checklist } from "./domain/checklist.ts";
8
9
  import type { ArtifactStore } from "./ports/artifact-store.ts";
9
10
  import type { GateRunner } from "./ports/gate-runner.ts";
10
11
  import { projectTaskExecution } from "./task-execution.ts";
11
- import { Tasks } from "./task-service.ts";
12
+ import { Tasks, type TaskStatus } from "./task-service.ts";
12
13
  import {
13
14
  createArtifactTemplate,
14
15
  createDocument,
@@ -33,6 +34,7 @@ import {
33
34
  import { taskContext } from "./task-context.ts";
34
35
 
35
36
  export const EXPECTED_OPERATION_NAMES = [
37
+ "system.migrate",
36
38
  "artifact.create",
37
39
  "artifact.query",
38
40
  "artifact.show",
@@ -46,13 +48,17 @@ export const EXPECTED_OPERATION_NAMES = [
46
48
  "tasks.graph",
47
49
  "tasks.plan",
48
50
  "tasks.show",
51
+ "tasks.active",
52
+ "tasks.focus",
49
53
  "tasks.start",
54
+ "tasks.submit",
50
55
  "tasks.complete",
51
56
  "tasks.run_gates",
52
57
  "tasks.set_checklist",
53
58
  "tasks.context",
54
- "tasks.fail",
59
+ "tasks.reject",
55
60
  "tasks.retry",
61
+ "tasks.cancel",
56
62
  "tasks.depend",
57
63
  "tasks.contain",
58
64
  "docs.create",
@@ -84,6 +90,7 @@ type OperationInput = Record<string, unknown>;
84
90
  type OperationHandler = (input: OperationInput) => unknown;
85
91
 
86
92
  export class UnknownOperationError extends Error {}
93
+ export class MigrationRequiredError extends Error {}
87
94
  export class PayloadTooLargeError extends Error {}
88
95
 
89
96
  function string(input: OperationInput, key: string): string {
@@ -111,21 +118,34 @@ function normalizeCreateInput(input: OperationInput): CreateArtifactInput {
111
118
  return { ...rest, templateId: typeof template_id === "string" ? template_id : undefined } as CreateArtifactInput;
112
119
  }
113
120
 
121
+ export interface SchemaState {
122
+ current: number;
123
+ required: number;
124
+ migrationRequired: boolean;
125
+ }
126
+
114
127
  export interface PapyrusService {
115
128
  operationNames(): OperationName[];
129
+ schemaState(): SchemaState;
116
130
  execute(operation: string, input?: OperationInput): Promise<unknown>;
117
131
  checkpoint(): void;
118
132
  optimize(): void;
119
133
  close(): void;
120
134
  }
121
135
 
122
- function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Record<OperationName, OperationHandler> {
136
+ function handlers(
137
+ artifacts: ArtifactStore,
138
+ gates: GateRunner,
139
+ tasks: Tasks,
140
+ migrate: () => unknown,
141
+ ): Record<OperationName, OperationHandler> {
123
142
  const taskFilter = (input: OperationInput) => ({
124
143
  status: optionalString(input, "status"),
125
144
  text: optionalString(input, "text"),
126
145
  limit: optionalNumber(input, "limit"),
127
146
  });
128
147
  return {
148
+ "system.migrate": () => migrate(),
129
149
  "artifact.create": (input) => artifacts.create(normalizeCreateInput(input)),
130
150
  "artifact.query": (input) => artifacts.query(input),
131
151
  "artifact.show": (input) => artifacts.get(string(input, "id"), {
@@ -156,7 +176,7 @@ function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Re
156
176
  "tasks.create": (input) => tasks.create({
157
177
  title: string(input, "title"),
158
178
  body: optionalString(input, "body"),
159
- status: optionalString(input, "status") as "pending" | "active" | "done" | "failed" | undefined,
179
+ status: optionalString(input, "status") as TaskStatus | undefined,
160
180
  labels: input["labels"] as string[] | undefined,
161
181
  extra: input["extra"] as Record<string, unknown> | undefined,
162
182
  gates: input["gates"] as Parameters<Tasks["create"]>[0]["gates"],
@@ -169,13 +189,17 @@ function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Re
169
189
  "tasks.graph": (input) => tasks.graph(taskFilter(input)),
170
190
  "tasks.plan": (input) => projectTaskExecution(tasks.graph(taskFilter(input))),
171
191
  "tasks.show": (input) => tasks.show(string(input, "id")),
192
+ "tasks.active": () => tasks.active(),
193
+ "tasks.focus": (input) => tasks.focus(string(input, "id")),
172
194
  "tasks.start": (input) => tasks.transition(string(input, "id"), "start"),
195
+ "tasks.submit": (input) => tasks.transition(string(input, "id"), "submit"),
173
196
  "tasks.complete": (input) => tasks.completeAsync(string(input, "id")),
174
197
  "tasks.run_gates": (input) => tasks.runGates(string(input, "id")),
175
198
  "tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
176
- "tasks.context": () => taskContext(artifacts),
177
- "tasks.fail": (input) => tasks.transition(string(input, "id"), "fail"),
199
+ "tasks.context": () => taskContext(artifacts, tasks.active()?.id),
200
+ "tasks.reject": (input) => tasks.transition(string(input, "id"), "reject"),
178
201
  "tasks.retry": (input) => tasks.transition(string(input, "id"), "retry"),
202
+ "tasks.cancel": (input) => tasks.transition(string(input, "id"), "cancel"),
179
203
  "tasks.depend": (input) => tasks.depend(string(input, "id"), string(input, "dependency_id")),
180
204
  "tasks.contain": (input) => tasks.contain(string(input, "parent_id"), string(input, "child_id")),
181
205
  "docs.create": (input) => createDocument(artifacts, {
@@ -223,13 +247,22 @@ export function createPapyrusService(path: string): PapyrusService {
223
247
  const db = openDb(path);
224
248
  const artifacts = new SQLiteArtifactStore(db);
225
249
  const gates = new SQLiteGateRunner(db);
226
- const tasks = new Tasks(artifacts, gates);
227
- const registry = handlers(artifacts, gates, tasks);
250
+ const focus = new SQLiteTaskFocusStore(db);
251
+ const tasks = new Tasks(artifacts, gates, focus);
252
+ const registry = handlers(artifacts, gates, tasks, () => migrateDb(db));
253
+ const state = (): SchemaState => {
254
+ const current = schemaVersion(db);
255
+ return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
256
+ };
228
257
  return {
229
258
  operationNames: () => [...EXPECTED_OPERATION_NAMES],
259
+ schemaState: state,
230
260
  async execute(operation, input = {}) {
231
261
  const handler = registry[operation as OperationName];
232
262
  if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
263
+ if (operation !== "system.migrate" && state().migrationRequired) {
264
+ throw new MigrationRequiredError("database migration required; run `papyrus migrate task-lifecycle`");
265
+ }
233
266
  return handler(input);
234
267
  },
235
268
  checkpoint: () => { db.exec("PRAGMA wal_checkpoint(PASSIVE)"); },
@@ -278,7 +311,7 @@ export function createApp(deps: { service: PapyrusService; token: string }): { f
278
311
  }
279
312
  const url = new URL(request.url);
280
313
  if (request.method === "GET" && url.pathname === "/health") {
281
- return json({ ok: true, version: VERSION });
314
+ return json({ ok: true, version: VERSION, schema: deps.service.schemaState() });
282
315
  }
283
316
  if (request.method === "GET" && url.pathname === "/api/v1/ops") {
284
317
  return json({ operations: deps.service.operationNames() });
@@ -1,8 +1,8 @@
1
1
  import type { Artifact } from "./domain/artifact.ts";
2
2
  import type { ArtifactStore } from "./ports/artifact-store.ts";
3
3
  import {
4
- TASK_CONTEXT_ACTIVE_LIMIT,
5
- TASK_CONTEXT_FAILED_LIMIT,
4
+ TASK_CONTEXT_CURRENT_LIMIT,
5
+ TASK_CONTEXT_REJECTED_LIMIT,
6
6
  TASK_RECONCILIATION_INSTRUCTION,
7
7
  } from "./constants.ts";
8
8
 
@@ -34,19 +34,20 @@ function renderCurrent(task: Artifact): string[] {
34
34
  ];
35
35
  }
36
36
 
37
- export function taskContext(artifacts: ArtifactStore): string | null {
37
+ export function taskContext(artifacts: ArtifactStore, activeTaskId?: string): string | null {
38
38
  const tasks = artifacts.query({ kind: "task" }).sort((left, right) => left.updated_at.localeCompare(right.updated_at));
39
- const open = tasks.filter((task) => task.status !== "done");
39
+ const open = tasks.filter((task) => task.status !== "done" && task.status !== "canceled");
40
40
  if (open.length === 0) return null;
41
41
 
42
42
  const done = tasks.length - open.length;
43
- const active = open.filter((task) => task.status === "active").slice(0, TASK_CONTEXT_ACTIVE_LIMIT);
44
- const next = open.find((task) => task.status === "pending");
45
- const failed = open.filter((task) => task.status === "failed").slice(0, TASK_CONTEXT_FAILED_LIMIT);
43
+ const active = activeTaskId ? open.find((task) => task.id === activeTaskId) : undefined;
44
+ const current = active ? [active] : open.filter((task) => task.status === "in-progress" || task.status === "review").slice(0, TASK_CONTEXT_CURRENT_LIMIT);
45
+ const next = open.find((task) => task.status === "todo");
46
+ const rejected = open.filter((task) => task.status === "rejected").slice(0, TASK_CONTEXT_REJECTED_LIMIT);
46
47
  const lines = [`Progress: ${done}/${tasks.length} done`];
47
- for (const task of active) lines.push(...renderCurrent(task));
48
+ for (const task of current) lines.push(...renderCurrent(task));
48
49
  if (next) lines.push(`Next: ${next.title} (${next.id})`);
49
- if (failed.length > 0) lines.push(`Blocked: ${failed.map((task) => `${task.title} (${task.id})`).join(", ")}`);
50
+ if (rejected.length > 0) lines.push(`Rejected: ${rejected.map((task) => `${task.title} (${task.id})`).join(", ")}`);
50
51
  lines.push("", TASK_RECONCILIATION_INSTRUCTION);
51
52
  return lines.join("\n");
52
53
  }
@@ -1,12 +1,22 @@
1
1
  import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
2
2
  import type { TaskGraph } from "./task-service.ts";
3
3
 
4
- export type TaskExecutionState = "done" | "active" | "ready" | "blocked" | "failed" | "invalid";
4
+ export type TaskExecutionState =
5
+ | "todo"
6
+ | "in-progress"
7
+ | "review"
8
+ | "rejected"
9
+ | "done"
10
+ | "canceled"
11
+ | "ready"
12
+ | "blocked"
13
+ | "invalid";
5
14
 
6
15
  export interface TaskExecutionNode {
7
16
  id: string;
8
17
  title: string;
9
18
  status: string;
19
+ active: boolean;
10
20
  state: TaskExecutionState;
11
21
  layer: number | null;
12
22
  prerequisiteIds: string[];
@@ -21,8 +31,10 @@ export interface TaskExecutionPlan {
21
31
 
22
32
  function executionState(status: string, invalid: boolean, prerequisitesDone: boolean): TaskExecutionState {
23
33
  if (invalid) return "invalid";
24
- if (status === "done" || status === "active" || status === "failed") return status;
25
- if (status === "pending" && prerequisitesDone) return "ready";
34
+ if (status === "todo") return prerequisitesDone ? "ready" : "blocked";
35
+ if (["in-progress", "review", "rejected", "done", "canceled"].includes(status)) {
36
+ return status as TaskExecutionState;
37
+ }
26
38
  return "blocked";
27
39
  }
28
40
 
@@ -96,6 +108,7 @@ export function projectTaskExecution(graph: TaskGraph): TaskExecutionPlan {
96
108
  id: node.task.id,
97
109
  title: node.task.title,
98
110
  status: node.task.status,
111
+ active: node.active === true,
99
112
  state,
100
113
  layer: layerById.get(node.task.id) ?? null,
101
114
  prerequisiteIds,
@@ -5,11 +5,14 @@ import type { TaskGraph } from "./task-service.ts";
5
5
  export type TaskGraphView = "execution" | "dependencies" | "composition";
6
6
 
7
7
  const EXECUTION_GLYPHS: Record<TaskExecutionState, string> = {
8
+ todo: "○",
9
+ "in-progress": "●",
10
+ review: "◆",
11
+ rejected: "▲",
8
12
  done: "■",
9
- active: "",
13
+ canceled: "×",
10
14
  ready: "◇",
11
15
  blocked: "○",
12
- failed: "▲",
13
16
  invalid: "!",
14
17
  };
15
18
 
@@ -39,7 +42,7 @@ export function projectTaskGraph(graph: TaskGraph, view: TaskGraphView): Display
39
42
  const nodes = view === "execution"
40
43
  ? projectTaskExecution(graph).nodes.map((node) => ({
41
44
  id: node.id,
42
- label: `${EXECUTION_GLYPHS[node.state]} ${node.title} · ${node.layer === null ? "no layer" : `layer ${node.layer + 1}`} · ${node.state}`,
45
+ label: `${node.active ? "▶ " : ""}${EXECUTION_GLYPHS[node.state]} ${node.title} · ${node.layer === null ? "no layer" : `layer ${node.layer + 1}`} · ${node.state}`,
43
46
  status: node.state,
44
47
  }))
45
48
  : graph.nodes
@@ -1,9 +1,10 @@
1
1
  import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
2
2
  import type { Artifact } from "./domain/artifact.ts";
3
- import { validateChecklist, type Checklist } from "./domain/checklist.ts";
3
+ import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
4
4
  import type { Gate, GateResult } from "./domain/gate.ts";
5
5
  import type { ArtifactStore } from "./ports/artifact-store.ts";
6
6
  import type { GateRunner } from "./ports/gate-runner.ts";
7
+ import { InMemoryTaskFocusStore, type TaskFocusStore } from "./ports/task-focus-store.ts";
7
8
  import { assertDependencyEdgeAllowed } from "./task-execution.ts";
8
9
 
9
10
  export interface TaskFilter {
@@ -12,10 +13,12 @@ export interface TaskFilter {
12
13
  limit?: number;
13
14
  }
14
15
 
16
+ export type TaskStatus = "todo" | "in-progress" | "review" | "rejected" | "done" | "canceled";
17
+
15
18
  export interface CreateTaskInput {
16
19
  title: string;
17
20
  body?: string;
18
- status?: "pending" | "active" | "done" | "failed";
21
+ status?: TaskStatus;
19
22
  labels?: string[];
20
23
  extra?: Record<string, unknown>;
21
24
  gates?: Gate[];
@@ -25,23 +28,32 @@ export interface CreateTaskInput {
25
28
  dependsOn?: string[];
26
29
  }
27
30
 
28
- export type TaskTransition = "start" | "fail" | "retry";
31
+ export type TaskTransition = "start" | "submit" | "reject" | "retry" | "cancel";
29
32
 
30
33
  export interface TaskBlockage {
31
34
  artifact: Artifact;
32
35
  dependencyIds: string[];
33
36
  }
34
37
 
38
+ export interface ChecklistReview {
39
+ item: string;
40
+ proof: ProofReference[];
41
+ accepted: boolean;
42
+ reason?: string;
43
+ }
44
+
35
45
  export interface TaskCompletion {
36
46
  artifact: Artifact;
37
47
  gates: GateResult[];
48
+ checklist: ChecklistReview[];
38
49
  completed: boolean;
39
- started: Artifact[];
50
+ focused: Artifact | null;
40
51
  blocked: TaskBlockage[];
41
52
  }
42
53
 
43
54
  export interface TaskNode {
44
55
  task: Artifact;
56
+ active?: boolean;
45
57
  parentIds: string[];
46
58
  childIds: string[];
47
59
  dependencyIds: string[];
@@ -52,16 +64,19 @@ export interface TaskGraph {
52
64
  rootIds: string[];
53
65
  }
54
66
 
55
- const TASK_TRANSITIONS: Record<TaskTransition, { from: string[]; to: string }> = {
56
- start: { from: ["pending"], to: "active" },
57
- fail: { from: ["pending", "active"], to: "failed" },
58
- retry: { from: ["failed"], to: "pending" },
67
+ const TASK_TRANSITIONS: Record<TaskTransition, { from: TaskStatus[]; to: TaskStatus }> = {
68
+ start: { from: ["todo"], to: "in-progress" },
69
+ submit: { from: ["in-progress"], to: "review" },
70
+ reject: { from: ["review"], to: "rejected" },
71
+ retry: { from: ["rejected"], to: "in-progress" },
72
+ cancel: { from: ["todo", "in-progress", "review", "rejected"], to: "canceled" },
59
73
  };
60
74
 
61
75
  export class Tasks {
62
76
  constructor(
63
77
  private readonly artifacts: ArtifactStore,
64
78
  private readonly gates: GateRunner,
79
+ private readonly focusStore: TaskFocusStore = new InMemoryTaskFocusStore(),
65
80
  ) {}
66
81
 
67
82
  private require(id: string): Artifact {
@@ -108,8 +123,10 @@ export class Tasks {
108
123
  throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
109
124
  }
110
125
  const byId = new Map(tasks.map((task) => [task.id, task]));
126
+ const focusedId = this.focusStore.get();
111
127
  const nodes = new Map(tasks.map((task) => [task.id, {
112
128
  task,
129
+ active: task.id === focusedId,
113
130
  parentIds: [] as string[],
114
131
  childIds: [] as string[],
115
132
  dependencyIds: [] as string[],
@@ -148,34 +165,63 @@ export class Tasks {
148
165
  return this.artifacts.get(id, { tree: true })!;
149
166
  }
150
167
 
168
+ active(): Artifact | null {
169
+ const id = this.focusStore.get();
170
+ if (!id) return null;
171
+ const task = this.artifacts.get(id);
172
+ if (!task || task.kind !== "task" || task.status === "done" || task.status === "canceled") {
173
+ this.focusStore.clear(id);
174
+ return null;
175
+ }
176
+ return task;
177
+ }
178
+
179
+ focus(id: string): Artifact {
180
+ const task = this.require(id);
181
+ if (task.status === "done" || task.status === "canceled") {
182
+ throw new Error(`cannot focus task from ${task.status}`);
183
+ }
184
+ this.focusStore.set(id);
185
+ return task;
186
+ }
187
+
151
188
  transition(id: string, action: TaskTransition): Artifact {
152
189
  const task = this.require(id);
153
190
  const transition = TASK_TRANSITIONS[action];
154
- if (!transition.from.includes(task.status)) throw new Error(`cannot ${action} task from ${task.status}`);
191
+ if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
155
192
  if (action === "start") {
156
193
  const blocking = this.dependencyIds(id).filter((dependencyId) => this.require(dependencyId).status !== "done");
157
194
  if (blocking.length > 0) throw new Error(`task "${id}" is blocked by dependencies: ${blocking.join(", ")}`);
195
+ this.focusStore.set(id);
158
196
  }
159
- return this.artifacts.setStatus(id, transition.to)!;
197
+ const updated = this.artifacts.setStatus(id, transition.to)!;
198
+ if (action === "start" || action === "retry") this.propagateProgressToAncestors(id);
199
+ if (action === "retry") this.focusStore.set(id);
200
+ if (action === "cancel") this.focusStore.clear(id);
201
+ return updated;
160
202
  }
161
203
 
162
204
  complete(id: string): TaskCompletion {
163
- const task = this.requireActive(id);
205
+ const task = this.requireReview(id);
206
+ const checklist = this.reviewChecklist(task);
164
207
  const results = this.gates.run(id);
165
- if (results.some((gate) => !gate.passed)) {
166
- return { artifact: task, gates: results, completed: false, started: [], blocked: [] };
208
+ if (results.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted)) {
209
+ const artifact = this.artifacts.setStatus(id, "rejected")!;
210
+ return { artifact, gates: results, checklist, completed: false, focused: this.active(), blocked: [] };
167
211
  }
168
- return this.finish(id, results);
212
+ return this.finish(id, results, checklist);
169
213
  }
170
214
 
171
215
  async completeAsync(id: string): Promise<TaskCompletion> {
172
- this.requireActive(id);
216
+ const task = this.requireReview(id);
217
+ const checklist = this.reviewChecklist(task);
173
218
  const results = await this.gates.runAsync(id);
174
- if (results.some((gate) => !gate.passed)) {
175
- return { artifact: this.require(id), gates: results, completed: false, started: [], blocked: [] };
219
+ if (results.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted)) {
220
+ const artifact = this.artifacts.setStatus(id, "rejected")!;
221
+ return { artifact, gates: results, checklist, completed: false, focused: this.active(), blocked: [] };
176
222
  }
177
- const current = this.requireActive(id);
178
- return this.finish(current.id, results);
223
+ const current = this.requireReview(id);
224
+ return this.finish(current.id, results, checklist);
179
225
  }
180
226
 
181
227
  runGates(id: string): Promise<GateResult[]> {
@@ -226,6 +272,39 @@ export class Tasks {
226
272
  return relationships;
227
273
  }
228
274
 
275
+ private parentIds(id: string): string[] {
276
+ return this.relationships(id)
277
+ .flatMap((edge) => {
278
+ if (edge.relation === "part_of" && edge.from === id) return [edge.to];
279
+ if (edge.relation === "contains" && edge.to === id) return [edge.from];
280
+ return [];
281
+ })
282
+ .filter((parentId, index, ids) => ids.indexOf(parentId) === index);
283
+ }
284
+
285
+ private propagateProgressToAncestors(id: string): void {
286
+ const pending = this.parentIds(id);
287
+ const visited = new Set<string>();
288
+ while (pending.length > 0) {
289
+ const parentId = pending.shift()!;
290
+ if (visited.has(parentId)) continue;
291
+ if (visited.size >= TASK_EXECUTION_MAX_NODES) throw new Error("task ancestry exceeds execution node bound");
292
+ visited.add(parentId);
293
+ const parent = this.require(parentId);
294
+ if (parent.status === "todo") this.artifacts.setStatus(parentId, "in-progress");
295
+ pending.push(...this.parentIds(parentId));
296
+ }
297
+ }
298
+
299
+ private reviewChecklist(task: Artifact): ChecklistReview[] {
300
+ return checklistEntries(task.extra["checklist"]).map((entry) => ({
301
+ item: entry.item,
302
+ proof: entry.proof,
303
+ accepted: !entry.legacy && entry.proof.length > 0,
304
+ ...((entry.legacy || entry.proof.length === 0) ? { reason: "typed proof reference required" } : {}),
305
+ }));
306
+ }
307
+
229
308
  private dependencyIds(id: string): string[] {
230
309
  const ids = this.relationships(id)
231
310
  .filter((edge) => edge.relation === "depends_on" && edge.from === id)
@@ -236,7 +315,7 @@ export class Tasks {
236
315
  return ids;
237
316
  }
238
317
 
239
- private finish(id: string, gates: GateResult[]): TaskCompletion {
318
+ private finish(id: string, gates: GateResult[], checklist: ChecklistReview[]): TaskCompletion {
240
319
  const successorIds = this.relationships(id)
241
320
  .filter((edge) => edge.relation === "depends_on" && edge.to === id)
242
321
  .map((edge) => edge.from);
@@ -244,25 +323,29 @@ export class Tasks {
244
323
  throw new Error(`task "${id}" exceeds ${TASK_EXECUTION_MAX_DEGREE} successors`);
245
324
  }
246
325
  const artifact = this.artifacts.setStatus(id, "done")!;
247
- const started: Artifact[] = [];
326
+ this.focusStore.clear(id);
248
327
  const blocked: TaskBlockage[] = [];
249
- for (const successorId of successorIds) {
328
+ let focused: Artifact | null = null;
329
+ for (const successorId of [...successorIds].sort()) {
250
330
  const successor = this.require(successorId);
251
- if (successor.status !== "pending") continue;
331
+ if (successor.status === "done" || successor.status === "canceled") continue;
252
332
  const dependencyIds = this.dependencyIds(successorId)
253
333
  .filter((dependencyId) => this.require(dependencyId).status !== "done");
254
334
  if (dependencyIds.length > 0) {
255
335
  blocked.push({ artifact: successor, dependencyIds });
256
336
  continue;
257
337
  }
258
- started.push(this.artifacts.setStatus(successorId, "active")!);
338
+ if (!focused) {
339
+ this.focusStore.set(successor.id);
340
+ focused = successor;
341
+ }
259
342
  }
260
- return { artifact, gates, completed: true, started, blocked };
343
+ return { artifact, gates, checklist, completed: true, focused, blocked };
261
344
  }
262
345
 
263
- private requireActive(id: string): Artifact {
346
+ private requireReview(id: string): Artifact {
264
347
  const task = this.require(id);
265
- if (task.status !== "active") throw new Error(`cannot complete task from ${task.status}`);
348
+ if (task.status !== "review") throw new Error(`cannot complete task from ${task.status}`);
266
349
  return task;
267
350
  }
268
351
  }