@danypops/papyrus 0.2.1 → 0.4.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.
@@ -0,0 +1,204 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SKILL_MAX_RENDERED_BYTES, SKILL_RUN_ID_MAX_LENGTH, TASK_EXECUTION_MAX_EDGES } from "./constants.ts";
3
+ import type { Artifact } from "./domain/artifact.ts";
4
+ import { validateChecklist } from "./domain/checklist.ts";
5
+ import {
6
+ resolveSkillArguments,
7
+ validateSkillDefinition,
8
+ type SkillArgumentValue,
9
+ type SkillDefinition,
10
+ } from "./domain/skill-definition.ts";
11
+ import type { ArtifactStore } from "./ports/artifact-store.ts";
12
+ import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
13
+ import { projectTaskExecution, type TaskExecutionPlan } from "./task-execution.ts";
14
+ import type { TaskGraph, TaskNode } from "./task-service.ts";
15
+
16
+ const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
17
+ const EXACT_PLACEHOLDER_PATTERN = /^{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}$/;
18
+ const PLACEHOLDER_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}/g;
19
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
20
+
21
+ export interface InstantiateSkillWorkflowInput {
22
+ runId?: string;
23
+ arguments?: Record<string, unknown>;
24
+ }
25
+
26
+ export interface SkillWorkflowRunResult {
27
+ skillId: string;
28
+ runId: string;
29
+ arguments: Record<string, SkillArgumentValue>;
30
+ created: {
31
+ docs: string[];
32
+ rules: string[];
33
+ tasks: string[];
34
+ };
35
+ rootTaskIds: string[];
36
+ execution: TaskExecutionPlan;
37
+ }
38
+
39
+ function requireWorkflowSkill(artifacts: ArtifactStore, skillId: string): { skill: Artifact; definition: SkillDefinition } {
40
+ const skill = artifacts.get(skillId);
41
+ if (!skill) throw new Error(`skill artifact "${skillId}" not found`);
42
+ if (skill.kind !== "skill" || skill.subtype !== "workflow") {
43
+ throw new Error(`artifact "${skillId}" is not a workflow Skill`);
44
+ }
45
+ if (skill.status !== "active") throw new Error(`cannot run workflow Skill from ${skill.status}`);
46
+ return { skill, definition: validateSkillDefinition(skill.extra["definition"]) };
47
+ }
48
+
49
+ function normalizeRunId(skillId: string, requested: string | undefined): string {
50
+ const runId = requested ?? `${skillId.slice(0, 40)}-${randomUUID().replaceAll("-", "").slice(0, 12)}`;
51
+ if (runId.length > SKILL_RUN_ID_MAX_LENGTH || !RUN_ID_PATTERN.test(runId)) {
52
+ throw new Error(`skill run id must match ${RUN_ID_PATTERN} and contain at most ${SKILL_RUN_ID_MAX_LENGTH} characters`);
53
+ }
54
+ return runId;
55
+ }
56
+
57
+ function renderValue(value: unknown, arguments_: Record<string, SkillArgumentValue>): unknown {
58
+ if (typeof value === "string") {
59
+ const exact = value.match(EXACT_PLACEHOLDER_PATTERN);
60
+ if (exact) {
61
+ const name = exact[1]!;
62
+ if (!(name in arguments_)) throw new Error(`skill input placeholder "${name}" has no argument value`);
63
+ return arguments_[name]!;
64
+ }
65
+ return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => {
66
+ if (!(name in arguments_)) throw new Error(`skill input placeholder "${name}" has no argument value`);
67
+ return String(arguments_[name]!);
68
+ });
69
+ }
70
+ if (Array.isArray(value)) return value.map((entry) => renderValue(entry, arguments_));
71
+ if (typeof value !== "object" || value === null) return value;
72
+ const rendered: Record<string, unknown> = {};
73
+ for (const [key, entry] of Object.entries(value)) {
74
+ if (UNSAFE_KEYS.has(key)) throw new Error(`unsafe skill blueprint key "${key}"`);
75
+ rendered[key] = renderValue(entry, arguments_);
76
+ }
77
+ return rendered;
78
+ }
79
+
80
+ function renderDefinition(definition: SkillDefinition, arguments_: Record<string, SkillArgumentValue>): SkillDefinition {
81
+ const rendered = renderValue(definition, arguments_) as SkillDefinition;
82
+ const bytes = new TextEncoder().encode(JSON.stringify(rendered)).byteLength;
83
+ if (bytes > SKILL_MAX_RENDERED_BYTES) throw new Error(`rendered skill workflow exceeds ${SKILL_MAX_RENDERED_BYTES} bytes`);
84
+ for (const task of rendered.blueprints.tasks) {
85
+ if (task.extra?.["checklist"] !== undefined) {
86
+ task.extra["checklist"] = validateChecklist(task.extra["checklist"]);
87
+ }
88
+ }
89
+ return validateSkillDefinition(rendered);
90
+ }
91
+
92
+ function withRunLabel(labels: string[] | undefined, runId: string): string[] {
93
+ return [...new Set([...(labels ?? []), `skill-run:${runId}`])];
94
+ }
95
+
96
+ function executionGraph(tasks: Artifact[], definition: SkillDefinition, ids: Map<string, string>): TaskGraph {
97
+ const byRef = new Map(definition.blueprints.tasks.map((task) => [task.ref, task]));
98
+ const nodes: TaskNode[] = tasks.map((task) => {
99
+ const ref = task.extra["skillRun"] && typeof task.extra["skillRun"] === "object"
100
+ ? (task.extra["skillRun"] as Record<string, unknown>)["ref"] as string
101
+ : "";
102
+ const blueprint = byRef.get(ref)!;
103
+ return {
104
+ task,
105
+ active: false,
106
+ parentIds: blueprint.parent ? [ids.get(blueprint.parent)!] : [],
107
+ childIds: definition.blueprints.tasks.filter((candidate) => candidate.parent === ref).map((candidate) => ids.get(candidate.ref)!),
108
+ dependencyIds: (blueprint.dependsOn ?? []).map((dependency) => ids.get(dependency)!),
109
+ };
110
+ });
111
+ return { nodes, rootIds: nodes.filter((node) => node.parentIds.length === 0).map((node) => node.task.id) };
112
+ }
113
+
114
+ export function instantiateSkillWorkflow(
115
+ artifacts: ArtifactStore,
116
+ skillId: string,
117
+ input: InstantiateSkillWorkflowInput = {},
118
+ ): SkillWorkflowRunResult {
119
+ const { definition } = requireWorkflowSkill(artifacts, skillId);
120
+ const arguments_ = resolveSkillArguments(definition, input.arguments);
121
+ const rendered = renderDefinition(definition, arguments_);
122
+ const runId = normalizeRunId(skillId, input.runId);
123
+ const refs = [
124
+ ...rendered.blueprints.docs.map(({ ref }) => ref),
125
+ ...rendered.blueprints.rules.map(({ ref }) => ref),
126
+ ...rendered.blueprints.tasks.map(({ ref }) => ref),
127
+ ];
128
+ const ids = new Map(refs.map((ref) => [ref, `${runId}-${ref}`]));
129
+ const taskIds = rendered.blueprints.tasks.map(({ ref }) => ids.get(ref)!);
130
+ const rootTaskIds = rendered.blueprints.tasks
131
+ .filter((task) => (task.dependsOn?.length ?? 0) === 0)
132
+ .map((task) => ids.get(task.ref)!);
133
+ const relationshipCount = rendered.links.length
134
+ + rendered.blueprints.tasks.reduce((count, task) => count + (task.dependsOn?.length ?? 0) + (task.parent ? 2 : 0), 0)
135
+ + rootTaskIds.length;
136
+ if (relationshipCount > TASK_EXECUTION_MAX_EDGES) {
137
+ throw new Error(`skill workflow run exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
138
+ }
139
+
140
+ const atomic = requireAtomicArtifactStore(artifacts);
141
+ return atomic.atomic(() => {
142
+ const docs = rendered.blueprints.docs.map((blueprint) => artifacts.create({
143
+ id: ids.get(blueprint.ref),
144
+ kind: "doc",
145
+ title: blueprint.title,
146
+ body: blueprint.body,
147
+ subtype: blueprint.subtype,
148
+ labels: withRunLabel(blueprint.labels, runId),
149
+ extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
150
+ }));
151
+ const rules = rendered.blueprints.rules.map((blueprint) => artifacts.create({
152
+ id: ids.get(blueprint.ref),
153
+ kind: "rule",
154
+ title: blueprint.title,
155
+ body: blueprint.body,
156
+ labels: withRunLabel(blueprint.labels, runId),
157
+ extra: {
158
+ ...(blueprint.extra ?? {}),
159
+ ...(blueprint.condition ? { condition: blueprint.condition } : {}),
160
+ ...(blueprint.action ? { action: blueprint.action } : {}),
161
+ ...(blueprint.severity ? { severity: blueprint.severity } : {}),
162
+ skillRun: { id: runId, skillId, ref: blueprint.ref },
163
+ scope: { type: "skill-run", runId, taskIds },
164
+ },
165
+ }));
166
+ const tasks = rendered.blueprints.tasks.map((blueprint) => artifacts.create({
167
+ id: ids.get(blueprint.ref),
168
+ kind: "task",
169
+ title: blueprint.title,
170
+ body: blueprint.body,
171
+ labels: withRunLabel(blueprint.labels, runId),
172
+ extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
173
+ }));
174
+
175
+ for (const blueprint of rendered.blueprints.tasks) {
176
+ const id = ids.get(blueprint.ref)!;
177
+ for (const dependency of blueprint.dependsOn ?? []) {
178
+ artifacts.link({ from: id, relation: "depends_on", to: ids.get(dependency)! });
179
+ }
180
+ if (blueprint.parent) {
181
+ const parentId = ids.get(blueprint.parent)!;
182
+ artifacts.link({ from: parentId, relation: "contains", to: id });
183
+ artifacts.link({ from: id, relation: "part_of", to: parentId });
184
+ }
185
+ }
186
+ for (const link of rendered.links) {
187
+ artifacts.link({ from: ids.get(link.from)!, relation: link.relation, to: ids.get(link.to)! });
188
+ }
189
+ for (const rootTaskId of rootTaskIds) artifacts.link({ from: skillId, relation: "triggers", to: rootTaskId });
190
+
191
+ return {
192
+ skillId,
193
+ runId,
194
+ arguments: arguments_,
195
+ created: {
196
+ docs: docs.map(({ id }) => id),
197
+ rules: rules.map(({ id }) => id),
198
+ tasks: tasks.map(({ id }) => id),
199
+ },
200
+ rootTaskIds,
201
+ execution: projectTaskExecution(executionGraph(tasks, rendered, ids)),
202
+ };
203
+ });
204
+ }
@@ -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
  }