@danypops/papyrus 0.4.0 → 0.6.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.
@@ -9,9 +9,11 @@ import {
9
9
  type SkillDefinition,
10
10
  } from "./domain/skill-definition.ts";
11
11
  import type { ArtifactStore } from "./ports/artifact-store.ts";
12
+ import type { TaskEventContext } from "./domain/task-event.ts";
13
+ import type { TaskEventStore } from "./ports/task-event-store.ts";
12
14
  import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
13
15
  import { projectTaskExecution, type TaskExecutionPlan } from "./task-execution.ts";
14
- import type { TaskGraph, TaskNode } from "./task-service.ts";
16
+ import type { TaskGraph, TaskNode, TaskStatus } from "./task-service.ts";
15
17
 
16
18
  const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
17
19
  const EXACT_PLACEHOLDER_PATTERN = /^{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}$/;
@@ -115,6 +117,7 @@ export function instantiateSkillWorkflow(
115
117
  artifacts: ArtifactStore,
116
118
  skillId: string,
117
119
  input: InstantiateSkillWorkflowInput = {},
120
+ history?: { events: TaskEventStore; context?: TaskEventContext },
118
121
  ): SkillWorkflowRunResult {
119
122
  const { definition } = requireWorkflowSkill(artifacts, skillId);
120
123
  const arguments_ = resolveSkillArguments(definition, input.arguments);
@@ -138,7 +141,7 @@ export function instantiateSkillWorkflow(
138
141
  }
139
142
 
140
143
  const atomic = requireAtomicArtifactStore(artifacts);
141
- return atomic.atomic(() => {
144
+ const persist = () => atomic.atomic(() => {
142
145
  const docs = rendered.blueprints.docs.map((blueprint) => artifacts.create({
143
146
  id: ids.get(blueprint.ref),
144
147
  kind: "doc",
@@ -163,14 +166,26 @@ export function instantiateSkillWorkflow(
163
166
  scope: { type: "skill-run", runId, taskIds },
164
167
  },
165
168
  }));
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
- }));
169
+ const tasks = rendered.blueprints.tasks.map((blueprint) => {
170
+ const task = artifacts.create({
171
+ id: ids.get(blueprint.ref),
172
+ kind: "task",
173
+ title: blueprint.title,
174
+ body: blueprint.body,
175
+ labels: withRunLabel(blueprint.labels, runId),
176
+ extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
177
+ });
178
+ if (history) history.events.append({
179
+ taskId: task.id,
180
+ type: "created",
181
+ actor: history.context?.actor ?? "system",
182
+ source: history.context?.source ?? "skill-run",
183
+ toStatus: task.status as TaskStatus,
184
+ ...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
185
+ ...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
186
+ });
187
+ return task;
188
+ });
174
189
 
175
190
  for (const blueprint of rendered.blueprints.tasks) {
176
191
  const id = ids.get(blueprint.ref)!;
@@ -201,4 +216,5 @@ export function instantiateSkillWorkflow(
201
216
  execution: projectTaskExecution(executionGraph(tasks, rendered, ids)),
202
217
  };
203
218
  });
219
+ return history ? history.events.atomic(persist) : persist();
204
220
  }
@@ -0,0 +1,188 @@
1
+ import {
2
+ TASK_AUTOMATION_ERROR_ID_MAX_LENGTH,
3
+ TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH,
4
+ TASK_AUTOMATION_GATE_CONCURRENCY,
5
+ TASK_AUTOMATION_HARD_MAX_RUNTIME_MS,
6
+ TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP,
7
+ TASK_AUTOMATION_INTERVAL_MS,
8
+ TASK_AUTOMATION_MAX_CANDIDATE_SCAN,
9
+ TASK_AUTOMATION_MAX_GATE_CONCURRENCY,
10
+ TASK_AUTOMATION_MAX_INTERVAL_MS,
11
+ TASK_AUTOMATION_MAX_RUNTIME_MS,
12
+ TASK_AUTOMATION_MAX_TASKS_PER_SWEEP,
13
+ TASK_AUTOMATION_MIN_INTERVAL_MS,
14
+ } from "./constants.ts";
15
+ import type { Artifact } from "./domain/artifact.ts";
16
+ import { projectTaskExecution } from "./task-execution.ts";
17
+ import type { Tasks } from "./task-service.ts";
18
+
19
+ export interface TaskAutomationSettings {
20
+ enabled: boolean;
21
+ intervalMs: number;
22
+ maxTasksPerSweep: number;
23
+ gateConcurrency: number;
24
+ maxRuntimeMs: number;
25
+ }
26
+
27
+ export interface TaskAutomationResult {
28
+ skipped?: "disabled" | "in-flight";
29
+ examined: number;
30
+ completed: number;
31
+ rejected: number;
32
+ started: number;
33
+ errors: Array<{ taskId: string; message: string }>;
34
+ timedOut: boolean;
35
+ }
36
+
37
+ function boundedInteger(
38
+ env: Record<string, string | undefined>,
39
+ name: string,
40
+ fallback: number,
41
+ minimum: number,
42
+ maximum: number,
43
+ ): number {
44
+ const source = env[name];
45
+ if (source === undefined || source === "") return fallback;
46
+ const value = Number(source);
47
+ if (!Number.isInteger(value) || value < minimum || value > maximum) {
48
+ throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
49
+ }
50
+ return value;
51
+ }
52
+
53
+ export function taskAutomationSettings(env: Record<string, string | undefined> = process.env): TaskAutomationSettings {
54
+ const enabled = env["PAPYRUS_AUTOMATION_ENABLED"] === "1";
55
+ if (env["PAPYRUS_AUTOMATION_ENABLED"] !== undefined && env["PAPYRUS_AUTOMATION_ENABLED"] !== "0" && !enabled) {
56
+ throw new Error("PAPYRUS_AUTOMATION_ENABLED must be 0 or 1");
57
+ }
58
+ return {
59
+ enabled,
60
+ intervalMs: boundedInteger(env, "PAPYRUS_AUTOMATION_INTERVAL_MS", TASK_AUTOMATION_INTERVAL_MS, TASK_AUTOMATION_MIN_INTERVAL_MS, TASK_AUTOMATION_MAX_INTERVAL_MS),
61
+ maxTasksPerSweep: boundedInteger(env, "PAPYRUS_AUTOMATION_MAX_TASKS", TASK_AUTOMATION_MAX_TASKS_PER_SWEEP, 1, TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP),
62
+ gateConcurrency: boundedInteger(env, "PAPYRUS_AUTOMATION_GATE_CONCURRENCY", TASK_AUTOMATION_GATE_CONCURRENCY, 1, TASK_AUTOMATION_MAX_GATE_CONCURRENCY),
63
+ maxRuntimeMs: boundedInteger(env, "PAPYRUS_AUTOMATION_MAX_RUNTIME_MS", TASK_AUTOMATION_MAX_RUNTIME_MS, 1, TASK_AUTOMATION_HARD_MAX_RUNTIME_MS),
64
+ };
65
+ }
66
+
67
+ function automationEnabled(task: Artifact): boolean {
68
+ const automation = task.extra["automation"];
69
+ return typeof automation === "object"
70
+ && automation !== null
71
+ && !Array.isArray(automation)
72
+ && (automation as Record<string, unknown>)["enabled"] === true;
73
+ }
74
+
75
+ function emptyResult(skipped?: TaskAutomationResult["skipped"]): TaskAutomationResult {
76
+ return { ...(skipped ? { skipped } : {}), examined: 0, completed: 0, rejected: 0, started: 0, errors: [], timedOut: false };
77
+ }
78
+
79
+ function boundedError(taskId: string, error: unknown): TaskAutomationResult["errors"][number] {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ return {
82
+ taskId: taskId.slice(0, TASK_AUTOMATION_ERROR_ID_MAX_LENGTH),
83
+ message: message.slice(0, TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH),
84
+ };
85
+ }
86
+
87
+ export interface TaskAutomationScheduler {
88
+ setInterval(callback: () => void, intervalMs: number): unknown;
89
+ clearInterval(handle: unknown): void;
90
+ }
91
+
92
+ const SYSTEM_SCHEDULER: TaskAutomationScheduler = {
93
+ setInterval: (callback, intervalMs) => setInterval(callback, intervalMs),
94
+ clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>),
95
+ };
96
+
97
+ export function scheduleTaskAutomation(
98
+ settings: TaskAutomationSettings,
99
+ sweep: () => Promise<unknown>,
100
+ onError: (error: unknown) => void,
101
+ scheduler: TaskAutomationScheduler = SYSTEM_SCHEDULER,
102
+ ): () => void {
103
+ if (!settings.enabled) return () => {};
104
+ const handle = scheduler.setInterval(() => { void sweep().catch(onError); }, settings.intervalMs);
105
+ return () => scheduler.clearInterval(handle);
106
+ }
107
+
108
+ export class TaskAutomationReconciler {
109
+ private inFlight = false;
110
+
111
+ constructor(
112
+ private readonly tasks: Tasks,
113
+ private readonly settings: TaskAutomationSettings,
114
+ private readonly now: () => number = () => Date.now(),
115
+ ) {}
116
+
117
+ status(): TaskAutomationSettings & { inFlight: boolean } {
118
+ return { ...this.settings, inFlight: this.inFlight };
119
+ }
120
+
121
+ async reconcile(): Promise<TaskAutomationResult> {
122
+ if (!this.settings.enabled) return emptyResult("disabled");
123
+ if (this.inFlight) return emptyResult("in-flight");
124
+ this.inFlight = true;
125
+ try { return await this.runSweep(); }
126
+ finally { this.inFlight = false; }
127
+ }
128
+
129
+ private async runSweep(): Promise<TaskAutomationResult> {
130
+ const result = emptyResult();
131
+ const deadline = this.now() + this.settings.maxRuntimeMs;
132
+ const candidates = this.tasks.list({ status: "review", limit: TASK_AUTOMATION_MAX_CANDIDATE_SCAN })
133
+ .filter(automationEnabled)
134
+ .sort((left, right) => left.id.localeCompare(right.id))
135
+ .slice(0, this.settings.maxTasksPerSweep);
136
+ const completedIds = new Set<string>();
137
+
138
+ for (let offset = 0; offset < candidates.length; offset += this.settings.gateConcurrency) {
139
+ if (this.now() >= deadline) { result.timedOut = true; break; }
140
+ const batch = candidates.slice(offset, offset + this.settings.gateConcurrency);
141
+ await Promise.all(batch.map(async (task) => {
142
+ result.examined += 1;
143
+ try {
144
+ const completion = await this.tasks.completeAsync(task.id, {
145
+ actor: "daemon",
146
+ source: "automation-reconciler",
147
+ reason: "automation-enabled review reconciliation",
148
+ }, { focusSuccessor: false, gateDeadlineMs: deadline });
149
+ if (completion.completed) {
150
+ result.completed += 1;
151
+ completedIds.add(task.id);
152
+ } else result.rejected += 1;
153
+ } catch (error) {
154
+ result.errors.push(boundedError(task.id, error));
155
+ }
156
+ }));
157
+ }
158
+
159
+ let remaining = Math.max(0, this.settings.maxTasksPerSweep - result.examined);
160
+ if (remaining > 0 && completedIds.size > 0 && this.now() < deadline) {
161
+ let graph: ReturnType<Tasks["graph"]>;
162
+ try { graph = this.tasks.graph(); }
163
+ catch (error) {
164
+ result.errors.push(boundedError("graph", error));
165
+ return result;
166
+ }
167
+ const stateById = new Map(projectTaskExecution(graph).nodes.map((node) => [node.id, node.state]));
168
+ for (const node of [...graph.nodes].sort((left, right) => left.task.id.localeCompare(right.task.id))) {
169
+ if (remaining === 0 || this.now() >= deadline) break;
170
+ if (node.task.status !== "todo" || !automationEnabled(node.task) || stateById.get(node.task.id) !== "ready") continue;
171
+ if (!node.dependencyIds.some((id) => completedIds.has(id))) continue;
172
+ try {
173
+ this.tasks.transition(node.task.id, "start", {
174
+ actor: "daemon",
175
+ source: "automation-reconciler",
176
+ reason: "automation-enabled successor became ready",
177
+ });
178
+ result.started += 1;
179
+ remaining -= 1;
180
+ } catch (error) {
181
+ result.errors.push(boundedError(node.task.id, error));
182
+ }
183
+ }
184
+ }
185
+ if (this.now() >= deadline) result.timedOut = true;
186
+ return result;
187
+ }
188
+ }
@@ -2,9 +2,11 @@ import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX
2
2
  import type { Artifact } from "./domain/artifact.ts";
3
3
  import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
4
4
  import type { Gate, GateResult } from "./domain/gate.ts";
5
+ import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
5
6
  import type { ArtifactStore } from "./ports/artifact-store.ts";
6
7
  import type { GateRunner } from "./ports/gate-runner.ts";
7
8
  import { InMemoryTaskFocusStore, type TaskFocusStore } from "./ports/task-focus-store.ts";
9
+ import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-store.ts";
8
10
  import { assertDependencyEdgeAllowed } from "./task-execution.ts";
9
11
 
10
12
  export interface TaskFilter {
@@ -13,11 +15,13 @@ export interface TaskFilter {
13
15
  limit?: number;
14
16
  }
15
17
 
16
- export type TaskStatus = "todo" | "in-progress" | "review" | "rejected" | "done" | "canceled";
18
+ export type TaskStatus = TaskLifecycleStatus;
17
19
 
18
20
  export interface CreateTaskInput {
21
+ id?: string;
19
22
  title: string;
20
23
  body?: string;
24
+ subtype?: string;
21
25
  status?: TaskStatus;
22
26
  labels?: string[];
23
27
  extra?: Record<string, unknown>;
@@ -42,6 +46,11 @@ export interface ChecklistReview {
42
46
  reason?: string;
43
47
  }
44
48
 
49
+ export interface TaskCompletionOptions {
50
+ focusSuccessor?: boolean;
51
+ gateDeadlineMs?: number;
52
+ }
53
+
45
54
  export interface TaskCompletion {
46
55
  artifact: Artifact;
47
56
  gates: GateResult[];
@@ -77,6 +86,7 @@ export class Tasks {
77
86
  private readonly artifacts: ArtifactStore,
78
87
  private readonly gates: GateRunner,
79
88
  private readonly focusStore: TaskFocusStore = new InMemoryTaskFocusStore(),
89
+ private readonly events: TaskEventStore = new InMemoryTaskEventStore(),
80
90
  ) {}
81
91
 
82
92
  private require(id: string): Artifact {
@@ -86,27 +96,32 @@ export class Tasks {
86
96
  return artifact;
87
97
  }
88
98
 
89
- create(input: CreateTaskInput): Artifact {
90
- if ((input.dependsOn?.length ?? 0) > TASK_EXECUTION_MAX_DEGREE) {
91
- throw new Error(`task cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
92
- }
93
- if (input.parentId) this.require(input.parentId);
94
- for (const dependency of input.dependsOn ?? []) this.require(dependency);
95
- const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
96
- if (input.gates !== undefined) extra["gates"] = input.gates;
97
- if (input.checklist !== undefined) extra["checklist"] = validateChecklist(input.checklist);
98
- const task = this.artifacts.create({
99
- kind: "task",
100
- title: input.title,
101
- body: input.body,
102
- status: input.status,
103
- labels: input.labels,
104
- extra,
105
- templateId: input.templateId,
99
+ create(input: CreateTaskInput, context: TaskEventContext = {}): Artifact {
100
+ return this.events.atomic(() => {
101
+ if ((input.dependsOn?.length ?? 0) > TASK_EXECUTION_MAX_DEGREE) {
102
+ throw new Error(`task cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
103
+ }
104
+ if (input.parentId) this.require(input.parentId);
105
+ for (const dependency of input.dependsOn ?? []) this.require(dependency);
106
+ const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
107
+ if (input.gates !== undefined) extra["gates"] = input.gates;
108
+ if (input.checklist !== undefined) extra["checklist"] = validateChecklist(input.checklist);
109
+ const task = this.artifacts.create({
110
+ id: input.id,
111
+ kind: "task",
112
+ title: input.title,
113
+ body: input.body,
114
+ subtype: input.subtype,
115
+ status: input.status,
116
+ labels: input.labels,
117
+ extra,
118
+ templateId: input.templateId,
119
+ });
120
+ if (input.parentId) this.contain(input.parentId, task.id);
121
+ for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
122
+ this.appendEvent({ taskId: task.id, type: "created", toStatus: task.status as TaskStatus }, context);
123
+ return this.show(task.id);
106
124
  });
107
- if (input.parentId) this.contain(input.parentId, task.id);
108
- for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
109
- return this.show(task.id);
110
125
  }
111
126
 
112
127
  list(filter: TaskFilter = {}): Artifact[] {
@@ -185,48 +200,55 @@ export class Tasks {
185
200
  return task;
186
201
  }
187
202
 
188
- transition(id: string, action: TaskTransition): Artifact {
189
- const task = this.require(id);
190
- const transition = TASK_TRANSITIONS[action];
191
- if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
192
- if (action === "start") {
193
- const blocking = this.dependencyIds(id).filter((dependencyId) => this.require(dependencyId).status !== "done");
194
- if (blocking.length > 0) throw new Error(`task "${id}" is blocked by dependencies: ${blocking.join(", ")}`);
195
- this.focusStore.set(id);
196
- }
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;
203
+ transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
204
+ return this.events.atomic(() => {
205
+ const task = this.require(id);
206
+ const transition = TASK_TRANSITIONS[action];
207
+ if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
208
+ if (action === "start") {
209
+ const blocking = this.dependencyIds(id).filter((dependencyId) => this.require(dependencyId).status !== "done");
210
+ if (blocking.length > 0) throw new Error(`task "${id}" is blocked by dependencies: ${blocking.join(", ")}`);
211
+ this.focusStore.set(id);
212
+ }
213
+ const updated = this.artifacts.setStatus(id, transition.to)!;
214
+ const eventType = { start: "started", submit: "submitted", reject: "review_rejected", retry: "retried", cancel: "canceled" }[action] as AppendTaskEvent["type"];
215
+ this.appendEvent({ taskId: id, type: eventType, fromStatus: task.status as TaskStatus, toStatus: transition.to }, context);
216
+ if (action === "start" || action === "retry") this.propagateProgressToAncestors(id, context);
217
+ if (action === "retry") this.focusStore.set(id);
218
+ if (action === "cancel") this.focusStore.clear(id);
219
+ return updated;
220
+ });
202
221
  }
203
222
 
204
- complete(id: string): TaskCompletion {
223
+ complete(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): TaskCompletion {
205
224
  const task = this.requireReview(id);
225
+ const attemptId = crypto.randomUUID();
226
+ this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
206
227
  const checklist = this.reviewChecklist(task);
207
228
  const results = this.gates.run(id);
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: [] };
211
- }
212
- return this.finish(id, results, checklist);
229
+ return this.resolveCompletion(id, attemptId, results, checklist, context, options);
213
230
  }
214
231
 
215
- async completeAsync(id: string): Promise<TaskCompletion> {
232
+ async completeAsync(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): Promise<TaskCompletion> {
216
233
  const task = this.requireReview(id);
234
+ const attemptId = crypto.randomUUID();
235
+ this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
217
236
  const checklist = this.reviewChecklist(task);
237
+ const results = await this.gates.runAsync(id, { deadlineMs: options.gateDeadlineMs });
238
+ this.requireReview(id);
239
+ return this.resolveCompletion(id, attemptId, results, checklist, context, options);
240
+ }
241
+
242
+ async runGates(id: string, context: TaskEventContext = {}): Promise<GateResult[]> {
243
+ this.require(id);
218
244
  const results = await this.gates.runAsync(id);
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: [] };
222
- }
223
- const current = this.requireReview(id);
224
- return this.finish(current.id, results, checklist);
245
+ this.events.atomic(() => this.appendEvent({ taskId: id, type: "gates_evaluated", evidence: { gates: results, result: results.every((gate) => gate.passed) ? "passed" : "failed" } }, context));
246
+ return results;
225
247
  }
226
248
 
227
- runGates(id: string): Promise<GateResult[]> {
249
+ history(id: string, query: TaskHistoryQuery = {}): TaskHistoryPage {
228
250
  this.require(id);
229
- return this.gates.runAsync(id);
251
+ return this.events.history(id, query);
230
252
  }
231
253
 
232
254
  setChecklist(id: string, checklist: Checklist): Artifact {
@@ -234,6 +256,19 @@ export class Tasks {
234
256
  return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
235
257
  }
236
258
 
259
+ setAutomation(id: string, enabled: boolean, context: TaskEventContext = {}): Artifact {
260
+ return this.events.atomic(() => {
261
+ const task = this.require(id);
262
+ const current = task.extra["automation"];
263
+ const automation = typeof current === "object" && current !== null && !Array.isArray(current)
264
+ ? current as Record<string, unknown>
265
+ : {};
266
+ const updated = this.artifacts.setExtra(id, { ...task.extra, automation: { ...automation, enabled } })!;
267
+ this.appendEvent({ taskId: id, type: enabled ? "automation_enabled" : "automation_disabled" }, context);
268
+ return updated;
269
+ });
270
+ }
271
+
237
272
  depend(id: string, dependencyId: string): Artifact {
238
273
  this.require(id);
239
274
  this.require(dependencyId);
@@ -282,7 +317,7 @@ export class Tasks {
282
317
  .filter((parentId, index, ids) => ids.indexOf(parentId) === index);
283
318
  }
284
319
 
285
- private propagateProgressToAncestors(id: string): void {
320
+ private propagateProgressToAncestors(id: string, context: TaskEventContext): void {
286
321
  const pending = this.parentIds(id);
287
322
  const visited = new Set<string>();
288
323
  while (pending.length > 0) {
@@ -291,7 +326,14 @@ export class Tasks {
291
326
  if (visited.size >= TASK_EXECUTION_MAX_NODES) throw new Error("task ancestry exceeds execution node bound");
292
327
  visited.add(parentId);
293
328
  const parent = this.require(parentId);
294
- if (parent.status === "todo") this.artifacts.setStatus(parentId, "in-progress");
329
+ if (parent.status === "todo") {
330
+ this.artifacts.setStatus(parentId, "in-progress");
331
+ this.appendEvent({ taskId: parentId, type: "started", fromStatus: "todo", toStatus: "in-progress" }, {
332
+ ...context,
333
+ source: "task-ancestry",
334
+ reason: `nested task ${id} entered progress`,
335
+ });
336
+ }
295
337
  pending.push(...this.parentIds(parentId));
296
338
  }
297
339
  }
@@ -315,7 +357,33 @@ export class Tasks {
315
357
  return ids;
316
358
  }
317
359
 
318
- private finish(id: string, gates: GateResult[], checklist: ChecklistReview[]): TaskCompletion {
360
+ private resolveCompletion(
361
+ id: string,
362
+ attemptId: string,
363
+ gates: GateResult[],
364
+ checklist: ChecklistReview[],
365
+ context: TaskEventContext,
366
+ options: TaskCompletionOptions,
367
+ ): TaskCompletion {
368
+ const failed = gates.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted);
369
+ if (failed) {
370
+ return this.events.atomic(() => {
371
+ const artifact = this.artifacts.setStatus(id, "rejected")!;
372
+ this.appendEvent({
373
+ taskId: id,
374
+ type: "review_rejected",
375
+ fromStatus: "review",
376
+ toStatus: "rejected",
377
+ attemptId,
378
+ evidence: { gates, checklist, result: "rejected" },
379
+ }, context);
380
+ return { artifact, gates, checklist, completed: false, focused: this.active(), blocked: [] };
381
+ });
382
+ }
383
+ return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context, options));
384
+ }
385
+
386
+ private finish(id: string, attemptId: string, gates: GateResult[], checklist: ChecklistReview[], context: TaskEventContext, options: TaskCompletionOptions): TaskCompletion {
319
387
  const successorIds = this.relationships(id)
320
388
  .filter((edge) => edge.relation === "depends_on" && edge.to === id)
321
389
  .map((edge) => edge.from);
@@ -323,6 +391,14 @@ export class Tasks {
323
391
  throw new Error(`task "${id}" exceeds ${TASK_EXECUTION_MAX_DEGREE} successors`);
324
392
  }
325
393
  const artifact = this.artifacts.setStatus(id, "done")!;
394
+ this.appendEvent({
395
+ taskId: id,
396
+ type: "completed",
397
+ fromStatus: "review",
398
+ toStatus: "done",
399
+ attemptId,
400
+ evidence: { gates, checklist, result: "completed" },
401
+ }, context);
326
402
  this.focusStore.clear(id);
327
403
  const blocked: TaskBlockage[] = [];
328
404
  let focused: Artifact | null = null;
@@ -335,7 +411,7 @@ export class Tasks {
335
411
  blocked.push({ artifact: successor, dependencyIds });
336
412
  continue;
337
413
  }
338
- if (!focused) {
414
+ if (options.focusSuccessor !== false && !focused) {
339
415
  this.focusStore.set(successor.id);
340
416
  focused = successor;
341
417
  }
@@ -343,6 +419,16 @@ export class Tasks {
343
419
  return { artifact, gates, checklist, completed: true, focused, blocked };
344
420
  }
345
421
 
422
+ private appendEvent(event: Omit<AppendTaskEvent, "actor" | "source">, context: TaskEventContext): void {
423
+ this.events.append({
424
+ ...event,
425
+ actor: context.actor ?? "system",
426
+ source: context.source ?? "task-domain",
427
+ ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),
428
+ ...(context.reason === undefined ? {} : { reason: context.reason }),
429
+ });
430
+ }
431
+
346
432
  private requireReview(id: string): Artifact {
347
433
  const task = this.require(id);
348
434
  if (task.status !== "review") throw new Error(`cannot complete task from ${task.status}`);